2026-04-29 16:21:44 +10:00
|
|
|
# audio_engine.py
|
|
|
|
|
|
|
|
|
|
import numpy as np
|
|
|
|
|
import soundfile as sf
|
|
|
|
|
import jack
|
|
|
|
|
import threading
|
|
|
|
|
import subprocess
|
|
|
|
|
import os
|
2026-07-17 20:44:15 +10:00
|
|
|
import time
|
2026-05-08 17:19:11 +10:00
|
|
|
import pyloudnorm as pyln
|
2026-07-17 20:44:15 +10:00
|
|
|
from datetime import datetime
|
2026-04-29 16:21:44 +10:00
|
|
|
|
|
|
|
|
|
2026-05-08 17:19:11 +10:00
|
|
|
LUFS_TARGET = -14.0
|
2026-04-29 16:21:44 +10:00
|
|
|
SOUNDFILE_FORMATS = {'.wav', '.flac', '.ogg', '.aiff', '.aif'}
|
|
|
|
|
FFMPEG_FORMATS = {'.mp3', '.m4a', '.mp4', '.opus', '.aac', '.wma'}
|
|
|
|
|
|
2026-05-12 19:29:16 +10:00
|
|
|
NUM_MIXER_CHANNELS = 8
|
|
|
|
|
DEFAULT_CHANNEL_NAMES = [
|
2026-05-15 00:36:48 +10:00
|
|
|
"Mic 1", "Mic 2", "Aux 1",
|
|
|
|
|
"Aux 2", "Aux 3", "Aux 4", "Aux 5", "Aux 6",
|
2026-05-12 19:29:16 +10:00
|
|
|
]
|
|
|
|
|
|
2026-04-29 16:21:44 +10:00
|
|
|
|
|
|
|
|
def load_audio(filepath: str, target_sr: int) -> np.ndarray:
|
|
|
|
|
ext = os.path.splitext(filepath)[1].lower()
|
|
|
|
|
|
|
|
|
|
if ext in SOUNDFILE_FORMATS:
|
|
|
|
|
data, sr = sf.read(filepath, always_2d=True, dtype='float32')
|
|
|
|
|
data = data.T
|
|
|
|
|
|
|
|
|
|
elif ext in FFMPEG_FORMATS:
|
|
|
|
|
cmd = [
|
|
|
|
|
'ffmpeg', '-i', filepath, '-f', 'f32le', '-acodec', 'pcm_f32le',
|
|
|
|
|
'-ar', str(target_sr), '-ac', '2', '-', '-loglevel', 'quiet'
|
|
|
|
|
]
|
|
|
|
|
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
|
|
|
if result.returncode != 0:
|
|
|
|
|
raise ValueError(f"FFmpeg failed: {result.stderr.decode()}")
|
|
|
|
|
raw = np.frombuffer(result.stdout, dtype=np.float32)
|
|
|
|
|
data = raw.reshape(-1, 2).T
|
|
|
|
|
sr = target_sr
|
|
|
|
|
|
|
|
|
|
else:
|
|
|
|
|
try:
|
|
|
|
|
data, sr = sf.read(filepath, always_2d=True, dtype='float32')
|
|
|
|
|
data = data.T
|
|
|
|
|
except Exception as e:
|
|
|
|
|
raise ValueError(f"Unsupported format: {ext} — {e}")
|
|
|
|
|
|
|
|
|
|
if sr != target_sr:
|
|
|
|
|
import librosa
|
|
|
|
|
data = np.array([
|
|
|
|
|
librosa.resample(data[ch], orig_sr=sr, target_sr=target_sr)
|
|
|
|
|
for ch in range(data.shape[0])
|
|
|
|
|
], dtype=np.float32)
|
|
|
|
|
|
|
|
|
|
if data.shape[0] == 1:
|
|
|
|
|
data = np.vstack([data, data])
|
|
|
|
|
|
|
|
|
|
data = np.clip(data, -1.0, 1.0)
|
|
|
|
|
return data
|
|
|
|
|
|
|
|
|
|
|
2026-05-08 17:19:11 +10:00
|
|
|
def normalize_lufs(audio: np.ndarray, sr: int,
|
|
|
|
|
target: float = LUFS_TARGET) -> np.ndarray:
|
|
|
|
|
meter = pyln.Meter(sr)
|
|
|
|
|
loudness = meter.integrated_loudness(audio.T)
|
|
|
|
|
if np.isinf(loudness):
|
|
|
|
|
return audio
|
|
|
|
|
gain_db = target - loudness
|
|
|
|
|
gain_linear = 10 ** (gain_db / 20.0)
|
|
|
|
|
normalized = audio * gain_linear
|
|
|
|
|
return np.clip(normalized, -1.0, 1.0)
|
|
|
|
|
|
|
|
|
|
|
2026-04-29 16:21:44 +10:00
|
|
|
class Track:
|
|
|
|
|
def __init__(self, filepath: str, audio: np.ndarray, sr: int):
|
|
|
|
|
self.filepath = filepath
|
|
|
|
|
self.filename = os.path.basename(filepath)
|
|
|
|
|
self.audio = audio
|
|
|
|
|
self.sr = sr
|
|
|
|
|
self.num_samples = audio.shape[1]
|
|
|
|
|
self.duration = self.num_samples / sr
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class DeckState:
|
|
|
|
|
def __init__(self):
|
|
|
|
|
self.track = None
|
|
|
|
|
self.queued = None
|
|
|
|
|
self.position = 0
|
|
|
|
|
self.playing = False
|
|
|
|
|
self.lock = threading.Lock()
|
2026-05-14 22:25:10 +10:00
|
|
|
self.manual_elapsed = 0.0
|
|
|
|
|
self.manual_duration = 0.0
|
2026-04-29 16:21:44 +10:00
|
|
|
|
|
|
|
|
def load(self, track: Track):
|
|
|
|
|
with self.lock:
|
|
|
|
|
self.track = track
|
|
|
|
|
self.position = 0
|
|
|
|
|
self.playing = False
|
|
|
|
|
|
|
|
|
|
def load_queue(self, track: Track):
|
|
|
|
|
with self.lock:
|
|
|
|
|
self.queued = track
|
|
|
|
|
|
|
|
|
|
def play(self):
|
|
|
|
|
with self.lock:
|
|
|
|
|
if self.track:
|
|
|
|
|
self.playing = True
|
|
|
|
|
|
|
|
|
|
def pause(self):
|
|
|
|
|
with self.lock:
|
|
|
|
|
self.playing = False
|
|
|
|
|
|
|
|
|
|
def stop(self):
|
|
|
|
|
with self.lock:
|
|
|
|
|
self.playing = False
|
|
|
|
|
self.track = None
|
|
|
|
|
self.position = 0
|
|
|
|
|
|
|
|
|
|
def skip_seconds(self, seconds: int):
|
|
|
|
|
with self.lock:
|
|
|
|
|
if self.track:
|
|
|
|
|
new = self.position + int(seconds * self.track.sr)
|
|
|
|
|
self.position = max(0, min(new, self.track.num_samples - 1))
|
|
|
|
|
|
|
|
|
|
def go_to_start(self):
|
|
|
|
|
with self.lock:
|
|
|
|
|
self.position = 0
|
|
|
|
|
|
|
|
|
|
def go_to_end(self):
|
|
|
|
|
with self.lock:
|
|
|
|
|
if self.track:
|
|
|
|
|
self.position = self.track.num_samples - 1
|
|
|
|
|
|
|
|
|
|
def get_elapsed(self) -> str:
|
2026-05-14 22:25:10 +10:00
|
|
|
with self.lock:
|
|
|
|
|
if not self.track:
|
|
|
|
|
return "00:00"
|
|
|
|
|
s = self.position / self.track.sr
|
2026-04-29 16:21:44 +10:00
|
|
|
return f"{int(s//60):02d}:{int(s%60):02d}"
|
|
|
|
|
|
|
|
|
|
def get_remaining(self) -> str:
|
2026-05-14 22:25:10 +10:00
|
|
|
with self.lock:
|
|
|
|
|
if not self.track:
|
|
|
|
|
return "-00:00"
|
|
|
|
|
remaining = max(0, self.track.duration - (self.position / self.track.sr))
|
2026-04-29 16:21:44 +10:00
|
|
|
return f"-{int(remaining//60):02d}:{int(remaining%60):02d}"
|
|
|
|
|
|
|
|
|
|
|
2026-05-12 19:29:16 +10:00
|
|
|
class MixerChannel:
|
|
|
|
|
def __init__(self, name: str, index: int):
|
|
|
|
|
self.name = name
|
|
|
|
|
self.index = index
|
|
|
|
|
self.volume = 1.0
|
2026-05-15 16:42:10 +10:00
|
|
|
self.gain = 1.0 # pre-fader gain trim
|
2026-05-12 19:29:16 +10:00
|
|
|
self.muted = False
|
|
|
|
|
self.solo = False
|
2026-05-13 16:47:02 +10:00
|
|
|
self.pfl = False
|
2026-05-15 00:36:48 +10:00
|
|
|
self.mic_on = False
|
2026-05-12 19:29:16 +10:00
|
|
|
self.vu_peak = 0.0
|
2026-05-16 22:41:25 +10:00
|
|
|
self._vu_smooth = 0.0
|
2026-05-12 19:29:16 +10:00
|
|
|
self.in_port_L = None
|
|
|
|
|
self.in_port_R = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MasterBus:
|
|
|
|
|
def __init__(self, sr: int):
|
|
|
|
|
self.vu_peak = 0.0
|
2026-05-16 22:41:25 +10:00
|
|
|
self._vu_smooth = 0.0
|
2026-05-12 19:29:16 +10:00
|
|
|
self.lufs_current = -70.0
|
2026-05-16 22:41:25 +10:00
|
|
|
self.lufs_short = -70.0
|
|
|
|
|
self.lufs_long = -70.0
|
2026-05-14 22:25:10 +10:00
|
|
|
self.volume = 1.0
|
2026-05-15 16:42:10 +10:00
|
|
|
self.pfl_hp_blend = 0.5 # 0.0 = PFL only, 0.5 = equal, 1.0 = HP only
|
2026-06-12 15:48:59 +10:00
|
|
|
self.headphone_volume = 0.7
|
2026-05-14 22:25:10 +10:00
|
|
|
self._lufs_accum = 0
|
2026-05-12 19:29:16 +10:00
|
|
|
|
|
|
|
|
|
2026-04-29 16:21:44 +10:00
|
|
|
class AudioEngine:
|
|
|
|
|
def __init__(self):
|
|
|
|
|
self.client = jack.Client("RadioPanel")
|
|
|
|
|
self.sr = self.client.samplerate
|
|
|
|
|
|
2026-05-13 16:47:02 +10:00
|
|
|
# Output ports for digital decks (standalone, not mixed into master)
|
2026-04-29 16:21:44 +10:00
|
|
|
self.ports = {
|
|
|
|
|
'deck1': (
|
|
|
|
|
self.client.outports.register("deck1_L"),
|
|
|
|
|
self.client.outports.register("deck1_R"),
|
|
|
|
|
),
|
|
|
|
|
'deck2': (
|
|
|
|
|
self.client.outports.register("deck2_L"),
|
|
|
|
|
self.client.outports.register("deck2_R"),
|
|
|
|
|
),
|
|
|
|
|
'cart': (
|
|
|
|
|
self.client.outports.register("cart_L"),
|
|
|
|
|
self.client.outports.register("cart_R"),
|
|
|
|
|
),
|
2026-07-17 20:44:15 +10:00
|
|
|
'preview': (
|
|
|
|
|
self.client.outports.register("preview_out_L"),
|
|
|
|
|
self.client.outports.register("preview_out_R"),
|
|
|
|
|
),
|
2026-04-29 16:21:44 +10:00
|
|
|
}
|
|
|
|
|
|
2026-05-12 19:29:16 +10:00
|
|
|
# Mixer channels: 8 stereo input ports
|
|
|
|
|
self.mixer = {
|
|
|
|
|
'channels': [],
|
|
|
|
|
'master': MasterBus(self.sr),
|
|
|
|
|
}
|
|
|
|
|
for i in range(NUM_MIXER_CHANNELS):
|
|
|
|
|
ch = MixerChannel(DEFAULT_CHANNEL_NAMES[i], i)
|
|
|
|
|
ch.in_port_L = self.client.inports.register(f"mixer_ch_{i+1:02d}_in_L")
|
|
|
|
|
ch.in_port_R = self.client.inports.register(f"mixer_ch_{i+1:02d}_in_R")
|
|
|
|
|
self.mixer['channels'].append(ch)
|
|
|
|
|
|
|
|
|
|
# Master output ports
|
|
|
|
|
self.master_out = (
|
|
|
|
|
self.client.outports.register("master_out_L"),
|
|
|
|
|
self.client.outports.register("master_out_R"),
|
2026-04-30 00:30:23 +10:00
|
|
|
)
|
|
|
|
|
|
2026-05-15 00:36:48 +10:00
|
|
|
# Headphone output ports (all channels except mics)
|
|
|
|
|
self.headphone_out = (
|
|
|
|
|
self.client.outports.register("headphone_out_L"),
|
|
|
|
|
self.client.outports.register("headphone_out_R"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Studio output ports (same as main, mutes when any mic is on)
|
|
|
|
|
self.studio_out = (
|
|
|
|
|
self.client.outports.register("studio_out_L"),
|
|
|
|
|
self.client.outports.register("studio_out_R"),
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-17 20:44:15 +10:00
|
|
|
# Music mix output (aux channels 3-8 only, no mics)
|
|
|
|
|
self.music_mix_out = (
|
|
|
|
|
self.client.outports.register("music_mix_L"),
|
|
|
|
|
self.client.outports.register("music_mix_R"),
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-13 16:47:02 +10:00
|
|
|
# PFL output ports
|
|
|
|
|
self.pfl_out = (
|
|
|
|
|
self.client.outports.register("pfl_out_L"),
|
|
|
|
|
self.client.outports.register("pfl_out_R"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Deck states (for digital decks and carts)
|
2026-04-29 16:21:44 +10:00
|
|
|
self.decks = {
|
|
|
|
|
'deck1': DeckState(),
|
|
|
|
|
'deck2': DeckState(),
|
|
|
|
|
'cart1': DeckState(),
|
2026-07-17 20:44:15 +10:00
|
|
|
'preview': DeckState(),
|
2026-04-29 16:21:44 +10:00
|
|
|
}
|
2026-07-17 20:44:15 +10:00
|
|
|
self.cart_queue = []
|
|
|
|
|
self.cart_continue = False
|
|
|
|
|
|
|
|
|
|
self.logger = None
|
|
|
|
|
self.mic_active_start = None
|
|
|
|
|
self._mic_duration = 0.0
|
|
|
|
|
self._mic_was_on = False
|
|
|
|
|
|
|
|
|
|
self.recording = False
|
|
|
|
|
self._record_buf = []
|
|
|
|
|
self._record_fade_samples = int(self.sr * 0.01)
|
|
|
|
|
self._record_fade_in_count = 0
|
|
|
|
|
self._record_fade_out_pending = False
|
2026-04-29 16:21:44 +10:00
|
|
|
|
|
|
|
|
self.client.set_process_callback(self._process)
|
|
|
|
|
self.client.activate()
|
|
|
|
|
|
2026-05-12 19:29:16 +10:00
|
|
|
def get_channel(self, index: int) -> MixerChannel:
|
|
|
|
|
return self.mixer['channels'][index]
|
|
|
|
|
|
|
|
|
|
def set_channel_volume(self, index: int, volume: float):
|
|
|
|
|
if 0 <= index < NUM_MIXER_CHANNELS:
|
|
|
|
|
self.mixer['channels'][index].volume = max(0.0, min(1.0, volume))
|
|
|
|
|
|
2026-05-15 16:42:10 +10:00
|
|
|
def set_channel_gain(self, index: int, gain: float):
|
|
|
|
|
if 0 <= index < NUM_MIXER_CHANNELS:
|
|
|
|
|
self.mixer['channels'][index].gain = max(0.0, min(2.0, gain))
|
|
|
|
|
|
2026-05-12 19:29:16 +10:00
|
|
|
def set_channel_mute(self, index: int, muted: bool):
|
|
|
|
|
if 0 <= index < NUM_MIXER_CHANNELS:
|
|
|
|
|
self.mixer['channels'][index].muted = muted
|
|
|
|
|
|
|
|
|
|
def set_channel_solo(self, index: int, solo: bool):
|
|
|
|
|
if 0 <= index < NUM_MIXER_CHANNELS:
|
|
|
|
|
self.mixer['channels'][index].solo = solo
|
|
|
|
|
|
2026-05-13 16:47:02 +10:00
|
|
|
def set_channel_pfl(self, index: int, pfl: bool):
|
|
|
|
|
if 0 <= index < NUM_MIXER_CHANNELS:
|
|
|
|
|
self.mixer['channels'][index].pfl = pfl
|
|
|
|
|
|
2026-05-15 00:36:48 +10:00
|
|
|
def set_channel_mic_on(self, index: int, on: bool):
|
|
|
|
|
if 0 <= index < NUM_MIXER_CHANNELS:
|
|
|
|
|
self.mixer['channels'][index].mic_on = on
|
|
|
|
|
|
|
|
|
|
def get_channel_mic_on(self, index: int) -> bool:
|
|
|
|
|
if 0 <= index < NUM_MIXER_CHANNELS:
|
|
|
|
|
return self.mixer['channels'][index].mic_on
|
|
|
|
|
return False
|
|
|
|
|
|
2026-04-29 16:21:44 +10:00
|
|
|
def _fill_port(self, port_L, port_R, deck: DeckState, frames: int):
|
|
|
|
|
buf_L = port_L.get_array()
|
|
|
|
|
buf_R = port_R.get_array()
|
|
|
|
|
buf_L.fill(0.0)
|
|
|
|
|
buf_R.fill(0.0)
|
|
|
|
|
|
2026-05-14 22:25:10 +10:00
|
|
|
with deck.lock:
|
|
|
|
|
playing = deck.playing
|
|
|
|
|
track = deck.track
|
|
|
|
|
pos = deck.position
|
|
|
|
|
|
|
|
|
|
if not playing or track is None:
|
2026-04-29 16:21:44 +10:00
|
|
|
return
|
|
|
|
|
|
|
|
|
|
end = min(pos + frames, track.num_samples)
|
|
|
|
|
n = end - pos
|
|
|
|
|
|
|
|
|
|
if n <= 0:
|
2026-05-14 22:25:10 +10:00
|
|
|
with deck.lock:
|
|
|
|
|
deck.playing = False
|
2026-04-29 16:21:44 +10:00
|
|
|
return
|
|
|
|
|
|
|
|
|
|
buf_L[:n] = track.audio[0, pos:end]
|
|
|
|
|
buf_R[:n] = track.audio[1, pos:end]
|
|
|
|
|
|
2026-05-14 22:25:10 +10:00
|
|
|
with deck.lock:
|
|
|
|
|
deck.position += n
|
|
|
|
|
if deck.position >= track.num_samples:
|
|
|
|
|
deck.playing = False
|
|
|
|
|
deck.position = track.num_samples - 1
|
2026-04-29 16:21:44 +10:00
|
|
|
|
|
|
|
|
def _process(self, frames: int):
|
2026-05-13 16:47:02 +10:00
|
|
|
# ── Digital decks ───────────────────────────────────────────────
|
2026-04-29 16:21:44 +10:00
|
|
|
self._fill_port(*self.ports['deck1'], self.decks['deck1'], frames)
|
|
|
|
|
self._fill_port(*self.ports['deck2'], self.decks['deck2'], frames)
|
|
|
|
|
|
2026-07-17 20:44:15 +10:00
|
|
|
# ── Preview deck ─────────────────────────────────────────────
|
|
|
|
|
self._fill_port(*self.ports['preview'], self.decks['preview'], frames)
|
|
|
|
|
|
|
|
|
|
# ── Mix cart ───────────────────────────────────────────────────
|
2026-04-29 16:21:44 +10:00
|
|
|
buf_L = self.ports['cart'][0].get_array()
|
|
|
|
|
buf_R = self.ports['cart'][1].get_array()
|
|
|
|
|
buf_L.fill(0.0)
|
|
|
|
|
buf_R.fill(0.0)
|
|
|
|
|
|
2026-07-17 20:44:15 +10:00
|
|
|
cart_deck = self.decks['cart1']
|
|
|
|
|
with cart_deck.lock:
|
|
|
|
|
playing = cart_deck.playing
|
|
|
|
|
track = cart_deck.track
|
|
|
|
|
pos = cart_deck.position
|
|
|
|
|
if playing and track is not None:
|
|
|
|
|
end = min(pos + frames, track.num_samples)
|
|
|
|
|
n = end - pos
|
|
|
|
|
if n > 0:
|
|
|
|
|
buf_L[:n] = track.audio[0, pos:end]
|
|
|
|
|
buf_R[:n] = track.audio[1, pos:end]
|
|
|
|
|
with cart_deck.lock:
|
|
|
|
|
cart_deck.position += max(n, 0)
|
|
|
|
|
if cart_deck.position >= track.num_samples:
|
|
|
|
|
cart_deck.playing = False
|
|
|
|
|
cart_deck.position = track.num_samples - 1
|
|
|
|
|
if self.cart_continue and self.cart_queue:
|
|
|
|
|
next_track = self.cart_queue.pop(0)
|
|
|
|
|
cart_deck.track = next_track
|
|
|
|
|
cart_deck.position = 0
|
|
|
|
|
cart_deck.playing = True
|
|
|
|
|
if self.logger:
|
|
|
|
|
self.logger.log_cart_trigger(next_track.filename)
|
|
|
|
|
elif not self.cart_queue:
|
|
|
|
|
self.cart_continue = False
|
2026-04-29 16:21:44 +10:00
|
|
|
|
2026-05-12 19:29:16 +10:00
|
|
|
# ── Mixer ───────────────────────────────────────────────────────
|
2026-05-16 22:41:25 +10:00
|
|
|
dt = frames / self.sr
|
|
|
|
|
vu_decay = np.exp(-dt / 0.15)
|
|
|
|
|
dt = frames / self.sr
|
|
|
|
|
vu_decay = np.exp(-dt / 0.15)
|
2026-05-12 19:29:16 +10:00
|
|
|
master_buf_L = self.master_out[0].get_array()
|
|
|
|
|
master_buf_R = self.master_out[1].get_array()
|
|
|
|
|
master_buf_L.fill(0.0)
|
|
|
|
|
master_buf_R.fill(0.0)
|
|
|
|
|
|
2026-05-15 00:36:48 +10:00
|
|
|
headphone_buf_L = self.headphone_out[0].get_array()
|
|
|
|
|
headphone_buf_R = self.headphone_out[1].get_array()
|
|
|
|
|
headphone_buf_L.fill(0.0)
|
|
|
|
|
headphone_buf_R.fill(0.0)
|
|
|
|
|
|
|
|
|
|
studio_buf_L = self.studio_out[0].get_array()
|
|
|
|
|
studio_buf_R = self.studio_out[1].get_array()
|
|
|
|
|
studio_buf_L.fill(0.0)
|
|
|
|
|
studio_buf_R.fill(0.0)
|
|
|
|
|
|
2026-05-13 16:47:02 +10:00
|
|
|
pfl_buf_L = self.pfl_out[0].get_array()
|
|
|
|
|
pfl_buf_R = self.pfl_out[1].get_array()
|
|
|
|
|
pfl_buf_L.fill(0.0)
|
|
|
|
|
pfl_buf_R.fill(0.0)
|
|
|
|
|
|
2026-07-17 20:44:15 +10:00
|
|
|
music_buf_L = self.music_mix_out[0].get_array()
|
|
|
|
|
music_buf_R = self.music_mix_out[1].get_array()
|
|
|
|
|
music_buf_L.fill(0.0)
|
|
|
|
|
music_buf_R.fill(0.0)
|
|
|
|
|
|
2026-05-16 22:41:25 +10:00
|
|
|
channels = self.mixer['channels']
|
|
|
|
|
any_solo = any(ch.solo for ch in channels)
|
|
|
|
|
any_mic_on = any(ch.mic_on for ch in channels[:2])
|
|
|
|
|
any_pfl = any(ch.pfl for ch in channels)
|
|
|
|
|
|
2026-07-17 20:44:15 +10:00
|
|
|
now = time.time()
|
|
|
|
|
if any_mic_on and not self._mic_was_on:
|
|
|
|
|
self.mic_active_start = now
|
|
|
|
|
if self.logger:
|
|
|
|
|
active_idx = next((ch.index for ch in channels[:2] if ch.mic_on), 0)
|
|
|
|
|
self.logger.log_mic_on(active_idx)
|
|
|
|
|
self._mic_duration = 0.0
|
|
|
|
|
elif not any_mic_on and self._mic_was_on and self.mic_active_start is not None:
|
|
|
|
|
duration = now - self.mic_active_start
|
|
|
|
|
self._mic_duration = 0.0
|
|
|
|
|
self.mic_active_start = None
|
|
|
|
|
if self.logger:
|
|
|
|
|
self.logger.log_mic_off(0, duration)
|
|
|
|
|
elif any_mic_on and self.mic_active_start is not None:
|
|
|
|
|
self._mic_duration = now - self.mic_active_start
|
|
|
|
|
self._mic_was_on = any_mic_on
|
|
|
|
|
|
2026-05-16 22:41:25 +10:00
|
|
|
for ch in channels:
|
2026-05-12 19:29:16 +10:00
|
|
|
in_L = ch.in_port_L.get_array()
|
|
|
|
|
in_R = ch.in_port_R.get_array()
|
|
|
|
|
|
|
|
|
|
mute = ch.muted or (any_solo and not ch.solo)
|
2026-05-15 00:36:48 +10:00
|
|
|
is_mic = ch.index < 2
|
2026-05-15 16:42:10 +10:00
|
|
|
g = ch.gain
|
2026-05-12 19:29:16 +10:00
|
|
|
|
2026-05-13 16:47:02 +10:00
|
|
|
if ch.pfl:
|
2026-05-15 16:42:10 +10:00
|
|
|
pfl_buf_L[:] += in_L[:] * g
|
|
|
|
|
pfl_buf_R[:] += in_R[:] * g
|
2026-05-13 16:47:02 +10:00
|
|
|
|
2026-05-12 19:29:16 +10:00
|
|
|
if mute:
|
|
|
|
|
ch.vu_peak = 0.0
|
2026-05-16 22:41:25 +10:00
|
|
|
ch._vu_smooth = 0.0
|
2026-05-12 19:29:16 +10:00
|
|
|
continue
|
|
|
|
|
|
2026-05-16 22:41:25 +10:00
|
|
|
factor = g * ch.volume
|
2026-05-15 00:36:48 +10:00
|
|
|
goes_to_main = not is_mic or ch.mic_on
|
|
|
|
|
goes_to_headphone = not is_mic
|
|
|
|
|
|
2026-05-16 22:41:25 +10:00
|
|
|
if goes_to_main:
|
|
|
|
|
master_buf_L[:] += in_L[:] * factor
|
|
|
|
|
master_buf_R[:] += in_R[:] * factor
|
|
|
|
|
if goes_to_headphone:
|
|
|
|
|
headphone_buf_L[:] += in_L[:] * factor
|
|
|
|
|
headphone_buf_R[:] += in_R[:] * factor
|
2026-05-12 19:29:16 +10:00
|
|
|
|
2026-07-17 20:44:15 +10:00
|
|
|
if ch.index >= 2:
|
|
|
|
|
music_buf_L[:] += in_L[:] * factor
|
|
|
|
|
music_buf_R[:] += in_R[:] * factor
|
|
|
|
|
|
2026-05-16 22:41:25 +10:00
|
|
|
peak = max(np.max(np.abs(in_L)), np.max(np.abs(in_R))) * factor
|
|
|
|
|
ch._vu_smooth = ch._vu_smooth * vu_decay + peak * (1 - vu_decay)
|
|
|
|
|
ch.vu_peak = ch._vu_smooth
|
2026-05-12 19:29:16 +10:00
|
|
|
|
2026-05-15 00:36:48 +10:00
|
|
|
# ── Studio bus: same as main, muted when any mic is on ────────
|
|
|
|
|
if any_mic_on:
|
|
|
|
|
studio_buf_L.fill(0.0)
|
|
|
|
|
studio_buf_R.fill(0.0)
|
|
|
|
|
else:
|
|
|
|
|
studio_buf_L[:] = master_buf_L[:]
|
|
|
|
|
studio_buf_R[:] = master_buf_R[:]
|
|
|
|
|
|
2026-05-15 16:42:10 +10:00
|
|
|
# ── PFL / Headphone blend ───────────────────────────────────────
|
2026-05-16 22:41:25 +10:00
|
|
|
if any_pfl:
|
|
|
|
|
blend = self.mixer['master'].pfl_hp_blend
|
|
|
|
|
hp_gain = blend
|
|
|
|
|
pfl_gain = 1.0 - blend
|
|
|
|
|
if pfl_gain > 0.0:
|
|
|
|
|
headphone_buf_L[:] = headphone_buf_L * hp_gain + pfl_buf_L * pfl_gain
|
|
|
|
|
headphone_buf_R[:] = headphone_buf_R * hp_gain + pfl_buf_R * pfl_gain
|
|
|
|
|
else:
|
|
|
|
|
headphone_buf_L[:] = pfl_buf_L[:]
|
|
|
|
|
headphone_buf_R[:] = pfl_buf_R[:]
|
|
|
|
|
|
|
|
|
|
# ── Master VU ──────────────────────────────────────────────────
|
|
|
|
|
master = self.mixer['master']
|
|
|
|
|
peak = max(np.max(np.abs(master_buf_L)), np.max(np.abs(master_buf_R)))
|
|
|
|
|
master._vu_smooth = master._vu_smooth * vu_decay + peak * (1 - vu_decay)
|
|
|
|
|
master.vu_peak = master._vu_smooth
|
2026-05-12 19:29:16 +10:00
|
|
|
|
2026-05-15 08:41:40 +10:00
|
|
|
vol = self.mixer['master'].volume
|
|
|
|
|
if vol < 1.0:
|
|
|
|
|
master_buf_L[:] *= vol
|
|
|
|
|
master_buf_R[:] *= vol
|
|
|
|
|
|
2026-07-17 20:44:15 +10:00
|
|
|
# ── LUFS metering from master mix ──────────────────────────
|
2026-05-14 22:25:10 +10:00
|
|
|
master._lufs_accum += frames
|
|
|
|
|
if master._lufs_accum >= 512:
|
2026-07-17 20:44:15 +10:00
|
|
|
lufs_dt = 512.0 / self.sr
|
2026-05-14 22:25:10 +10:00
|
|
|
master._lufs_accum -= 512
|
2026-07-17 20:44:15 +10:00
|
|
|
ms = (np.mean(master_buf_L[:] ** 2) + np.mean(master_buf_R[:] ** 2)) / 2.0
|
2026-05-16 22:41:25 +10:00
|
|
|
if ms < 1e-12:
|
|
|
|
|
master.lufs_current = -70.0
|
|
|
|
|
else:
|
|
|
|
|
master.lufs_current = -0.691 + 10 * np.log10(ms)
|
|
|
|
|
decay_short = np.exp(-lufs_dt / 4.0)
|
|
|
|
|
decay_long = np.exp(-lufs_dt / 600.0)
|
|
|
|
|
master.lufs_short = master.lufs_short * decay_short + master.lufs_current * (1 - decay_short)
|
|
|
|
|
master.lufs_long = master.lufs_long * decay_long + master.lufs_current * (1 - decay_long)
|
2026-04-29 16:21:44 +10:00
|
|
|
|
2026-07-17 20:44:15 +10:00
|
|
|
# ── Recording ─────────────────────────────────────────────────
|
|
|
|
|
if self.recording or self._record_fade_out_pending:
|
|
|
|
|
chunk_L = master_buf_L[:].copy()
|
|
|
|
|
chunk_R = master_buf_R[:].copy()
|
|
|
|
|
|
|
|
|
|
if self._record_fade_in_count < self._record_fade_samples:
|
|
|
|
|
n = min(frames, self._record_fade_samples - self._record_fade_in_count)
|
|
|
|
|
ramp = np.linspace(0, 1, n)
|
|
|
|
|
chunk_L[:n] *= ramp
|
|
|
|
|
chunk_R[:n] *= ramp
|
|
|
|
|
self._record_fade_in_count += n
|
|
|
|
|
|
|
|
|
|
if self._record_fade_out_pending:
|
|
|
|
|
fade_total = self._record_fade_samples
|
|
|
|
|
ramp = np.linspace(1, 0, frames)
|
|
|
|
|
chunk_L *= ramp
|
|
|
|
|
chunk_R *= ramp
|
|
|
|
|
self._record_buf.append(np.vstack([chunk_L, chunk_R]))
|
|
|
|
|
self._finish_recording()
|
|
|
|
|
else:
|
|
|
|
|
self._record_buf.append(np.vstack([chunk_L, chunk_R]))
|
|
|
|
|
|
2026-04-29 16:21:44 +10:00
|
|
|
def load_track(self, deck_key: str, filepath: str):
|
|
|
|
|
def _load():
|
|
|
|
|
audio = load_audio(filepath, self.sr)
|
|
|
|
|
track = Track(filepath, audio, self.sr)
|
|
|
|
|
self.decks[deck_key].load(track)
|
|
|
|
|
threading.Thread(target=_load, daemon=True).start()
|
|
|
|
|
|
2026-05-08 17:19:11 +10:00
|
|
|
def load_cart_track(self, cart_key: str, filepath: str):
|
|
|
|
|
def _load():
|
|
|
|
|
audio = load_audio(filepath, self.sr)
|
|
|
|
|
audio = normalize_lufs(audio, self.sr)
|
|
|
|
|
track = Track(filepath, audio, self.sr)
|
|
|
|
|
self.decks[cart_key].load(track)
|
|
|
|
|
threading.Thread(target=_load, daemon=True).start()
|
|
|
|
|
|
2026-04-29 16:21:44 +10:00
|
|
|
def load_queue(self, deck_key: str, filepath: str):
|
|
|
|
|
def _load():
|
|
|
|
|
audio = load_audio(filepath, self.sr)
|
|
|
|
|
track = Track(filepath, audio, self.sr)
|
|
|
|
|
self.decks[deck_key].load_queue(track)
|
|
|
|
|
threading.Thread(target=_load, daemon=True).start()
|
|
|
|
|
|
2026-07-17 20:44:15 +10:00
|
|
|
def add_to_cart_queue(self, filepath: str):
|
|
|
|
|
if len(self.cart_queue) >= 3:
|
|
|
|
|
return
|
|
|
|
|
def _load():
|
|
|
|
|
audio = load_audio(filepath, self.sr)
|
|
|
|
|
audio = normalize_lufs(audio, self.sr)
|
|
|
|
|
track = Track(filepath, audio, self.sr)
|
|
|
|
|
self.cart_queue.append(track)
|
|
|
|
|
threading.Thread(target=_load, daemon=True).start()
|
|
|
|
|
|
|
|
|
|
def clear_cart_queue(self):
|
|
|
|
|
self.cart_queue.clear()
|
|
|
|
|
|
|
|
|
|
def log_cart_trigger(self, track_name: str):
|
|
|
|
|
if self.logger:
|
|
|
|
|
self.logger.log_cart_trigger(track_name)
|
|
|
|
|
|
|
|
|
|
def get_mic_duration(self) -> float:
|
|
|
|
|
if self.mic_active_start is not None:
|
|
|
|
|
return time.time() - self.mic_active_start
|
|
|
|
|
return self._mic_duration
|
|
|
|
|
|
|
|
|
|
def start_recording(self):
|
|
|
|
|
self.recording = True
|
|
|
|
|
self._record_buf = []
|
|
|
|
|
self._record_fade_in_count = 0
|
|
|
|
|
self._record_fade_out_pending = False
|
|
|
|
|
if self.logger:
|
|
|
|
|
self.logger._write_raw({
|
|
|
|
|
"event": "recording_start",
|
|
|
|
|
"timestamp": time.time(),
|
|
|
|
|
"iso": datetime.now().isoformat(),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
def stop_recording(self):
|
|
|
|
|
if not self.recording:
|
|
|
|
|
return
|
|
|
|
|
self.recording = False
|
|
|
|
|
self._record_fade_out_pending = True
|
|
|
|
|
|
|
|
|
|
def _finish_recording(self):
|
|
|
|
|
self._record_fade_out_pending = False
|
|
|
|
|
if not self._record_buf:
|
|
|
|
|
return
|
|
|
|
|
filename = datetime.now().strftime("RadioPanel_%Y-%m-%d_%H%M%S.wav")
|
|
|
|
|
filepath = os.path.join(os.path.expanduser("~"), filename)
|
|
|
|
|
try:
|
|
|
|
|
full = np.concatenate(self._record_buf, axis=1)
|
|
|
|
|
sf.write(filepath, full.T, self.sr, subtype='PCM_16')
|
|
|
|
|
if self.logger:
|
|
|
|
|
self.logger._write_raw({
|
|
|
|
|
"event": "recording_stop",
|
|
|
|
|
"timestamp": time.time(),
|
|
|
|
|
"iso": datetime.now().isoformat(),
|
|
|
|
|
"filepath": filepath,
|
|
|
|
|
"duration_sec": round(full.shape[1] / self.sr, 3),
|
|
|
|
|
})
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
2026-04-29 16:21:44 +10:00
|
|
|
def shutdown(self):
|
|
|
|
|
self.client.deactivate()
|
2026-05-12 19:29:16 +10:00
|
|
|
self.client.close()
|