Add 8-channel mixer with master compressor/limiter, MIDI learn system, separate deck outputs
- Mixer: 8 renamable stereo input channels with VU meter, volume fader, mute/solo - Master bus: compressor (threshold, ratio, attack, release, makeup) + limiter (ceiling), LUFS metering - Decks/carts keep their own JACK outputs, completely separate from mixer - MIDI learn: configurable bindings saved to ~/.gti_radiostudio/midi_map.json - mixer_widget.py: QDial-based DSP controls, scrollable channel strips
This commit is contained in:
+179
-37
@@ -13,6 +13,12 @@ LUFS_TARGET = -14.0
|
||||
SOUNDFILE_FORMATS = {'.wav', '.flac', '.ogg', '.aiff', '.aif'}
|
||||
FFMPEG_FORMATS = {'.mp3', '.m4a', '.mp4', '.opus', '.aac', '.wma'}
|
||||
|
||||
NUM_MIXER_CHANNELS = 8
|
||||
DEFAULT_CHANNEL_NAMES = [
|
||||
"Turntable 1", "Turntable 2", "Mic", "Aux 1",
|
||||
"Aux 2", "Aux 3", "Aux 4", "Aux 5",
|
||||
]
|
||||
|
||||
|
||||
def load_audio(filepath: str, target_sr: int) -> np.ndarray:
|
||||
ext = os.path.splitext(filepath)[1].lower()
|
||||
@@ -137,12 +143,99 @@ class DeckState:
|
||||
return f"-{int(remaining//60):02d}:{int(remaining%60):02d}"
|
||||
|
||||
|
||||
class MixerChannel:
|
||||
def __init__(self, name: str, index: int):
|
||||
self.name = name
|
||||
self.index = index
|
||||
self.volume = 1.0
|
||||
self.muted = False
|
||||
self.solo = False
|
||||
self.vu_peak = 0.0
|
||||
self.in_port_L = None
|
||||
self.in_port_R = None
|
||||
self.out_port_L = None
|
||||
self.out_port_R = None
|
||||
|
||||
def reset_vu(self):
|
||||
self.vu_peak = 0.0
|
||||
|
||||
|
||||
class Compressor:
|
||||
def __init__(self, sr: int):
|
||||
self.sr = sr
|
||||
self.threshold_db = -20.0
|
||||
self.ratio = 4.0
|
||||
self.attack_ms = 5.0
|
||||
self.release_ms = 100.0
|
||||
self.makeup_gain_db = 0.0
|
||||
self.bypassed = False
|
||||
|
||||
self._envelope = 0.0
|
||||
self._attack_coeff = self._time_constant(self.attack_ms)
|
||||
self._release_coeff = self._time_constant(self.release_ms)
|
||||
|
||||
def _time_constant(self, ms: float) -> float:
|
||||
return np.exp(-1.0 / (ms * self.sr / 1000.0))
|
||||
|
||||
def set_attack(self, ms: float):
|
||||
self.attack_ms = ms
|
||||
self._attack_coeff = self._time_constant(ms)
|
||||
|
||||
def set_release(self, ms: float):
|
||||
self.release_ms = ms
|
||||
self._release_coeff = self._time_constant(ms)
|
||||
|
||||
def process(self, audio: np.ndarray):
|
||||
if self.bypassed:
|
||||
return
|
||||
threshold_linear = 10 ** (self.threshold_db / 20.0)
|
||||
makeup_linear = 10 ** (self.makeup_gain_db / 20.0)
|
||||
for ch in range(audio.shape[0]):
|
||||
for i in range(audio.shape[1]):
|
||||
sample = audio[ch, i]
|
||||
level = abs(sample)
|
||||
if level > self._envelope:
|
||||
self._envelope += (1 - self._attack_coeff) * (level - self._envelope)
|
||||
else:
|
||||
self._envelope += (1 - self._release_coeff) * (level - self._envelope)
|
||||
if self._envelope > threshold_linear:
|
||||
env_db = 20 * np.log10(max(self._envelope, 1e-10))
|
||||
gain_db = (self.threshold_db - env_db) * (1 - 1.0 / self.ratio)
|
||||
gain = 10 ** (gain_db / 20.0)
|
||||
else:
|
||||
gain = 1.0
|
||||
audio[ch, i] = sample * gain * makeup_linear
|
||||
|
||||
|
||||
class Limiter:
|
||||
def __init__(self):
|
||||
self.ceiling_db = -0.1
|
||||
self.bypassed = False
|
||||
|
||||
def process(self, audio: np.ndarray):
|
||||
if self.bypassed:
|
||||
return
|
||||
ceiling = 10 ** (self.ceiling_db / 20.0)
|
||||
np.clip(audio, -ceiling, ceiling, out=audio)
|
||||
|
||||
|
||||
class MasterBus:
|
||||
def __init__(self, sr: int):
|
||||
self.compressor = Compressor(sr)
|
||||
self.limiter = Limiter()
|
||||
self.vu_peak = 0.0
|
||||
self.lufs_current = -70.0
|
||||
|
||||
def reset_vu(self):
|
||||
self.vu_peak = 0.0
|
||||
|
||||
|
||||
class AudioEngine:
|
||||
def __init__(self):
|
||||
self.client = jack.Client("RadioPanel")
|
||||
self.sr = self.client.samplerate
|
||||
|
||||
# Output ports
|
||||
# Output ports for decks (standalone, not mixed into master)
|
||||
self.ports = {
|
||||
'deck1': (
|
||||
self.client.outports.register("deck1_L"),
|
||||
@@ -158,20 +251,24 @@ class AudioEngine:
|
||||
),
|
||||
}
|
||||
|
||||
# LUFS meter input ports
|
||||
self.lufs_inports = (
|
||||
self.client.inports.register("master_L"),
|
||||
self.client.inports.register("master_R"),
|
||||
# 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"),
|
||||
)
|
||||
|
||||
# LUFS state (3 second short-term buffer)
|
||||
self.lufs_buffer_seconds = 3.0
|
||||
self.lufs_buffer_frames = int(self.lufs_buffer_seconds * self.sr)
|
||||
self.lufs_buffer = np.zeros((2, self.lufs_buffer_frames), dtype=np.float32)
|
||||
self.lufs_write_pos = 0
|
||||
self.lufs_current = -70.0 # dB LUFS (very quiet default)
|
||||
|
||||
# Deck states
|
||||
# Deck states (unchanged)
|
||||
self.decks = {
|
||||
'deck1': DeckState(),
|
||||
'deck2': DeckState(),
|
||||
@@ -184,6 +281,25 @@ class AudioEngine:
|
||||
self.client.set_process_callback(self._process)
|
||||
self.client.activate()
|
||||
|
||||
def get_channel(self, index: int) -> MixerChannel:
|
||||
return self.mixer['channels'][index]
|
||||
|
||||
def set_channel_name(self, index: int, name: str):
|
||||
if 0 <= index < NUM_MIXER_CHANNELS:
|
||||
self.mixer['channels'][index].name = name
|
||||
|
||||
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))
|
||||
|
||||
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
|
||||
|
||||
def _fill_port(self, port_L, port_R, deck: DeckState, frames: int):
|
||||
buf_L = port_L.get_array()
|
||||
buf_R = port_R.get_array()
|
||||
@@ -211,7 +327,7 @@ class AudioEngine:
|
||||
deck.position = track.num_samples - 1
|
||||
|
||||
def _process(self, frames: int):
|
||||
# Decks
|
||||
# ── Decks (unchanged) ──────────────────────────────────────────
|
||||
self._fill_port(*self.ports['deck1'], self.decks['deck1'], frames)
|
||||
self._fill_port(*self.ports['deck2'], self.decks['deck2'], frames)
|
||||
|
||||
@@ -239,33 +355,59 @@ class AudioEngine:
|
||||
deck.playing = False
|
||||
deck.position = track.num_samples - 1
|
||||
|
||||
# LUFS metering
|
||||
lufs_in_L = self.lufs_inports[0].get_array()
|
||||
lufs_in_R = self.lufs_inports[1].get_array()
|
||||
# ── Mixer ───────────────────────────────────────────────────────
|
||||
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)
|
||||
|
||||
# Write to ring buffer
|
||||
for i in range(frames):
|
||||
self.lufs_buffer[0, self.lufs_write_pos] = lufs_in_L[i]
|
||||
self.lufs_buffer[1, self.lufs_write_pos] = lufs_in_R[i]
|
||||
self.lufs_write_pos = (self.lufs_write_pos + 1) % self.lufs_buffer_frames
|
||||
any_solo = any(ch.solo for ch in self.mixer['channels'])
|
||||
|
||||
# Calculate LUFS every 512 frames (reduces CPU)
|
||||
for ch in self.mixer['channels']:
|
||||
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)
|
||||
|
||||
if mute:
|
||||
ch.vu_peak = 0.0
|
||||
continue
|
||||
|
||||
vol = ch.volume
|
||||
peak = 0.0
|
||||
for i in range(frames):
|
||||
s_L = in_L[i] * vol
|
||||
s_R = in_R[i] * vol
|
||||
master_buf_L[i] += s_L
|
||||
master_buf_R[i] += s_R
|
||||
peak = max(peak, abs(s_L), abs(s_R))
|
||||
|
||||
ch.vu_peak = peak
|
||||
|
||||
# ── Master DSP ──────────────────────────────────────────────────
|
||||
master_audio = np.array([master_buf_L, master_buf_R])
|
||||
|
||||
self.mixer['master'].compressor.process(master_audio)
|
||||
self.mixer['master'].limiter.process(master_audio)
|
||||
|
||||
# Copy processed audio back to port buffers
|
||||
master_buf_L[:] = master_audio[0]
|
||||
master_buf_R[:] = master_audio[1]
|
||||
|
||||
# Master VU
|
||||
master_peak = max(np.max(np.abs(master_audio[0])), np.max(np.abs(master_audio[1])))
|
||||
self.mixer['master'].vu_peak = master_peak
|
||||
|
||||
# LUFS calculation (every ~512 frames)
|
||||
if frames % 512 < frames:
|
||||
self._calculate_lufs()
|
||||
self._calculate_lufs(master_audio)
|
||||
|
||||
def _calculate_lufs(self):
|
||||
"""
|
||||
Simplified LUFS calculation (BS.1770-4 compliant).
|
||||
K-weighting filter + gating applied.
|
||||
"""
|
||||
# Mean square energy across 3 second window
|
||||
mean_square = np.mean(self.lufs_buffer ** 2)
|
||||
|
||||
if mean_square < 1e-12: # Silence threshold
|
||||
self.lufs_current = -70.0
|
||||
def _calculate_lufs(self, audio: np.ndarray):
|
||||
mean_square = np.mean(audio ** 2)
|
||||
if mean_square < 1e-12:
|
||||
self.mixer['master'].lufs_current = -70.0
|
||||
else:
|
||||
# Convert to dB (simplified - proper K-weighting would use filters)
|
||||
self.lufs_current = -0.691 + 10 * np.log10(mean_square)
|
||||
self.mixer['master'].lufs_current = -0.691 + 10 * np.log10(mean_square)
|
||||
|
||||
def load_track(self, deck_key: str, filepath: str):
|
||||
def _load():
|
||||
@@ -291,4 +433,4 @@ class AudioEngine:
|
||||
|
||||
def shutdown(self):
|
||||
self.client.deactivate()
|
||||
self.client.close()
|
||||
self.client.close()
|
||||
|
||||
Reference in New Issue
Block a user