bridge.py 3.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
  1. import os
  2. import sys
  3. import time
  4. import socket
  5. import struct
  6. import numpy as np
  7. # Network settings
  8. UDP_IP = os.environ.get("UDP_IP", "239.0.0.1")
  9. UDP_PORT = int(os.environ.get("UDP_PORT", "11988"))
  10. # Audio stream constants
  11. SAMPLE_RATE = 44100
  12. CHUNK_SIZE = 1024
  13. CHUNK_DURATION = CHUNK_SIZE / SAMPLE_RATE
  14. FRAME_BYTES = CHUNK_SIZE * 4
  15. MAX_SILENT_FRAMES = 43 # ~1 second of silence at 43.06 FPS
  16. # DSP tuning environment overrides
  17. FREQ_MIN = float(os.environ.get("FREQ_MIN", "150.0"))
  18. FREQ_MAX = float(os.environ.get("FREQ_MAX", "6000.0"))
  19. GAIN_MULT = float(os.environ.get("GAIN_MULT", "3500.0"))
  20. SILENCE_THRESHOLD = float(os.environ.get("SILENCE_THRESHOLD", "0.5"))
  21. sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
  22. sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
  23. # Pre-compute Hanning window to save CPU cycles inside the loop
  24. HANNING_WINDOW = np.hanning(CHUNK_SIZE).astype(np.float32)
  25. # Compute 16 logarithmic frequency bands
  26. FREQ_EDGES = np.logspace(np.log10(FREQ_MIN), np.log10(FREQ_MAX), 17)
  27. fft_freqs = np.fft.rfftfreq(CHUNK_SIZE, 1.0 / SAMPLE_RATE)
  28. bins_idx = []
  29. for i in range(16):
  30. low = FREQ_EDGES[i]
  31. high = FREQ_EDGES[i + 1]
  32. idx = np.where((fft_freqs >= low) & (fft_freqs < high) & (fft_freqs > 0))[0]
  33. if len(idx) == 0:
  34. non_zero_bins = np.where(fft_freqs > 0)[0]
  35. closest = non_zero_bins[np.argmin(np.abs(fft_freqs[non_zero_bins] - low))]
  36. idx = [closest]
  37. bins_idx.append(idx)
  38. sample_smth = 0.0
  39. silence_frames = 0
  40. STRUCT_FMT_V2 = "<6s2xffB3x16sd"
  41. start_time = None
  42. frames_processed = 0
  43. while True:
  44. raw_data = sys.stdin.buffer.read(FRAME_BYTES)
  45. if not raw_data or len(raw_data) < FRAME_BYTES:
  46. break
  47. now = time.perf_counter()
  48. # Start the master hardware clock the exact millisecond the first audio byte arrives
  49. if start_time is None:
  50. start_time = now
  51. # Do the DSP math
  52. audio = np.frombuffer(raw_data, dtype=np.int16).astype(np.float32)
  53. left = audio[0::2]
  54. right = audio[1::2]
  55. mono = (left + right) * (1.0 / 65536.0)
  56. raw_mag = float(np.max(np.abs(mono)) * 255.0)
  57. sample_smth = 0.7 * sample_smth + 0.3 * raw_mag
  58. sample_peak = 1 if raw_mag > (sample_smth * 1.5 + 20) else 0
  59. if raw_mag < SILENCE_THRESHOLD:
  60. silence_frames += 1
  61. else:
  62. silence_frames = 0
  63. if silence_frames <= MAX_SILENT_FRAMES:
  64. windowed = mono * HANNING_WINDOW
  65. fft_vals = np.abs(np.fft.rfft(windowed)) * (2.0 / CHUNK_SIZE)
  66. fft_result = bytearray(16)
  67. for i in range(16):
  68. energy = float(np.mean(fft_vals[bins_idx[i]])) * GAIN_MULT
  69. fft_result[i] = min(255, int(np.clip(energy, 0, 255)))
  70. payload = struct.pack(
  71. STRUCT_FMT_V2,
  72. b"00002\x00",
  73. float(raw_mag),
  74. float(sample_smth),
  75. sample_peak,
  76. bytes(fft_result),
  77. float(raw_mag)
  78. )
  79. try:
  80. sock.sendto(payload, (UDP_IP, UDP_PORT))
  81. except Exception:
  82. pass
  83. # PERFECT METRONOME PACING
  84. frames_processed += 1
  85. target_time = start_time + (frames_processed * CHUNK_DURATION)
  86. sleep_time = target_time - time.perf_counter()
  87. if sleep_time > 0:
  88. time.sleep(sleep_time)
  89. elif sleep_time < -2.0:
  90. # Only snap the clock if the container actually suspended or froze
  91. # for over 2 seconds so we don't spam a million packets at once.
  92. start_time = time.perf_counter()
  93. frames_processed = 0