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:
+166
-36
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user