Overhaul: 5-task implementation

- Remove TURN decks (3/4), clean up MIDI, layout
- Single cart player with 3-item autoload queue + continue toggle
- Event logging (SessionLogger), mic tracking, large mic timer widget
- LUFS metering moved from external ports to internal master mix
- Master output WAV recording with top-bar REC toggle
- Music mix output (ch 3-8 only, no mics)
- Preview bar for playlist auditioning
- Merged show log with [SONG][CART][TALK] prefixes
- Live CSV writer for OBS song display (~/live_songs.csv)
- Misc fixes: preview race condition, datetime import for recording
This commit is contained in:
2026-07-17 20:44:15 +10:00
parent da8240a9e2
commit 0277089b2d
13 changed files with 1015 additions and 326 deletions
+166 -36
View File
@@ -6,7 +6,9 @@ import jack
import threading
import subprocess
import os
import time
import pyloudnorm as pyln
from datetime import datetime
LUFS_TARGET = -14.0
@@ -195,12 +197,12 @@ class AudioEngine:
self.client.outports.register("cart_L"),
self.client.outports.register("cart_R"),
),
'preview': (
self.client.outports.register("preview_out_L"),
self.client.outports.register("preview_out_R"),
),
}
# LUFS metering input (external processed signal)
self.lufs_in_L = self.client.inports.register("lufs_in_L")
self.lufs_in_R = self.client.inports.register("lufs_in_R")
# Mixer channels: 8 stereo input ports
self.mixer = {
'channels': [],
@@ -230,6 +232,12 @@ class AudioEngine:
self.client.outports.register("studio_out_R"),
)
# 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"),
)
# PFL output ports
self.pfl_out = (
self.client.outports.register("pfl_out_L"),
@@ -240,13 +248,22 @@ class AudioEngine:
self.decks = {
'deck1': DeckState(),
'deck2': DeckState(),
'deck3': DeckState(),
'deck4': DeckState(),
'cart1': DeckState(),
'cart2': DeckState(),
'cart3': DeckState(),
'cart4': DeckState(),
'preview': DeckState(),
}
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
self.client.set_process_callback(self._process)
self.client.activate()
@@ -319,33 +336,40 @@ class AudioEngine:
self._fill_port(*self.ports['deck1'], self.decks['deck1'], frames)
self._fill_port(*self.ports['deck2'], self.decks['deck2'], frames)
# ── Mix carts ───────────────────────────────────────────────────
# ── Preview deck ─────────────────────────────────────────────
self._fill_port(*self.ports['preview'], self.decks['preview'], frames)
# ── Mix cart ───────────────────────────────────────────────────
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)
for cart_key in ('cart1', 'cart2', 'cart3', 'cart4'):
deck = self.decks[cart_key]
with deck.lock:
playing = deck.playing
track = deck.track
pos = deck.position
if not playing or track is None:
continue
end = min(pos + frames, track.num_samples)
n = end - pos
if n <= 0:
with deck.lock:
deck.playing = False
continue
buf_L[:n] += track.audio[0, pos:end]
buf_R[:n] += track.audio[1, pos:end]
with deck.lock:
deck.position += n
if deck.position >= track.num_samples:
deck.playing = False
deck.position = track.num_samples - 1
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
# ── Mixer ───────────────────────────────────────────────────────
dt = frames / self.sr
@@ -372,11 +396,33 @@ class AudioEngine:
pfl_buf_L.fill(0.0)
pfl_buf_R.fill(0.0)
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)
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)
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
for ch in channels:
in_L = ch.in_port_L.get_array()
in_R = ch.in_port_R.get_array()
@@ -405,6 +451,10 @@ class AudioEngine:
headphone_buf_L[:] += in_L[:] * factor
headphone_buf_R[:] += in_R[:] * factor
if ch.index >= 2:
music_buf_L[:] += in_L[:] * factor
music_buf_R[:] += in_R[:] * factor
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
@@ -440,14 +490,12 @@ class AudioEngine:
master_buf_L[:] *= vol
master_buf_R[:] *= vol
# ── LUFS metering from external processing input ────────────────
# ── LUFS metering from master mix ──────────────────────────
master._lufs_accum += frames
if master._lufs_accum >= 512:
lufs_dt = master._lufs_accum / self.sr
lufs_dt = 512.0 / self.sr
master._lufs_accum -= 512
lufs_L = self.lufs_in_L.get_array()
lufs_R = self.lufs_in_R.get_array()
ms = (np.mean(lufs_L ** 2) + np.mean(lufs_R ** 2)) / 2.0
ms = (np.mean(master_buf_L[:] ** 2) + np.mean(master_buf_R[:] ** 2)) / 2.0
if ms < 1e-12:
master.lufs_current = -70.0
else:
@@ -457,6 +505,28 @@ class AudioEngine:
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)
# ── 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]))
def load_track(self, deck_key: str, filepath: str):
def _load():
audio = load_audio(filepath, self.sr)
@@ -479,6 +549,66 @@ class AudioEngine:
self.decks[deck_key].load_queue(track)
threading.Thread(target=_load, daemon=True).start()
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
def shutdown(self):
self.client.deactivate()
self.client.close()