bridge.py 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  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", "40.0")) # Catch deep sub-bass and 50Hz kicks
  18. FREQ_MAX = float(os.environ.get("FREQ_MAX", "12000.0")) # Capture crisp cymbals and high transients
  19. SILENCE_THRESHOLD = float(os.environ.get("SILENCE_THRESHOLD", "0.5"))
  20. # Dynamic AGC and Frequency Tilt overrides
  21. GAIN_MIN = float(os.environ.get("GAIN_MIN", "800.0")) # Floor: prevents squashing heavily mastered EDM
  22. GAIN_MAX = float(os.environ.get("GAIN_MAX", "8500.0")) # Ceiling: prevents boosting background tape hiss
  23. TILT_EXPONENT = float(os.environ.get("TILT_EXPONENT", "0.42")) # Treble tilt compensation
  24. DECAY_RATE = float(os.environ.get("DECAY_RATE", "0.995")) # Slightly faster recovery (~5s) for better bounce
  25. CONTRAST_EXPONENT = float(os.environ.get("CONTRAST_EXPONENT", "2.0")) # >1.0 crushes noise floor & exaggerates beats
  26. sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
  27. sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
  28. # Pre-compute Hanning window
  29. HANNING_WINDOW = np.hanning(CHUNK_SIZE).astype(np.float32)
  30. # Compute 16 logarithmic frequency bands
  31. FREQ_EDGES = np.logspace(np.log10(FREQ_MIN), np.log10(FREQ_MAX), 17)
  32. fft_freqs = np.fft.rfftfreq(CHUNK_SIZE, 1.0 / SAMPLE_RATE)
  33. bins_idx = []
  34. for i in range(16):
  35. low = FREQ_EDGES[i]
  36. high = FREQ_EDGES[i + 1]
  37. idx = np.where((fft_freqs >= low) & (fft_freqs < high) & (fft_freqs > 0))[0]
  38. if len(idx) == 0:
  39. non_zero_bins = np.where(fft_freqs > 0)[0]
  40. closest = non_zero_bins[np.argmin(np.abs(fft_freqs[non_zero_bins] - low))]
  41. idx = [closest]
  42. bins_idx.append(idx)
  43. # Pre-compute logarithmic treble tilt weights
  44. band_centers = np.sqrt(FREQ_EDGES[:-1] * FREQ_EDGES[1:])
  45. TILT_WEIGHTS = ((band_centers / FREQ_MIN) ** TILT_EXPONENT).astype(np.float32)
  46. # State variables
  47. sample_smth = 0.0
  48. silence_frames = 0
  49. running_peak = 0.05
  50. STRUCT_FMT_V2 = "<6s2xffB3x16sd"
  51. start_time = None
  52. frames_processed = 0
  53. while True:
  54. t0 = time.perf_counter()
  55. raw_data = sys.stdin.buffer.read(FRAME_BYTES)
  56. read_duration = time.perf_counter() - t0
  57. if not raw_data or len(raw_data) < FRAME_BYTES:
  58. break
  59. # Reset metronome and baseline on gap/pause
  60. if read_duration > 0.2:
  61. start_time = time.perf_counter()
  62. frames_processed = 0
  63. running_peak = 0.05
  64. if start_time is None:
  65. start_time = time.perf_counter()
  66. # Audio ingestion and magnitude extraction
  67. audio = np.frombuffer(raw_data, dtype=np.int16).astype(np.float32)
  68. left = audio[0::2]
  69. right = audio[1::2]
  70. mono = (left + right) * (1.0 / 65536.0)
  71. raw_mag = float(np.max(np.abs(mono)) * 255.0)
  72. sample_smth = 0.7 * sample_smth + 0.3 * raw_mag
  73. sample_peak = 1 if raw_mag > (sample_smth * 1.5 + 20) else 0
  74. if raw_mag < SILENCE_THRESHOLD:
  75. silence_frames += 1
  76. else:
  77. silence_frames = 0
  78. if silence_frames <= MAX_SILENT_FRAMES:
  79. windowed = mono * HANNING_WINDOW
  80. fft_vals = np.abs(np.fft.rfft(windowed)) * (2.0 / CHUNK_SIZE)
  81. # Vectorized band extraction
  82. raw_energies = np.empty(16, dtype=np.float32)
  83. for i in range(16):
  84. raw_energies[i] = np.mean(fft_vals[bins_idx[i]])
  85. # Apply logarithmic treble tilt
  86. tilted_energies = raw_energies * TILT_WEIGHTS
  87. # Track rolling peak
  88. current_max = float(np.max(tilted_energies))
  89. if current_max > running_peak:
  90. running_peak = current_max
  91. else:
  92. running_peak = max(0.005, running_peak * DECAY_RATE)
  93. # Dynamic gain bounded by floor and ceiling
  94. dynamic_gain = np.clip(1.0 / running_peak, GAIN_MIN / 255.0, GAIN_MAX / 255.0)
  95. # Normalize 0.0 to 1.0, apply contrast exponent, scale to 0-255
  96. normalized = np.clip(tilted_energies * dynamic_gain, 0.0, 1.0)
  97. contrasted = (normalized ** CONTRAST_EXPONENT) * 255.0
  98. fft_result = bytes(np.clip(contrasted, 0, 255).astype(np.uint8))
  99. payload = struct.pack(
  100. STRUCT_FMT_V2,
  101. b"00002\x00",
  102. float(raw_mag),
  103. float(sample_smth),
  104. sample_peak,
  105. fft_result,
  106. float(raw_mag)
  107. )
  108. try:
  109. sock.sendto(payload, (UDP_IP, UDP_PORT))
  110. except Exception:
  111. pass
  112. # Metronome pacing
  113. frames_processed += 1
  114. target_time = start_time + (frames_processed * CHUNK_DURATION)
  115. sleep_time = target_time - time.perf_counter()
  116. if sleep_time > 0:
  117. time.sleep(sleep_time)
  118. elif sleep_time < -1.0:
  119. start_time = time.perf_counter()
  120. frames_processed = 0