Compare commits
10
Commits
680a9b5d51
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0277089b2d | ||
|
|
da8240a9e2 | ||
|
|
90d5ac26ec | ||
|
|
2b81a80a39 | ||
|
|
4d6a639c08 | ||
|
|
9e4315886d | ||
|
|
89fb7010fb | ||
|
|
6067f442b0 | ||
|
|
7de0cc3de0 | ||
|
|
d2240c61c3 |
@@ -1,3 +1,4 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
venv/
|
||||
obs_overlays/
|
||||
|
||||
@@ -8,7 +8,7 @@ VIBE CODED AS FUCK. DO WHAT YOU WANT. I DON'T OWN IT YOU DON'T
|
||||
| **Playlist Manager** | ✅ | Drag/drop, Add files/folder, Load to deck, Queue, Right-click menu |
|
||||
| **CART Playlist** | ✅ | Separate playlist panel, auto-fills next free cart slot |
|
||||
| **MIDI Controller** | ✅ | Numark DJ2Go - Play/Pause, Cue, Jog wheels, 4 Cart buttons |
|
||||
| **Audio Fingerprinting** | ⚠️ | AcoustID integrated, API working but database gaps |
|
||||
|
||||
| **Play History** | ✅ | Scrollable session history, file tags + MusicBrainz metadata |
|
||||
| **Top Bar** | ✅ | 24hr clock, full date display |
|
||||
| **Colour Themes** | ✅ | 4 themes - Green, Amber, Blue, Red - cycle with ◈ button |
|
||||
@@ -20,7 +20,6 @@ radiopanel/
|
||||
├── deck_widget.py # CDJ-style deck UI + transport controls
|
||||
├── cart_widget.py # 4-slot CART machine
|
||||
├── playlist_widget.py # Playlist + CART playlist panels
|
||||
├── fingerprint_widget.py # AcoustID audio fingerprinting
|
||||
├── midi_engine.py # Numark DJ2Go MIDI mapping
|
||||
├── top_bar_widget.py # Clock, date, theme switcher
|
||||
├── history_widget.py # Session play history
|
||||
|
||||
+398
-78
@@ -6,13 +6,21 @@ import jack
|
||||
import threading
|
||||
import subprocess
|
||||
import os
|
||||
import time
|
||||
import pyloudnorm as pyln
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
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 = [
|
||||
"Mic 1", "Mic 2", "Aux 1",
|
||||
"Aux 2", "Aux 3", "Aux 4", "Aux 5", "Aux 6",
|
||||
]
|
||||
|
||||
|
||||
def load_audio(filepath: str, target_sr: int) -> np.ndarray:
|
||||
ext = os.path.splitext(filepath)[1].lower()
|
||||
@@ -83,6 +91,8 @@ class DeckState:
|
||||
self.position = 0
|
||||
self.playing = False
|
||||
self.lock = threading.Lock()
|
||||
self.manual_elapsed = 0.0
|
||||
self.manual_duration = 0.0
|
||||
|
||||
def load(self, track: Track):
|
||||
with self.lock:
|
||||
@@ -125,24 +135,55 @@ class DeckState:
|
||||
self.position = self.track.num_samples - 1
|
||||
|
||||
def get_elapsed(self) -> str:
|
||||
if not self.track:
|
||||
return "00:00"
|
||||
s = self.position / self.track.sr
|
||||
with self.lock:
|
||||
if not self.track:
|
||||
return "00:00"
|
||||
s = self.position / self.track.sr
|
||||
return f"{int(s//60):02d}:{int(s%60):02d}"
|
||||
|
||||
def get_remaining(self) -> str:
|
||||
if not self.track:
|
||||
return "-00:00"
|
||||
remaining = max(0, self.track.duration - (self.position / self.track.sr))
|
||||
with self.lock:
|
||||
if not self.track:
|
||||
return "-00:00"
|
||||
remaining = max(0, self.track.duration - (self.position / self.track.sr))
|
||||
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.gain = 1.0 # pre-fader gain trim
|
||||
self.muted = False
|
||||
self.solo = False
|
||||
self.pfl = False
|
||||
self.mic_on = False
|
||||
self.vu_peak = 0.0
|
||||
self._vu_smooth = 0.0
|
||||
self.in_port_L = None
|
||||
self.in_port_R = None
|
||||
|
||||
|
||||
class MasterBus:
|
||||
def __init__(self, sr: int):
|
||||
self.vu_peak = 0.0
|
||||
self._vu_smooth = 0.0
|
||||
self.lufs_current = -70.0
|
||||
self.lufs_short = -70.0
|
||||
self.lufs_long = -70.0
|
||||
self.volume = 1.0
|
||||
self.pfl_hp_blend = 0.5 # 0.0 = PFL only, 0.5 = equal, 1.0 = HP only
|
||||
self.headphone_volume = 0.7
|
||||
self._lufs_accum = 0
|
||||
|
||||
|
||||
class AudioEngine:
|
||||
def __init__(self):
|
||||
self.client = jack.Client("RadioPanel")
|
||||
self.sr = self.client.samplerate
|
||||
|
||||
# Output ports
|
||||
# Output ports for digital decks (standalone, not mixed into master)
|
||||
self.ports = {
|
||||
'deck1': (
|
||||
self.client.outports.register("deck1_L"),
|
||||
@@ -156,116 +197,335 @@ 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 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)
|
||||
# Headphone output ports (all channels except mics)
|
||||
self.headphone_out = (
|
||||
self.client.outports.register("headphone_out_L"),
|
||||
self.client.outports.register("headphone_out_R"),
|
||||
)
|
||||
|
||||
# Deck states
|
||||
# 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"),
|
||||
)
|
||||
|
||||
# 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"),
|
||||
self.client.outports.register("pfl_out_R"),
|
||||
)
|
||||
|
||||
# Deck states (for digital decks and carts)
|
||||
self.decks = {
|
||||
'deck1': DeckState(),
|
||||
'deck2': 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()
|
||||
|
||||
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))
|
||||
|
||||
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))
|
||||
|
||||
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 set_channel_pfl(self, index: int, pfl: bool):
|
||||
if 0 <= index < NUM_MIXER_CHANNELS:
|
||||
self.mixer['channels'][index].pfl = pfl
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
if not deck.playing or deck.track is None:
|
||||
with deck.lock:
|
||||
playing = deck.playing
|
||||
track = deck.track
|
||||
pos = deck.position
|
||||
|
||||
if not playing or track is None:
|
||||
return
|
||||
|
||||
pos = deck.position
|
||||
track = deck.track
|
||||
end = min(pos + frames, track.num_samples)
|
||||
n = end - pos
|
||||
|
||||
if n <= 0:
|
||||
deck.playing = False
|
||||
with deck.lock:
|
||||
deck.playing = False
|
||||
return
|
||||
|
||||
buf_L[:n] = track.audio[0, pos:end]
|
||||
buf_R[:n] = track.audio[1, pos:end]
|
||||
deck.position += n
|
||||
|
||||
if deck.position >= track.num_samples:
|
||||
deck.playing = False
|
||||
deck.position = track.num_samples - 1
|
||||
|
||||
def _process(self, frames: int):
|
||||
# Decks
|
||||
self._fill_port(*self.ports['deck1'], self.decks['deck1'], frames)
|
||||
self._fill_port(*self.ports['deck2'], self.decks['deck2'], frames)
|
||||
|
||||
# Mix carts
|
||||
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]
|
||||
if not deck.playing or deck.track is None:
|
||||
continue
|
||||
pos = deck.position
|
||||
track = deck.track
|
||||
end = min(pos + frames, track.num_samples)
|
||||
n = end - pos
|
||||
if n <= 0:
|
||||
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
|
||||
|
||||
# LUFS metering
|
||||
lufs_in_L = self.lufs_inports[0].get_array()
|
||||
lufs_in_R = self.lufs_inports[1].get_array()
|
||||
def _process(self, frames: int):
|
||||
# ── Digital decks ───────────────────────────────────────────────
|
||||
self._fill_port(*self.ports['deck1'], self.decks['deck1'], frames)
|
||||
self._fill_port(*self.ports['deck2'], self.decks['deck2'], frames)
|
||||
|
||||
# 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
|
||||
# ── Preview deck ─────────────────────────────────────────────
|
||||
self._fill_port(*self.ports['preview'], self.decks['preview'], frames)
|
||||
|
||||
# Calculate LUFS every 512 frames (reduces CPU)
|
||||
if frames % 512 < frames:
|
||||
self._calculate_lufs()
|
||||
# ── 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)
|
||||
|
||||
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
|
||||
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
|
||||
vu_decay = np.exp(-dt / 0.15)
|
||||
dt = frames / self.sr
|
||||
vu_decay = np.exp(-dt / 0.15)
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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()
|
||||
|
||||
mute = ch.muted or (any_solo and not ch.solo)
|
||||
is_mic = ch.index < 2
|
||||
g = ch.gain
|
||||
|
||||
if ch.pfl:
|
||||
pfl_buf_L[:] += in_L[:] * g
|
||||
pfl_buf_R[:] += in_R[:] * g
|
||||
|
||||
if mute:
|
||||
ch.vu_peak = 0.0
|
||||
ch._vu_smooth = 0.0
|
||||
continue
|
||||
|
||||
factor = g * ch.volume
|
||||
goes_to_main = not is_mic or ch.mic_on
|
||||
goes_to_headphone = not is_mic
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
# ── 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:
|
||||
# Convert to dB (simplified - proper K-weighting would use filters)
|
||||
self.lufs_current = -0.691 + 10 * np.log10(mean_square)
|
||||
studio_buf_L[:] = master_buf_L[:]
|
||||
studio_buf_R[:] = master_buf_R[:]
|
||||
|
||||
# ── PFL / Headphone blend ───────────────────────────────────────
|
||||
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
|
||||
|
||||
vol = self.mixer['master'].volume
|
||||
if vol < 1.0:
|
||||
master_buf_L[:] *= vol
|
||||
master_buf_R[:] *= vol
|
||||
|
||||
# ── LUFS metering from master mix ──────────────────────────
|
||||
master._lufs_accum += frames
|
||||
if master._lufs_accum >= 512:
|
||||
lufs_dt = 512.0 / self.sr
|
||||
master._lufs_accum -= 512
|
||||
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:
|
||||
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)
|
||||
|
||||
# ── 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():
|
||||
@@ -289,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()
|
||||
self.client.close()
|
||||
|
||||
+200
-133
@@ -2,36 +2,27 @@
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QFrame, QSizePolicy, QGridLayout
|
||||
QPushButton, QFrame, QSizePolicy, QCheckBox, QScrollArea
|
||||
)
|
||||
from PySide6.QtCore import Qt, QTimer, QSize
|
||||
from PySide6.QtCore import Qt, QTimer, QSize, Signal
|
||||
from PySide6.QtGui import QFont
|
||||
import os
|
||||
|
||||
from icons import icon_play, icon_pause, icon_eject
|
||||
from icons import icon_play, icon_pause, icon_eject, icon_delete
|
||||
|
||||
|
||||
class CartSlot(QFrame):
|
||||
def __init__(self, cart_key: str, slot_number: int, engine, parent=None):
|
||||
class CartPlayerWidget(QFrame):
|
||||
"""Single active cart player with visible 3-item autoload queue."""
|
||||
cart_triggered = Signal(str)
|
||||
|
||||
def __init__(self, engine, parent=None):
|
||||
super().__init__(parent)
|
||||
self.cart_key = cart_key
|
||||
self.slot_number = slot_number
|
||||
self.engine = engine
|
||||
self._playing_icon = False
|
||||
self.engine = engine
|
||||
self._playing_icon = False
|
||||
|
||||
self.setFrameStyle(QFrame.Box | QFrame.Raised)
|
||||
self.setLineWidth(1)
|
||||
self.setStyleSheet("""
|
||||
QFrame {
|
||||
background-color: #1e1e1e;
|
||||
border: 1px solid #444;
|
||||
border-radius: 5px;
|
||||
}
|
||||
""")
|
||||
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
self.setFixedHeight(110)
|
||||
|
||||
self.setFixedHeight(160)
|
||||
self._build_ui()
|
||||
self._connect_signals()
|
||||
|
||||
@@ -40,23 +31,78 @@ class CartSlot(QFrame):
|
||||
self.timer.timeout.connect(self._refresh_display)
|
||||
self.timer.start()
|
||||
|
||||
def _build_ui(self):
|
||||
root = QHBoxLayout(self)
|
||||
root.setContentsMargins(8, 6, 8, 6)
|
||||
root.setSpacing(8)
|
||||
self.queue_timer = QTimer(self)
|
||||
self.queue_timer.setInterval(500)
|
||||
self.queue_timer.timeout.connect(self._refresh_queue)
|
||||
self.queue_timer.start()
|
||||
|
||||
badge = QLabel(str(self.slot_number))
|
||||
badge.setFixedSize(28, 28)
|
||||
def _build_ui(self):
|
||||
self.setStyleSheet("""
|
||||
QFrame {
|
||||
background-color: #111;
|
||||
border: 2px solid #cc3300;
|
||||
border-radius: 6px;
|
||||
}
|
||||
""")
|
||||
root = QHBoxLayout(self)
|
||||
root.setContentsMargins(10, 8, 10, 8)
|
||||
root.setSpacing(10)
|
||||
|
||||
# ── Left: Cart player box ──────────────────────────────────
|
||||
player_frame = QFrame()
|
||||
player_frame.setStyleSheet("""
|
||||
QFrame {
|
||||
background-color: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 5px;
|
||||
}
|
||||
""")
|
||||
player_frame.setFixedWidth(320)
|
||||
player_layout = QVBoxLayout(player_frame)
|
||||
player_layout.setContentsMargins(10, 8, 10, 8)
|
||||
player_layout.setSpacing(6)
|
||||
|
||||
header_row = QHBoxLayout()
|
||||
badge = QLabel("1")
|
||||
badge.setFixedSize(26, 26)
|
||||
badge.setAlignment(Qt.AlignCenter)
|
||||
badge.setFont(QFont("Arial", 12, QFont.Bold))
|
||||
badge.setStyleSheet("""
|
||||
background-color: #cc3300;
|
||||
color: white;
|
||||
border-radius: 14px;
|
||||
border-radius: 15px;
|
||||
border: none;
|
||||
""")
|
||||
root.addWidget(badge, alignment=Qt.AlignVCenter)
|
||||
header_row.addWidget(badge)
|
||||
|
||||
cart_label = QLabel("CART")
|
||||
cart_label.setFont(QFont("Arial", 12, QFont.Bold))
|
||||
cart_label.setStyleSheet("color: #cc3300; background: transparent; border: none;")
|
||||
header_row.addWidget(cart_label)
|
||||
|
||||
self.continue_toggle = QCheckBox("CONTINUE")
|
||||
self.continue_toggle.setFont(QFont("Arial", 9, QFont.Bold))
|
||||
self.continue_toggle.setStyleSheet("""
|
||||
QCheckBox {
|
||||
color: #888; background: transparent; border: none; spacing: 4px;
|
||||
}
|
||||
QCheckBox::indicator {
|
||||
width: 16px; height: 16px;
|
||||
border: 1px solid #555; border-radius: 3px;
|
||||
background-color: #1a1a1a;
|
||||
}
|
||||
QCheckBox::indicator:checked {
|
||||
background-color: #cc3300; border-color: #ff6644;
|
||||
}
|
||||
QCheckBox::indicator:hover { border-color: #888; }
|
||||
""")
|
||||
self.continue_toggle.setToolTip("Auto-play next track from queue after current finishes")
|
||||
self.continue_toggle.stateChanged.connect(self._on_continue_toggled)
|
||||
header_row.addWidget(self.continue_toggle)
|
||||
header_row.addStretch()
|
||||
player_layout.addLayout(header_row)
|
||||
|
||||
# LCD display
|
||||
self.lcd_frame = QFrame()
|
||||
self.lcd_frame.setStyleSheet("""
|
||||
QFrame {
|
||||
@@ -65,32 +111,26 @@ class CartSlot(QFrame):
|
||||
border-radius: 3px;
|
||||
}
|
||||
""")
|
||||
self.lcd_frame.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
lcd_layout = QHBoxLayout(self.lcd_frame)
|
||||
lcd_layout.setContentsMargins(8, 4, 8, 4)
|
||||
lcd_layout.setSpacing(6)
|
||||
|
||||
lcd_layout = QVBoxLayout(self.lcd_frame)
|
||||
lcd_layout.setContentsMargins(6, 4, 6, 4)
|
||||
lcd_layout.setSpacing(2)
|
||||
lcd_left = QVBoxLayout()
|
||||
lcd_left.setSpacing(2)
|
||||
|
||||
self.track_label = QLabel("EMPTY")
|
||||
self.track_label.setFont(QFont("Courier New", 9, QFont.Bold))
|
||||
self.track_label.setStyleSheet(
|
||||
"color: #00ff41; background: transparent; border: none;"
|
||||
)
|
||||
self.track_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||||
self.track_label.setStyleSheet("color: #00ff41; background: transparent; border: none;")
|
||||
self.track_label.setMinimumWidth(180)
|
||||
|
||||
time_row = QHBoxLayout()
|
||||
|
||||
self.elapsed_label = QLabel("00:00")
|
||||
self.elapsed_label.setFont(QFont("Courier New", 14, QFont.Bold))
|
||||
self.elapsed_label.setStyleSheet(
|
||||
"color: #00ff41; background: transparent; border: none;"
|
||||
)
|
||||
self.elapsed_label.setFont(QFont("Courier New", 18, QFont.Bold))
|
||||
self.elapsed_label.setStyleSheet("color: #00ff41; background: transparent; border: none;")
|
||||
|
||||
self.remaining_label = QLabel("-00:00")
|
||||
self.remaining_label.setFont(QFont("Courier New", 10))
|
||||
self.remaining_label.setStyleSheet(
|
||||
"color: #00cc33; background: transparent; border: none;"
|
||||
)
|
||||
self.remaining_label.setFont(QFont("Courier New", 10, QFont.Bold))
|
||||
self.remaining_label.setStyleSheet("color: #00cc33; background: transparent; border: none;")
|
||||
self.remaining_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
|
||||
time_row.addWidget(self.elapsed_label)
|
||||
@@ -99,21 +139,18 @@ class CartSlot(QFrame):
|
||||
|
||||
self.status_label = QLabel("STOPPED")
|
||||
self.status_label.setFont(QFont("Courier New", 8))
|
||||
self.status_label.setStyleSheet(
|
||||
"color: #008822; background: transparent; border: none;"
|
||||
)
|
||||
self.status_label.setStyleSheet("color: #008822; background: transparent; border: none;")
|
||||
|
||||
lcd_layout.addWidget(self.track_label)
|
||||
lcd_layout.addLayout(time_row)
|
||||
lcd_layout.addWidget(self.status_label)
|
||||
|
||||
root.addWidget(self.lcd_frame)
|
||||
lcd_left.addWidget(self.track_label)
|
||||
lcd_left.addLayout(time_row)
|
||||
lcd_left.addWidget(self.status_label)
|
||||
lcd_layout.addLayout(lcd_left)
|
||||
|
||||
btn_col = QVBoxLayout()
|
||||
btn_col.setSpacing(4)
|
||||
btn_col.setSpacing(3)
|
||||
|
||||
self.btn_play = QPushButton()
|
||||
self.btn_play.setFixedSize(60, 44)
|
||||
self.btn_play.setFixedSize(56, 36)
|
||||
self.btn_play.setIconSize(QSize(22, 22))
|
||||
self.btn_play.setIcon(icon_play("#00ff41"))
|
||||
self.btn_play.setStyleSheet("""
|
||||
@@ -127,8 +164,8 @@ class CartSlot(QFrame):
|
||||
""")
|
||||
|
||||
self.btn_eject = QPushButton()
|
||||
self.btn_eject.setFixedSize(60, 30)
|
||||
self.btn_eject.setIconSize(QSize(18, 18))
|
||||
self.btn_eject.setFixedSize(56, 28)
|
||||
self.btn_eject.setIconSize(QSize(16, 16))
|
||||
self.btn_eject.setIcon(icon_eject("#ff4444"))
|
||||
self.btn_eject.setStyleSheet("""
|
||||
QPushButton {
|
||||
@@ -142,37 +179,85 @@ class CartSlot(QFrame):
|
||||
|
||||
btn_col.addWidget(self.btn_play)
|
||||
btn_col.addWidget(self.btn_eject)
|
||||
lcd_layout.addLayout(btn_col)
|
||||
|
||||
root.addLayout(btn_col)
|
||||
player_layout.addWidget(self.lcd_frame)
|
||||
root.addWidget(player_frame)
|
||||
|
||||
def apply_theme(self, theme: dict):
|
||||
"""Apply theme to cart slot LCD."""
|
||||
self.lcd_frame.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
background-color: {theme['bg']};
|
||||
border: 1px solid {theme['border']};
|
||||
border-radius: 3px;
|
||||
}}
|
||||
# ── Right: Queue panel ──────────────────────────────────────
|
||||
queue_frame = QFrame()
|
||||
queue_frame.setStyleSheet("""
|
||||
QFrame {
|
||||
background-color: #111;
|
||||
border: 1px solid #2a2010;
|
||||
border-radius: 5px;
|
||||
}
|
||||
""")
|
||||
queue_frame.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
queue_layout = QVBoxLayout(queue_frame)
|
||||
queue_layout.setContentsMargins(8, 6, 8, 6)
|
||||
queue_layout.setSpacing(2)
|
||||
|
||||
queue_header = QHBoxLayout()
|
||||
ql = QLabel("AUTOLOAD QUEUE")
|
||||
ql.setFont(QFont("Arial", 9, QFont.Bold))
|
||||
ql.setStyleSheet("color: #ffaa00; background: transparent; border: none;")
|
||||
queue_header.addWidget(ql)
|
||||
queue_header.addStretch()
|
||||
|
||||
self.queue_count = QLabel("0/3")
|
||||
self.queue_count.setFont(QFont("Courier New", 9, QFont.Bold))
|
||||
self.queue_count.setStyleSheet("color: #555; background: transparent; border: none;")
|
||||
queue_header.addWidget(self.queue_count)
|
||||
|
||||
clear_btn = QPushButton("CLR")
|
||||
clear_btn.setFixedSize(40, 20)
|
||||
clear_btn.setFont(QFont("Arial", 7, QFont.Bold))
|
||||
clear_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #3a1a1a;
|
||||
color: #ff6666;
|
||||
border: 1px solid #555;
|
||||
border-radius: 3px;
|
||||
}
|
||||
QPushButton:hover { background-color: #5a2a2a; }
|
||||
""")
|
||||
clear_btn.clicked.connect(self.engine.clear_cart_queue)
|
||||
queue_header.addWidget(clear_btn)
|
||||
queue_layout.addLayout(queue_header)
|
||||
|
||||
self.queue_items = []
|
||||
for i in range(3):
|
||||
item = QLabel(f" — ")
|
||||
item.setFont(QFont("Courier New", 9))
|
||||
item.setStyleSheet("color: #555; background: transparent; border: none; padding: 2px 4px;")
|
||||
item.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||||
queue_layout.addWidget(item)
|
||||
self.queue_items.append(item)
|
||||
|
||||
queue_layout.addStretch()
|
||||
root.addWidget(queue_frame)
|
||||
|
||||
def _connect_signals(self):
|
||||
self.btn_play.clicked.connect(self._toggle_play)
|
||||
self.btn_eject.clicked.connect(self._eject)
|
||||
|
||||
def _on_continue_toggled(self, state):
|
||||
self.engine.cart_continue = bool(state)
|
||||
|
||||
def load_track(self, filepath: str):
|
||||
self.engine.load_cart_track(self.cart_key, filepath)
|
||||
name = os.path.basename(filepath)
|
||||
short = name[:28] + "..." if len(name) > 28 else name
|
||||
self.engine.load_cart_track("cart1", filepath)
|
||||
name = os.path.basename(filepath)
|
||||
short = name[:32] + "..." if len(name) > 32 else name
|
||||
self.track_label.setText(short)
|
||||
self.status_label.setText("LOADED")
|
||||
self.elapsed_label.setText("00:00")
|
||||
self.remaining_label.setText("-00:00")
|
||||
|
||||
def toggle(self):
|
||||
deck = self.engine.decks[self.cart_key]
|
||||
deck = self.engine.decks["cart1"]
|
||||
if deck.track is None:
|
||||
return
|
||||
|
||||
if deck.playing:
|
||||
deck.stop()
|
||||
deck.go_to_start()
|
||||
@@ -181,42 +266,36 @@ class CartSlot(QFrame):
|
||||
else:
|
||||
deck.play()
|
||||
self._set_ui_playing()
|
||||
self.engine.log_cart_trigger(deck.track.filename)
|
||||
self.cart_triggered.emit(deck.track.filename)
|
||||
|
||||
def _set_ui_playing(self):
|
||||
self.btn_play.setIcon(icon_pause("#ffffff"))
|
||||
self._playing_icon = True
|
||||
self.btn_play.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #00aa33;
|
||||
border: 1px solid #00ff41;
|
||||
border-radius: 3px;
|
||||
background-color: #00aa33; border: 1px solid #00ff41; border-radius: 3px;
|
||||
}
|
||||
QPushButton:hover { background-color: #00cc44; }
|
||||
""")
|
||||
self.status_label.setText("PLAYING")
|
||||
self.elapsed_label.setStyleSheet(
|
||||
"color: #00ff41; background: transparent; border: none;"
|
||||
)
|
||||
self.elapsed_label.setStyleSheet("color: #00ff41; background: transparent; border: none;")
|
||||
|
||||
def _set_ui_stopped(self):
|
||||
self.btn_play.setIcon(icon_play("#00ff41"))
|
||||
self._playing_icon = False
|
||||
self.btn_play.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #1a6b3a;
|
||||
border: 1px solid #555;
|
||||
border-radius: 3px;
|
||||
background-color: #1a6b3a; border: 1px solid #555; border-radius: 3px;
|
||||
}
|
||||
QPushButton:hover { background-color: #2a7b4a; }
|
||||
""")
|
||||
self.elapsed_label.setText("00:00")
|
||||
self.remaining_label.setText("-00:00")
|
||||
self.elapsed_label.setStyleSheet(
|
||||
"color: #00aa2a; background: transparent; border: none;"
|
||||
)
|
||||
self.elapsed_label.setStyleSheet("color: #00aa2a; background: transparent; border: none;")
|
||||
|
||||
def _toggle_play(self):
|
||||
deck = self.engine.decks[self.cart_key]
|
||||
deck = self.engine.decks["cart1"]
|
||||
if deck.track is None:
|
||||
return
|
||||
if deck.playing:
|
||||
@@ -226,72 +305,60 @@ class CartSlot(QFrame):
|
||||
else:
|
||||
deck.play()
|
||||
self._set_ui_playing()
|
||||
self.engine.log_cart_trigger(deck.track.filename)
|
||||
self.cart_triggered.emit(deck.track.filename)
|
||||
|
||||
def _eject(self):
|
||||
deck = self.engine.decks[self.cart_key]
|
||||
deck = self.engine.decks["cart1"]
|
||||
deck.stop()
|
||||
self._set_ui_stopped()
|
||||
self.track_label.setText("EMPTY")
|
||||
self.status_label.setText("STOPPED")
|
||||
|
||||
def _refresh_display(self):
|
||||
deck = self.engine.decks[self.cart_key]
|
||||
deck = self.engine.decks["cart1"]
|
||||
track = deck.track
|
||||
playing = deck.playing
|
||||
|
||||
if not deck.playing and self._playing_icon:
|
||||
if not playing and self._playing_icon:
|
||||
self._set_ui_stopped()
|
||||
self.status_label.setText("STOPPED")
|
||||
|
||||
if deck.track:
|
||||
if track:
|
||||
self.elapsed_label.setText(deck.get_elapsed())
|
||||
self.remaining_label.setText(deck.get_remaining())
|
||||
|
||||
def _refresh_queue(self):
|
||||
q = self.engine.cart_queue
|
||||
qlen = len(q)
|
||||
self.queue_count.setText(f"{qlen}/3")
|
||||
if qlen == 0:
|
||||
self.queue_count.setStyleSheet("color: #555; background: transparent; border: none;")
|
||||
elif qlen < 3:
|
||||
self.queue_count.setStyleSheet("color: #ffaa00; background: transparent; border: none;")
|
||||
else:
|
||||
self.queue_count.setStyleSheet("color: #ff4444; background: transparent; border: none;")
|
||||
|
||||
class CartBankWidget(QWidget):
|
||||
def __init__(self, engine, parent=None):
|
||||
super().__init__(parent)
|
||||
self.engine = engine
|
||||
self._build_ui()
|
||||
for i in range(3):
|
||||
if i < qlen:
|
||||
name = q[i].filename
|
||||
name = name[:35] + "..." if len(name) > 35 else name
|
||||
self.queue_items[i].setText(f" {i+1}. {name}")
|
||||
self.queue_items[i].setStyleSheet("color: #ffaa00; background: transparent; border: none; padding: 2px 4px;")
|
||||
else:
|
||||
self.queue_items[i].setText(" — ")
|
||||
self.queue_items[i].setStyleSheet("color: #333; background: transparent; border: none; padding: 2px 4px;")
|
||||
|
||||
def _build_ui(self):
|
||||
self.setStyleSheet("""
|
||||
QWidget {
|
||||
background-color: #111;
|
||||
border: 2px solid #cc3300;
|
||||
border-radius: 6px;
|
||||
}
|
||||
""")
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(8, 6, 8, 6)
|
||||
root.setSpacing(6)
|
||||
|
||||
header = QLabel("CARTS")
|
||||
header.setFont(QFont("Arial", 11, QFont.Bold))
|
||||
header.setStyleSheet("""
|
||||
color: #cc3300;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 2px;
|
||||
""")
|
||||
header.setAlignment(Qt.AlignCenter)
|
||||
root.addWidget(header)
|
||||
|
||||
grid = QGridLayout()
|
||||
grid.setSpacing(6)
|
||||
|
||||
self.slots = []
|
||||
for i in range(4):
|
||||
cart_key = f"cart{i + 1}"
|
||||
slot = CartSlot(cart_key, i + 1, self.engine)
|
||||
row = i // 2
|
||||
col = i % 2
|
||||
grid.addWidget(slot, row, col)
|
||||
self.slots.append(slot)
|
||||
|
||||
root.addLayout(grid)
|
||||
if not self.engine.cart_continue and self.continue_toggle.isChecked():
|
||||
self.continue_toggle.blockSignals(True)
|
||||
self.continue_toggle.setChecked(False)
|
||||
self.continue_toggle.blockSignals(False)
|
||||
|
||||
def apply_theme(self, theme: dict):
|
||||
"""Propagate theme to all cart slots."""
|
||||
for slot in self.slots:
|
||||
slot.apply_theme(theme)
|
||||
|
||||
self.lcd_frame.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
background-color: {theme['bg']};
|
||||
border: 1px solid {theme['border']};
|
||||
border-radius: 3px;
|
||||
}}
|
||||
""")
|
||||
@@ -1,26 +0,0 @@
|
||||
# debug_audio.py - save and run this
|
||||
import jack
|
||||
import numpy as np
|
||||
import time
|
||||
|
||||
client = jack.Client("DebugTest")
|
||||
client.activate()
|
||||
|
||||
in_L = client.inports.register("test_in_L")
|
||||
in_R = client.inports.register("test_in_R")
|
||||
|
||||
@client.set_process_callback
|
||||
def process(frames):
|
||||
lvl_L = np.max(np.abs(in_L.get_array())) if len(in_L.get_array()) > 0 else 0
|
||||
lvl_R = np.max(np.abs(in_R.get_array())) if len(in_R.get_array()) > 0 else 0
|
||||
if lvl_L > 0.001 or lvl_R > 0.001:
|
||||
print(f"Levels: L={lvl_L:.4f} R={lvl_R:.4f}")
|
||||
|
||||
print("Connect DebugTest:test_in_L/R to your audio source in qpwgraph")
|
||||
print("Press Ctrl+C to stop")
|
||||
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
client.deactivate()
|
||||
+174
-229
@@ -2,11 +2,10 @@
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QFrame, QSizePolicy
|
||||
QPushButton, QFrame, QSizePolicy, QInputDialog
|
||||
)
|
||||
from PySide6.QtCore import Qt, QTimer, Signal, QSize
|
||||
from PySide6.QtGui import QFont, QColor, QPainter, QPen
|
||||
import numpy as np
|
||||
from PySide6.QtGui import QFont, QColor
|
||||
import os
|
||||
|
||||
from icons import (
|
||||
@@ -15,92 +14,6 @@ from icons import (
|
||||
)
|
||||
|
||||
|
||||
class WaveformWidget(QFrame):
|
||||
"""Displays downsampled waveform with playhead position."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setFixedHeight(60)
|
||||
self.setStyleSheet("""
|
||||
QFrame {
|
||||
background-color: #050505;
|
||||
border: 1px solid #1a1a1a;
|
||||
border-radius: 3px;
|
||||
}
|
||||
""")
|
||||
|
||||
self.waveform_data = None
|
||||
self.position_pct = 0.0
|
||||
self.warning_flash = False
|
||||
|
||||
def set_waveform(self, audio: np.ndarray):
|
||||
if audio is None or audio.shape[1] == 0:
|
||||
self.waveform_data = None
|
||||
self.update()
|
||||
return
|
||||
|
||||
mono = np.mean(audio, axis=0)
|
||||
width = max(500, self.width())
|
||||
|
||||
samples_per_pixel = max(1, len(mono) // width)
|
||||
num_pixels = len(mono) // samples_per_pixel
|
||||
|
||||
self.waveform_data = np.array([
|
||||
np.sqrt(np.mean(mono[i*samples_per_pixel:(i+1)*samples_per_pixel]**2))
|
||||
for i in range(num_pixels)
|
||||
], dtype=np.float32)
|
||||
|
||||
self.update()
|
||||
|
||||
def set_position(self, position_pct: float, warning: bool = False):
|
||||
self.position_pct = max(0.0, min(1.0, position_pct))
|
||||
self.warning_flash = warning
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
super().paintEvent(event)
|
||||
|
||||
if self.waveform_data is None or len(self.waveform_data) == 0:
|
||||
return
|
||||
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.Antialiasing)
|
||||
|
||||
rect = self.rect().adjusted(2, 2, -2, -2)
|
||||
width = rect.width()
|
||||
height = rect.height()
|
||||
|
||||
max_val = np.max(self.waveform_data) if np.max(self.waveform_data) > 0 else 1.0
|
||||
scaled = (self.waveform_data / max_val) * (height * 0.8)
|
||||
|
||||
x_scale = width / len(self.waveform_data)
|
||||
|
||||
for i, rms in enumerate(self.waveform_data):
|
||||
x = rect.x() + int(i * x_scale)
|
||||
bar_h = int(scaled[i])
|
||||
y_top = rect.y() + (height - bar_h) // 2
|
||||
|
||||
pct = i / len(self.waveform_data)
|
||||
|
||||
if pct < self.position_pct:
|
||||
color = QColor("#004400")
|
||||
elif pct > 0.85:
|
||||
color = QColor("#ff0000") if self.warning_flash else QColor("#aa0000")
|
||||
elif pct > 0.70:
|
||||
color = QColor("#ffaa00")
|
||||
else:
|
||||
color = QColor("#00ff41")
|
||||
|
||||
pen = QPen(color, 1)
|
||||
painter.setPen(pen)
|
||||
painter.drawLine(x, y_top, x, y_top + bar_h)
|
||||
|
||||
playhead_x = rect.x() + int(self.position_pct * width)
|
||||
pen = QPen(QColor("#ffffff"), 2)
|
||||
painter.setPen(pen)
|
||||
painter.drawLine(playhead_x, rect.y(), playhead_x, rect.y() + height)
|
||||
|
||||
|
||||
class LCDDisplay(QFrame):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
@@ -109,37 +22,37 @@ class LCDDisplay(QFrame):
|
||||
QFrame {
|
||||
background-color: #0a1a0a;
|
||||
border: 2px solid #1a2a1a;
|
||||
border-radius: 4px;
|
||||
border-radius: 0px;
|
||||
}
|
||||
""")
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(10, 8, 10, 8)
|
||||
layout.setSpacing(4)
|
||||
layout.setContentsMargins(6, 4, 6, 4)
|
||||
layout.setSpacing(2)
|
||||
|
||||
self.track_label = QLabel("NO TRACK LOADED")
|
||||
self.track_label.setFont(QFont("Courier New", 13, QFont.Bold))
|
||||
self.track_label.setStyleSheet(
|
||||
"color: #00ff41; background: transparent;"
|
||||
)
|
||||
self.track_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||||
self.track_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
self.track_btn = QPushButton("NO TRACK LOADED")
|
||||
self.track_btn.setFont(QFont("Courier New", 9, QFont.Bold))
|
||||
self.track_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
color: #00ff41; background: transparent; border: none;
|
||||
text-align: left; padding: 0px;
|
||||
}
|
||||
QPushButton:hover { color: #ffffff; }
|
||||
""")
|
||||
self.track_btn.setCursor(Qt.PointingHandCursor)
|
||||
self.track_btn.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
|
||||
time_row = QHBoxLayout()
|
||||
time_row.setSpacing(4)
|
||||
|
||||
self.elapsed_label = QLabel("00:00")
|
||||
self.elapsed_label.setFont(QFont("Courier New", 32, QFont.Bold))
|
||||
self.elapsed_label.setStyleSheet(
|
||||
"color: #00ff41; background: transparent;"
|
||||
)
|
||||
self.elapsed_label.setFont(QFont("Courier New", 20, QFont.Bold))
|
||||
self.elapsed_label.setStyleSheet("color: #00ff41; background: transparent;")
|
||||
self.elapsed_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||||
|
||||
self.remaining_label = QLabel("-00:00")
|
||||
self.remaining_label.setFont(QFont("Courier New", 18, QFont.Bold))
|
||||
self.remaining_label.setStyleSheet(
|
||||
"color: #00cc33; background: transparent;"
|
||||
)
|
||||
self.remaining_label.setFont(QFont("Courier New", 12, QFont.Bold))
|
||||
self.remaining_label.setStyleSheet("color: #00cc33; background: transparent;")
|
||||
self.remaining_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
|
||||
time_row.addWidget(self.elapsed_label)
|
||||
@@ -149,30 +62,29 @@ class LCDDisplay(QFrame):
|
||||
status_row = QHBoxLayout()
|
||||
|
||||
self.status_label = QLabel("STOPPED")
|
||||
self.status_label.setFont(QFont("Courier New", 11, QFont.Bold))
|
||||
self.status_label.setStyleSheet(
|
||||
"color: #008822; background: transparent;"
|
||||
)
|
||||
self.status_label.setFont(QFont("Courier New", 8, QFont.Bold))
|
||||
self.status_label.setStyleSheet("color: #008822; background: transparent;")
|
||||
|
||||
self.queue_label = QLabel("QUEUE: EMPTY")
|
||||
self.queue_label.setFont(QFont("Courier New", 10))
|
||||
self.queue_label.setStyleSheet(
|
||||
"color: #008822; background: transparent;"
|
||||
)
|
||||
self.queue_label.setFont(QFont("Courier New", 7))
|
||||
self.queue_label.setStyleSheet("color: #008822; background: transparent;")
|
||||
self.queue_label.setAlignment(Qt.AlignRight)
|
||||
|
||||
status_row.addWidget(self.status_label)
|
||||
status_row.addStretch()
|
||||
status_row.addWidget(self.queue_label)
|
||||
|
||||
layout.addWidget(self.track_label)
|
||||
layout.addWidget(self.track_btn)
|
||||
layout.addLayout(time_row)
|
||||
layout.addLayout(status_row)
|
||||
|
||||
def update_track(self, name: str):
|
||||
if len(name) > 40:
|
||||
name = name[:37] + "..."
|
||||
self.track_label.setText(name)
|
||||
self.track_btn.setText(name)
|
||||
|
||||
def track_name(self) -> str:
|
||||
return self.track_btn.text()
|
||||
|
||||
def update_time(self, elapsed: str, remaining: str):
|
||||
self.elapsed_label.setText(elapsed)
|
||||
@@ -190,49 +102,35 @@ class LCDDisplay(QFrame):
|
||||
|
||||
def set_playing(self, playing: bool):
|
||||
colour = "#00ff41" if playing else "#00aa2a"
|
||||
self.elapsed_label.setStyleSheet(
|
||||
f"color: {colour}; background: transparent;"
|
||||
)
|
||||
self.elapsed_label.setStyleSheet(f"color: {colour}; background: transparent;")
|
||||
|
||||
def flash_warning(self, active: bool):
|
||||
"""Flash all time displays red when in warning zone."""
|
||||
if active:
|
||||
self.elapsed_label.setStyleSheet(
|
||||
"color: #ff4444; background: transparent;"
|
||||
)
|
||||
self.remaining_label.setStyleSheet(
|
||||
"color: #ff4444; background: transparent;"
|
||||
)
|
||||
self.elapsed_label.setStyleSheet("color: #ff4444; background: transparent;")
|
||||
self.remaining_label.setStyleSheet("color: #ff4444; background: transparent;")
|
||||
else:
|
||||
self.elapsed_label.setStyleSheet(
|
||||
"color: #00ff41; background: transparent;"
|
||||
)
|
||||
self.remaining_label.setStyleSheet(
|
||||
"color: #00cc33; background: transparent;"
|
||||
)
|
||||
self.elapsed_label.setStyleSheet("color: #00ff41; background: transparent;")
|
||||
self.remaining_label.setStyleSheet("color: #00cc33; background: transparent;")
|
||||
|
||||
|
||||
def make_icon_button(icon: QIcon,
|
||||
color: str = "#3a3a3a",
|
||||
icon_color: str = "white",
|
||||
width: int = 52,
|
||||
height: int = 48) -> QPushButton:
|
||||
def make_icon_button(icon, color="#2a2a2a", icon_color="white",
|
||||
width=40, height=40):
|
||||
btn = QPushButton()
|
||||
btn.setFixedSize(width, height)
|
||||
btn.setIconSize(QSize(22, 22))
|
||||
btn.setIconSize(QSize(18, 18))
|
||||
btn.setIcon(icon)
|
||||
btn.setStyleSheet(f"""
|
||||
QPushButton {{
|
||||
background-color: {color};
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
border: 2px solid #444;
|
||||
border-radius: 0px;
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
background-color: #555;
|
||||
}}
|
||||
QPushButton:pressed {{
|
||||
background-color: #222;
|
||||
border: 1px solid #888;
|
||||
border: 2px solid #888;
|
||||
}}
|
||||
""")
|
||||
return btn
|
||||
@@ -242,7 +140,7 @@ class DeckWidget(QWidget):
|
||||
track_started = Signal(str)
|
||||
|
||||
def __init__(self, deck_key: str, label: str, accent_color: str,
|
||||
engine, parent=None):
|
||||
engine, parent=None, mode='deck'):
|
||||
super().__init__(parent)
|
||||
self.deck_key = deck_key
|
||||
self.engine = engine
|
||||
@@ -250,11 +148,9 @@ class DeckWidget(QWidget):
|
||||
self.current_theme = None
|
||||
self.flash_state = False
|
||||
self._playing_icon = False
|
||||
self._playing_icon = False
|
||||
self.mode = mode
|
||||
|
||||
# Convert "DECK 1" to "DECK_001"
|
||||
deck_num = label.split()[-1]
|
||||
self.deck_label = f"DECK_{int(deck_num):03d}"
|
||||
self.deck_label = label
|
||||
|
||||
self._build_ui()
|
||||
self._connect_signals()
|
||||
@@ -274,21 +170,21 @@ class DeckWidget(QWidget):
|
||||
QWidget {{
|
||||
background-color: #1c1c1c;
|
||||
border: 2px solid {self.accent_color};
|
||||
border-radius: 6px;
|
||||
border-radius: 0px;
|
||||
}}
|
||||
""")
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(10, 10, 10, 10)
|
||||
root.setSpacing(8)
|
||||
root.setContentsMargins(4, 4, 4, 4)
|
||||
root.setSpacing(3)
|
||||
|
||||
self.header = QLabel(self.deck_label)
|
||||
self.header.setFont(QFont("Courier New", 24, QFont.Bold))
|
||||
self.header.setFont(QFont("Courier New", 13, QFont.Bold))
|
||||
self.header.setStyleSheet(f"""
|
||||
color: {self.accent_color};
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
padding: 1px;
|
||||
""")
|
||||
self.header.setAlignment(Qt.AlignCenter)
|
||||
root.addWidget(self.header)
|
||||
@@ -296,18 +192,15 @@ class DeckWidget(QWidget):
|
||||
self.lcd = LCDDisplay()
|
||||
root.addWidget(self.lcd)
|
||||
|
||||
self.waveform = WaveformWidget()
|
||||
root.addWidget(self.waveform)
|
||||
|
||||
transport = QHBoxLayout()
|
||||
transport.setSpacing(6)
|
||||
transport.setSpacing(3)
|
||||
|
||||
self.btn_to_start = make_icon_button(icon_skip_start())
|
||||
self.btn_back_15 = make_icon_button(icon_skip_back(), icon_color="#aaaaaa")
|
||||
self.btn_play = make_icon_button(icon_play(), "#1a6b3a", "#00ff41", 68, 48)
|
||||
self.btn_play = make_icon_button(icon_play(), "#1a5a2a", "#00ff41", 48, 40)
|
||||
self.btn_fwd_15 = make_icon_button(icon_skip_forward(), icon_color="#aaaaaa")
|
||||
self.btn_to_end = make_icon_button(icon_skip_end())
|
||||
self.btn_stop = make_icon_button(icon_stop(), "#8a1a1a", "#ff4444")
|
||||
self.btn_stop = make_icon_button(icon_stop(), "#6a1a1a", "#ff4444")
|
||||
|
||||
transport.addWidget(self.btn_to_start)
|
||||
transport.addWidget(self.btn_back_15)
|
||||
@@ -319,33 +212,44 @@ class DeckWidget(QWidget):
|
||||
|
||||
root.addLayout(transport)
|
||||
|
||||
if self.mode == 'turn':
|
||||
self.btn_to_start.setVisible(False)
|
||||
self.btn_back_15.setVisible(False)
|
||||
self.btn_fwd_15.setVisible(False)
|
||||
self.btn_to_end.setVisible(False)
|
||||
|
||||
def apply_theme(self, theme: dict):
|
||||
self.current_theme = theme
|
||||
|
||||
border_color = theme['border']
|
||||
bg_color = theme['bg2']
|
||||
|
||||
deck_accent = theme.get('deck_border', theme['accent'])
|
||||
self.accent_color = deck_accent
|
||||
self.setStyleSheet(f"""
|
||||
QWidget {{
|
||||
background-color: {bg_color};
|
||||
border: 2px solid {border_color};
|
||||
border-radius: 6px;
|
||||
border: 2px solid {deck_accent};
|
||||
border-radius: 0px;
|
||||
}}
|
||||
""")
|
||||
self.lcd.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
background-color: {theme['lcd_bg']};
|
||||
border: 2px solid {theme['lcd_border']};
|
||||
border-radius: 0px;
|
||||
}}
|
||||
""")
|
||||
self.lcd.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
background-color: {theme['lcd_bg']};
|
||||
border: 2px solid {theme['lcd_border']};
|
||||
border-radius: 0px;
|
||||
}}
|
||||
""")
|
||||
|
||||
self.lcd.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
background-color: {theme['bg']};
|
||||
border: 2px solid {border_color};
|
||||
border-radius: 4px;
|
||||
}}
|
||||
""")
|
||||
|
||||
self.waveform.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
background-color: {theme['bg']};
|
||||
border: 1px solid {border_color};
|
||||
border-radius: 3px;
|
||||
border-radius: 0px;
|
||||
}}
|
||||
""")
|
||||
|
||||
@@ -355,21 +259,39 @@ class DeckWidget(QWidget):
|
||||
def _connect_signals(self):
|
||||
self.btn_play.clicked.connect(self._toggle_play)
|
||||
self.btn_stop.clicked.connect(self._stop_eject)
|
||||
self.btn_to_start.clicked.connect(self._go_to_start)
|
||||
self.btn_to_end.clicked.connect(self._go_to_end)
|
||||
self.btn_back_15.clicked.connect(lambda: self._scrub(-15))
|
||||
self.btn_fwd_15.clicked.connect(lambda: self._scrub(15))
|
||||
self.lcd.track_btn.clicked.connect(self._edit_track_name)
|
||||
if self.mode != 'turn':
|
||||
self.btn_to_start.clicked.connect(self._go_to_start)
|
||||
self.btn_to_end.clicked.connect(self._go_to_end)
|
||||
self.btn_back_15.clicked.connect(lambda: self._scrub(-15))
|
||||
self.btn_fwd_15.clicked.connect(lambda: self._scrub(15))
|
||||
|
||||
def _edit_track_name(self):
|
||||
current = self.lcd.track_btn.text()
|
||||
if current == "NO TRACK LOADED":
|
||||
current = ""
|
||||
name, ok = QInputDialog.getText(self, "Track Name", "Track name:", text=current)
|
||||
if ok:
|
||||
if not name.strip():
|
||||
name = "NO TRACK LOADED"
|
||||
self.lcd.update_track(name.strip())
|
||||
if self.mode == 'turn':
|
||||
deck = self.engine.decks[self.deck_key]
|
||||
if self.mode == 'turn' and name.strip() != "NO TRACK LOADED":
|
||||
dur, ok2 = QInputDialog.getText(self, "Duration", "Duration (MM:SS):", text="00:00")
|
||||
if ok2 and ':' in dur:
|
||||
parts = dur.split(':')
|
||||
try:
|
||||
total_sec = int(parts[0]) * 60 + int(parts[1])
|
||||
deck = self.engine.decks[self.deck_key]
|
||||
deck.manual_duration = float(total_sec)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def set_track_name(self, name: str):
|
||||
self.lcd.update_track(name)
|
||||
self.lcd.update_status("LOADED")
|
||||
self.lcd.update_time("00:00", "-00:00")
|
||||
QTimer.singleShot(200, self._update_waveform)
|
||||
|
||||
def _update_waveform(self):
|
||||
deck = self.engine.decks[self.deck_key]
|
||||
if deck.track:
|
||||
self.waveform.set_waveform(deck.track.audio)
|
||||
|
||||
def set_queue_name(self, name: str):
|
||||
self.lcd.update_queue(name)
|
||||
@@ -378,6 +300,8 @@ class DeckWidget(QWidget):
|
||||
self._toggle_play()
|
||||
|
||||
def cue(self):
|
||||
if self.mode == 'turn':
|
||||
return
|
||||
deck = self.engine.decks[self.deck_key]
|
||||
if deck.track is None:
|
||||
return
|
||||
@@ -390,7 +314,7 @@ class DeckWidget(QWidget):
|
||||
QPushButton {
|
||||
background-color: #1a6b3a;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
border-radius: 0px;
|
||||
}
|
||||
QPushButton:hover { background-color: #2a7b4a; }
|
||||
""")
|
||||
@@ -402,49 +326,64 @@ class DeckWidget(QWidget):
|
||||
|
||||
def _toggle_play(self):
|
||||
deck = self.engine.decks[self.deck_key]
|
||||
if deck.track is None:
|
||||
if deck.track is None and self.mode != 'turn':
|
||||
return
|
||||
if deck.playing:
|
||||
deck.pause()
|
||||
if self.mode == 'turn':
|
||||
deck.playing = False
|
||||
else:
|
||||
deck.pause()
|
||||
self.btn_play.setIcon(icon_play("#00ff41"))
|
||||
self._playing_icon = False
|
||||
self.btn_play.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #1a6b3a;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
border-radius: 0px;
|
||||
}
|
||||
QPushButton:hover { background-color: #2a7b4a; }
|
||||
""")
|
||||
self.lcd.update_status("PAUSED")
|
||||
self.lcd.update_status("PAUSED" if self.mode != 'turn' else "STOPPED")
|
||||
self.lcd.set_playing(False)
|
||||
else:
|
||||
deck.play()
|
||||
if self.mode == 'turn':
|
||||
deck.playing = True
|
||||
else:
|
||||
deck.play()
|
||||
self.btn_play.setIcon(icon_pause("#ffffff"))
|
||||
self._playing_icon = True
|
||||
self.btn_play.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #00aa33;
|
||||
border: 1px solid #00ff41;
|
||||
border-radius: 4px;
|
||||
border-radius: 0px;
|
||||
}
|
||||
QPushButton:hover { background-color: #00cc44; }
|
||||
""")
|
||||
self.lcd.update_status("PLAYING")
|
||||
self.lcd.set_playing(True)
|
||||
if deck.track:
|
||||
if self.mode == 'turn':
|
||||
deck.manual_elapsed = 0.0
|
||||
name = self.lcd.track_btn.text()
|
||||
if name and name != "NO TRACK LOADED":
|
||||
self.track_started.emit(f"[TURN] {name}")
|
||||
elif deck.track:
|
||||
self.track_started.emit(deck.track.filepath)
|
||||
|
||||
def _stop_eject(self):
|
||||
deck = self.engine.decks[self.deck_key]
|
||||
deck.stop()
|
||||
if self.mode == 'turn':
|
||||
deck.playing = False
|
||||
deck.manual_elapsed = 0.0
|
||||
else:
|
||||
deck.stop()
|
||||
self.btn_play.setIcon(icon_play("#00ff41"))
|
||||
self._playing_icon = False
|
||||
self.btn_play.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #1a6b3a;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
border-radius: 0px;
|
||||
}
|
||||
QPushButton:hover { background-color: #2a7b4a; }
|
||||
""")
|
||||
@@ -452,7 +391,6 @@ class DeckWidget(QWidget):
|
||||
self.lcd.update_time("00:00", "-00:00")
|
||||
self.lcd.update_status("STOPPED")
|
||||
self.lcd.set_playing(False)
|
||||
self.waveform.set_waveform(None)
|
||||
|
||||
def _go_to_start(self):
|
||||
self.engine.decks[self.deck_key].go_to_start()
|
||||
@@ -466,71 +404,78 @@ class DeckWidget(QWidget):
|
||||
def _refresh_display(self):
|
||||
deck = self.engine.decks[self.deck_key]
|
||||
|
||||
# Auto-load queued track when current finishes
|
||||
if not deck.playing and deck.track is not None:
|
||||
if deck.position >= deck.track.num_samples - 1:
|
||||
if deck.queued:
|
||||
deck.load(deck.queued)
|
||||
self.lcd.update_track(os.path.splitext(os.path.basename(deck.queued.filepath))[0])
|
||||
QTimer.singleShot(200, self._update_waveform)
|
||||
if self.mode == 'turn':
|
||||
if deck.playing:
|
||||
deck.manual_elapsed += 0.1
|
||||
if deck.manual_duration > 0 and deck.manual_elapsed >= deck.manual_duration:
|
||||
deck.playing = False
|
||||
self.btn_play.setIcon(icon_play("#00ff41"))
|
||||
self._playing_icon = False
|
||||
self.lcd.update_status("STOPPED")
|
||||
self.lcd.set_playing(False)
|
||||
|
||||
elapsed_s = int(deck.manual_elapsed)
|
||||
elapsed = f"{elapsed_s // 60:02d}:{elapsed_s % 60:02d}"
|
||||
if deck.manual_duration > 0:
|
||||
remaining = max(0, deck.manual_duration - deck.manual_elapsed)
|
||||
remaining_s = int(remaining)
|
||||
remaining_str = f"-{remaining_s // 60:02d}:{remaining_s % 60:02d}"
|
||||
else:
|
||||
remaining_str = "-00:00"
|
||||
self.lcd.update_time(elapsed, remaining_str)
|
||||
return
|
||||
|
||||
track = deck.track
|
||||
playing = deck.playing
|
||||
position = deck.position
|
||||
queued = deck.queued
|
||||
|
||||
if not playing and track is not None:
|
||||
if position >= track.num_samples - 1:
|
||||
if queued:
|
||||
deck.load(queued)
|
||||
self.lcd.update_track(os.path.splitext(os.path.basename(queued.filepath))[0])
|
||||
deck.queued = None
|
||||
self.lcd.update_queue("")
|
||||
self.lcd.update_status("LOADED")
|
||||
|
||||
# Sync button if stopped manually
|
||||
if not deck.playing and self._playing_icon:
|
||||
if not playing and self._playing_icon:
|
||||
self.btn_play.setIcon(icon_play("#00ff41"))
|
||||
self._playing_icon = False
|
||||
self.btn_play.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #1a6b3a;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
border-radius: 0px;
|
||||
}
|
||||
QPushButton:hover { background-color: #2a7b4a; }
|
||||
""")
|
||||
self.lcd.update_status("STOPPED")
|
||||
self.lcd.set_playing(False)
|
||||
|
||||
if deck.track:
|
||||
self.lcd.update_time(
|
||||
deck.get_elapsed(),
|
||||
deck.get_remaining()
|
||||
)
|
||||
|
||||
position_pct = deck.position / deck.track.num_samples
|
||||
remaining_sec = deck.track.duration - (deck.position / deck.track.sr)
|
||||
|
||||
# Warning if < 20 seconds OR < 15% remaining
|
||||
if track:
|
||||
self.lcd.update_time(deck.get_elapsed(), deck.get_remaining())
|
||||
position_pct = position / track.num_samples
|
||||
remaining_sec = track.duration - (position / track.sr)
|
||||
in_warning = (remaining_sec < 20) or (position_pct > 0.85)
|
||||
|
||||
# Flash header AND all numbers if in warning zone and playing
|
||||
if deck.playing and in_warning:
|
||||
|
||||
if playing and in_warning:
|
||||
flash_color = '#ff4444' if self.flash_state else self.accent_color
|
||||
|
||||
# Flash header
|
||||
self.header.setStyleSheet(f"""
|
||||
color: {flash_color};
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
padding: 2px;
|
||||
""")
|
||||
|
||||
# Flash LCD numbers
|
||||
self.lcd.flash_warning(self.flash_state)
|
||||
else:
|
||||
# Normal colors
|
||||
self.header.setStyleSheet(f"""
|
||||
color: {self.accent_color};
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
padding: 2px;
|
||||
""")
|
||||
self.lcd.flash_warning(False)
|
||||
|
||||
self.waveform.set_position(position_pct, in_warning and self.flash_state)
|
||||
|
||||
if deck.queued:
|
||||
self.lcd.update_queue(
|
||||
os.path.basename(deck.queued.filepath)
|
||||
)
|
||||
if queued:
|
||||
self.lcd.update_queue(os.path.basename(queued.filepath))
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# event_logger.py
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
LOG_DIR = os.path.expanduser("~/.gti_radiostudio/sessions")
|
||||
|
||||
|
||||
class SessionLogger:
|
||||
def __init__(self):
|
||||
self._lock = threading.Lock()
|
||||
self._log_path = None
|
||||
self._start_new_session()
|
||||
|
||||
def _start_new_session(self):
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
ts = datetime.now().strftime("%Y-%m-%d_%H%M%S")
|
||||
self._log_path = os.path.join(LOG_DIR, f"{ts}.jsonl")
|
||||
self._write_raw({
|
||||
"event": "session_start",
|
||||
"timestamp": time.time(),
|
||||
"iso": datetime.now().isoformat(),
|
||||
})
|
||||
|
||||
def _write_raw(self, entry: dict):
|
||||
with self._lock:
|
||||
try:
|
||||
with open(self._log_path, 'a') as f:
|
||||
f.write(json.dumps(entry) + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def log_cart_trigger(self, track_name: str):
|
||||
self._write_raw({
|
||||
"event": "cart_trigger",
|
||||
"timestamp": time.time(),
|
||||
"iso": datetime.now().isoformat(),
|
||||
"track": track_name,
|
||||
})
|
||||
|
||||
def log_mic_on(self, channel: int):
|
||||
self._write_raw({
|
||||
"event": "mic_on",
|
||||
"timestamp": time.time(),
|
||||
"iso": datetime.now().isoformat(),
|
||||
"channel": channel,
|
||||
})
|
||||
|
||||
def log_mic_off(self, channel: int, duration_sec: float):
|
||||
self._write_raw({
|
||||
"event": "mic_off",
|
||||
"timestamp": time.time(),
|
||||
"iso": datetime.now().isoformat(),
|
||||
"channel": channel,
|
||||
"duration_sec": round(duration_sec, 3),
|
||||
})
|
||||
|
||||
def log_deck_play(self, deck_key: str, track_name: str):
|
||||
self._write_raw({
|
||||
"event": "deck_play",
|
||||
"timestamp": time.time(),
|
||||
"iso": datetime.now().isoformat(),
|
||||
"deck": deck_key,
|
||||
"track": track_name,
|
||||
})
|
||||
|
||||
@property
|
||||
def log_path(self):
|
||||
return self._log_path
|
||||
+219
-73
@@ -1,9 +1,12 @@
|
||||
# history_widget.py
|
||||
|
||||
import csv
|
||||
import io
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
LIVE_CSV_PATH = os.path.expanduser("~/.gti_radiostudio/live_songs.csv")
|
||||
|
||||
import musicbrainzngs
|
||||
from mutagen import File as MutagenFile
|
||||
@@ -13,8 +16,8 @@ from PySide6.QtWidgets import (
|
||||
QLineEdit, QFormLayout, QDialogButtonBox,
|
||||
QFileDialog
|
||||
)
|
||||
from PySide6.QtCore import Qt, Signal, QSize
|
||||
from PySide6.QtGui import QFont
|
||||
from PySide6.QtCore import Qt, QTimer, Signal, QSize
|
||||
from PySide6.QtGui import QFont, QColor
|
||||
|
||||
from icons import icon_plus, icon_delete, icon_export
|
||||
|
||||
@@ -64,12 +67,12 @@ def _lookup_musicbrainz(artist: str, title: str) -> dict:
|
||||
class HistoryEntry(QFrame):
|
||||
deleted = Signal(object)
|
||||
|
||||
def __init__(self, index: int, filepath: str, metadata: dict,
|
||||
def __init__(self, index: int, entry_type: str, entry_data: tuple,
|
||||
parent=None):
|
||||
super().__init__(parent)
|
||||
self._index = index
|
||||
self._filepath = filepath
|
||||
self._metadata = metadata
|
||||
self._index = index
|
||||
self._entry_type = entry_type
|
||||
self._entry_data = entry_data
|
||||
|
||||
self.setFrameStyle(QFrame.Box)
|
||||
self.setStyleSheet("""
|
||||
@@ -87,29 +90,17 @@ class HistoryEntry(QFrame):
|
||||
text_col = QVBoxLayout()
|
||||
text_col.setSpacing(2)
|
||||
|
||||
artist = metadata.get('artist') or 'Unknown Artist'
|
||||
title = metadata.get('title') or self._filename(filepath)
|
||||
|
||||
top = QLabel(f"{index}. {artist} — {title}")
|
||||
top.setFont(QFont("Arial", 11, QFont.Bold))
|
||||
top.setStyleSheet(
|
||||
"color: #00ff41; background: transparent; border: none;"
|
||||
)
|
||||
top.setWordWrap(True)
|
||||
text_col.addWidget(top)
|
||||
|
||||
album = metadata.get('album') or ''
|
||||
year = metadata.get('year') or ''
|
||||
parts = [p for p in [album, year[:4] if year and len(year) >= 4 else year] if p]
|
||||
|
||||
if parts:
|
||||
sub = QLabel(" " + " · ".join(parts))
|
||||
sub.setFont(QFont("Arial", 9))
|
||||
sub.setStyleSheet(
|
||||
"color: #888888; background: transparent; border: none;"
|
||||
)
|
||||
sub.setWordWrap(True)
|
||||
text_col.addWidget(sub)
|
||||
if entry_type == 'song':
|
||||
filepath, metadata = entry_data
|
||||
artist = metadata.get('artist') or 'Unknown Artist'
|
||||
title = metadata.get('title') or self._filename(filepath)
|
||||
self._render_song(text_col, artist, title, metadata)
|
||||
elif entry_type == 'cart':
|
||||
track_name, = entry_data
|
||||
self._render_cart(text_col, track_name)
|
||||
elif entry_type == 'talk':
|
||||
duration, = entry_data
|
||||
self._render_talk(text_col, duration)
|
||||
|
||||
root.addLayout(text_col, stretch=1)
|
||||
|
||||
@@ -135,13 +126,57 @@ class HistoryEntry(QFrame):
|
||||
name = os.path.basename(filepath)
|
||||
return os.path.splitext(name)[0]
|
||||
|
||||
def _render_song(self, layout, artist: str, title: str, metadata: dict):
|
||||
top = QLabel(f"[SONG] {artist} — {title}")
|
||||
top.setFont(QFont("Arial", 11, QFont.Bold))
|
||||
top.setStyleSheet("color: #00ff41; background: transparent; border: none;")
|
||||
top.setWordWrap(True)
|
||||
layout.addWidget(top)
|
||||
|
||||
album = metadata.get('album') or ''
|
||||
year = metadata.get('year') or ''
|
||||
parts = [p for p in [album, year[:4] if year and len(year) >= 4 else year] if p]
|
||||
if parts:
|
||||
sub = QLabel(" " + " · ".join(parts))
|
||||
sub.setFont(QFont("Arial", 9))
|
||||
sub.setStyleSheet("color: #888888; background: transparent; border: none;")
|
||||
sub.setWordWrap(True)
|
||||
layout.addWidget(sub)
|
||||
|
||||
def _render_cart(self, layout, track_name: str):
|
||||
top = QLabel(f"[CART] {track_name}")
|
||||
top.setFont(QFont("Arial", 11, QFont.Bold))
|
||||
top.setStyleSheet("color: #ffaa00; background: transparent; border: none;")
|
||||
top.setWordWrap(True)
|
||||
layout.addWidget(top)
|
||||
|
||||
def _render_talk(self, layout, duration: float):
|
||||
dur_str = f"{int(duration//60):02d}:{int(duration%60):02d}"
|
||||
top = QLabel(f"[TALK] {dur_str}")
|
||||
top.setFont(QFont("Arial", 11, QFont.Bold))
|
||||
top.setStyleSheet("color: #4488ff; background: transparent; border: none;")
|
||||
top.setWordWrap(True)
|
||||
layout.addWidget(top)
|
||||
|
||||
@property
|
||||
def filepath(self) -> str:
|
||||
return self._filepath
|
||||
if self._entry_type == 'song':
|
||||
return self._entry_data[0]
|
||||
return ""
|
||||
|
||||
@property
|
||||
def metadata(self) -> dict:
|
||||
return self._metadata
|
||||
if self._entry_type == 'song':
|
||||
return self._entry_data[1]
|
||||
return {}
|
||||
|
||||
@property
|
||||
def entry_type(self) -> str:
|
||||
return self._entry_type
|
||||
|
||||
@property
|
||||
def entry_data(self) -> tuple:
|
||||
return self._entry_data
|
||||
|
||||
|
||||
class AddEntryDialog(QDialog):
|
||||
@@ -206,14 +241,13 @@ class AddEntryDialog(QDialog):
|
||||
}
|
||||
|
||||
|
||||
class HistoryWidget(QFrame):
|
||||
_metadata_ready = Signal(str, dict)
|
||||
|
||||
class ShowHistoryWidget(QFrame):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._history = []
|
||||
self._pending = {}
|
||||
self._entry_index = 0
|
||||
self._session_start = None
|
||||
self._mic_was_on = False
|
||||
|
||||
self.setFrameStyle(QFrame.Box | QFrame.Raised)
|
||||
self.setStyleSheet("""
|
||||
@@ -227,13 +261,19 @@ class HistoryWidget(QFrame):
|
||||
self._build_ui()
|
||||
self._metadata_ready.connect(self._on_metadata_ready)
|
||||
|
||||
self._mic_timer = QTimer(self)
|
||||
self._mic_timer.setInterval(500)
|
||||
self._mic_timer.timeout.connect(self._poll_mic)
|
||||
self._mic_timer.start()
|
||||
|
||||
_metadata_ready = Signal(str, dict)
|
||||
|
||||
def _build_ui(self):
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(8, 8, 8, 8)
|
||||
root.setSpacing(6)
|
||||
|
||||
# Header
|
||||
header = QLabel("PLAY HISTORY")
|
||||
header = QLabel("SHOW LOG")
|
||||
header.setFont(QFont("Arial", 11, QFont.Bold))
|
||||
header.setStyleSheet(
|
||||
"color: #336633; background: transparent; border: none; padding: 2px;"
|
||||
@@ -241,7 +281,6 @@ class HistoryWidget(QFrame):
|
||||
header.setAlignment(Qt.AlignCenter)
|
||||
root.addWidget(header)
|
||||
|
||||
# Toolbar
|
||||
bar = QHBoxLayout()
|
||||
bar.setSpacing(4)
|
||||
|
||||
@@ -263,10 +302,25 @@ class HistoryWidget(QFrame):
|
||||
self._add_btn.clicked.connect(self._add_manual_entry)
|
||||
bar.addWidget(self._add_btn)
|
||||
|
||||
self._reset_btn = QPushButton("RESET LIVE")
|
||||
self._reset_btn.setFixedHeight(28)
|
||||
self._reset_btn.setFont(QFont("Arial", 9, QFont.Bold))
|
||||
self._reset_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #3a1a1a;
|
||||
color: #ff6666;
|
||||
border: 1px solid #553333;
|
||||
border-radius: 3px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
QPushButton:hover { background-color: #5a2a2a; }
|
||||
""")
|
||||
self._reset_btn.clicked.connect(self._reset_live_csv)
|
||||
bar.addWidget(self._reset_btn)
|
||||
|
||||
bar.addStretch()
|
||||
root.addLayout(bar)
|
||||
|
||||
# Scroll area
|
||||
self._scroll = QScrollArea()
|
||||
self._scroll.setWidgetResizable(True)
|
||||
self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
@@ -296,7 +350,6 @@ class HistoryWidget(QFrame):
|
||||
self._scroll.setWidget(self._list_widget)
|
||||
root.addWidget(self._scroll)
|
||||
|
||||
# Export bar
|
||||
export_bar = QHBoxLayout()
|
||||
export_bar.setSpacing(4)
|
||||
|
||||
@@ -348,13 +401,28 @@ class HistoryWidget(QFrame):
|
||||
}}
|
||||
""")
|
||||
|
||||
def add_track(self, filepath: str):
|
||||
def _elapsed_str(self, ts: datetime = None) -> str:
|
||||
now = datetime.now()
|
||||
if self._session_start is None:
|
||||
self._session_start = now
|
||||
delta = (ts or now) - self._session_start
|
||||
total_s = int(delta.total_seconds())
|
||||
return f"{total_s//3600:02d}:{(total_s%3600)//60:02d}:{total_s%60:02d}"
|
||||
|
||||
def add_song(self, filepath: str):
|
||||
idx = self._entry_index + 1
|
||||
metadata = _read_file_tags(filepath)
|
||||
self._history.append((filepath, metadata))
|
||||
self._history.append(('song', (filepath, metadata)))
|
||||
self._entry_index = idx
|
||||
|
||||
entry = self._insert_entry(idx, filepath, metadata)
|
||||
ts = self._elapsed_str()
|
||||
self._insert_entry(idx, 'song', (filepath, metadata), ts=ts)
|
||||
self._append_live_csv(
|
||||
metadata.get('artist') or 'Unknown Artist',
|
||||
metadata.get('title') or (os.path.splitext(os.path.basename(filepath))[0] if filepath else 'Unknown'),
|
||||
metadata.get('album') or '',
|
||||
metadata.get('year') or '',
|
||||
)
|
||||
|
||||
if not metadata.get('artist') or not metadata.get('album'):
|
||||
self._pending[filepath] = idx
|
||||
@@ -364,6 +432,38 @@ class HistoryWidget(QFrame):
|
||||
daemon=True
|
||||
).start()
|
||||
|
||||
def add_cart(self, track_name: str):
|
||||
idx = self._entry_index + 1
|
||||
self._history.append(('cart', (track_name,)))
|
||||
self._entry_index = idx
|
||||
ts = self._elapsed_str()
|
||||
self._insert_entry(idx, 'cart', (track_name,), ts)
|
||||
|
||||
def add_talk(self, duration: float):
|
||||
if duration < 1:
|
||||
return
|
||||
idx = self._entry_index + 1
|
||||
self._history.append(('talk', (duration,)))
|
||||
self._entry_index = idx
|
||||
ts = self._elapsed_str()
|
||||
self._insert_entry(idx, 'talk', (duration,), ts)
|
||||
|
||||
def _poll_mic(self):
|
||||
if not hasattr(self, '_engine'):
|
||||
return
|
||||
channels = self._engine.mixer['channels']
|
||||
any_on = any(ch.mic_on for ch in channels[:2])
|
||||
if any_on and not self._mic_was_on:
|
||||
pass
|
||||
elif not any_on and self._mic_was_on:
|
||||
dur = self._engine.get_mic_duration()
|
||||
if dur > 1:
|
||||
self.add_talk(dur)
|
||||
self._mic_was_on = any_on
|
||||
|
||||
def set_engine(self, engine):
|
||||
self._engine = engine
|
||||
|
||||
def _add_manual_entry(self):
|
||||
dlg = AddEntryDialog(self)
|
||||
if dlg.exec() != QDialog.Accepted:
|
||||
@@ -380,13 +480,16 @@ class HistoryWidget(QFrame):
|
||||
'album': None,
|
||||
'year': None,
|
||||
}
|
||||
self._history.append(("", metadata))
|
||||
self._history.append(('song', ("", metadata)))
|
||||
self._entry_index = idx
|
||||
self._insert_entry(idx, "", metadata)
|
||||
ts = self._elapsed_str()
|
||||
self._insert_entry(idx, 'song', ("", metadata), ts)
|
||||
|
||||
def _insert_entry(self, index: int, filepath: str,
|
||||
metadata: dict) -> HistoryEntry:
|
||||
entry = HistoryEntry(index, filepath, metadata)
|
||||
_pending = {}
|
||||
|
||||
def _insert_entry(self, index: int, entry_type: str,
|
||||
entry_data: tuple, ts: str = "") -> HistoryEntry:
|
||||
entry = HistoryEntry(index, entry_type, entry_data)
|
||||
entry.deleted.connect(self._delete_entry)
|
||||
self._list_layout.insertWidget(0, entry)
|
||||
self._scroll.verticalScrollBar().setValue(0)
|
||||
@@ -398,6 +501,8 @@ class HistoryWidget(QFrame):
|
||||
self._history.pop(idx)
|
||||
entry.deleteLater()
|
||||
self._rebuild_list()
|
||||
if entry.entry_type == 'song':
|
||||
self._rewrite_live_csv()
|
||||
|
||||
def _enrich_metadata(self, filepath: str, existing: dict):
|
||||
artist = existing.get('artist')
|
||||
@@ -415,9 +520,9 @@ class HistoryWidget(QFrame):
|
||||
self._metadata_ready.emit(filepath, merged)
|
||||
|
||||
def _on_metadata_ready(self, filepath: str, metadata: dict):
|
||||
for i, (fp, _) in enumerate(self._history):
|
||||
if fp == filepath:
|
||||
self._history[i] = (fp, metadata)
|
||||
for i, (entry_type, entry_data) in enumerate(self._history):
|
||||
if entry_type == 'song' and entry_data[0] == filepath:
|
||||
self._history[i] = ('song', (filepath, metadata))
|
||||
break
|
||||
self._rebuild_list()
|
||||
|
||||
@@ -427,52 +532,93 @@ class HistoryWidget(QFrame):
|
||||
if item.widget():
|
||||
item.widget().deleteLater()
|
||||
|
||||
for i, (filepath, metadata) in enumerate(reversed(self._history)):
|
||||
for i, (entry_type, entry_data) in enumerate(reversed(self._history)):
|
||||
index = len(self._history) - i
|
||||
entry = HistoryEntry(index, filepath, metadata)
|
||||
entry = HistoryEntry(index, entry_type, entry_data)
|
||||
entry.deleted.connect(self._delete_entry)
|
||||
self._list_layout.insertWidget(
|
||||
self._list_layout.count() - 1, entry
|
||||
)
|
||||
|
||||
def _iter_entries(self):
|
||||
for i, (fp, md) in enumerate(reversed(self._history)):
|
||||
idx = len(self._history) - i
|
||||
artist = md.get('artist') or 'Unknown Artist'
|
||||
title = md.get('title') or (os.path.splitext(os.path.basename(fp))[0] if fp else 'Unknown')
|
||||
album = md.get('album') or ''
|
||||
year = md.get('year') or ''
|
||||
yield idx, artist, title, album, year
|
||||
for entry_type, entry_data in reversed(self._history):
|
||||
if entry_type == 'song':
|
||||
fp, md = entry_data
|
||||
artist = md.get('artist') or 'Unknown Artist'
|
||||
title = md.get('title') or (os.path.splitext(os.path.basename(fp))[0] if fp else 'Unknown')
|
||||
album = md.get('album') or ''
|
||||
year = md.get('year') or ''
|
||||
yield ('SONG', f"{artist} — {title}", album, year)
|
||||
elif entry_type == 'cart':
|
||||
track_name, = entry_data
|
||||
yield ('CART', track_name, '', '')
|
||||
elif entry_type == 'talk':
|
||||
duration, = entry_data
|
||||
dur_str = f"{int(duration//60):02d}:{int(duration%60):02d}"
|
||||
yield ('TALK', dur_str, '', '')
|
||||
|
||||
def _append_live_csv(self, artist: str, title: str,
|
||||
album: str, year: str):
|
||||
os.makedirs(os.path.dirname(LIVE_CSV_PATH), exist_ok=True)
|
||||
write_header = not os.path.exists(LIVE_CSV_PATH)
|
||||
with open(LIVE_CSV_PATH, 'a', newline='') as f:
|
||||
w = csv.writer(f)
|
||||
if write_header:
|
||||
w.writerow(["Artist", "Title", "Album", "Year"])
|
||||
w.writerow([artist, title, album, year])
|
||||
|
||||
def _rewrite_live_csv(self):
|
||||
if not os.path.exists(LIVE_CSV_PATH):
|
||||
return
|
||||
with open(LIVE_CSV_PATH, 'w', newline='') as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow(["Artist", "Title", "Album", "Year"])
|
||||
for entry_type, entry_data in self._history:
|
||||
if entry_type == 'song':
|
||||
fp, md = entry_data
|
||||
artist = md.get('artist') or 'Unknown Artist'
|
||||
title = md.get('title') or (os.path.splitext(os.path.basename(fp))[0] if fp else 'Unknown')
|
||||
album = md.get('album') or ''
|
||||
year = md.get('year') or ''
|
||||
w.writerow([artist, title, album, year])
|
||||
|
||||
def _reset_live_csv(self):
|
||||
try:
|
||||
if os.path.exists(LIVE_CSV_PATH):
|
||||
os.remove(LIVE_CSV_PATH)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _export_txt(self):
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self, "Export History (TXT)",
|
||||
os.path.expanduser("~/play_history.txt"),
|
||||
self, "Export Show Log",
|
||||
os.path.expanduser("~/show_log.txt"),
|
||||
"Text Files (*.txt)"
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
with open(path, 'w') as f:
|
||||
for idx, artist, title, album, year in self._iter_entries():
|
||||
f.write(f"{idx}. {artist} — {title}")
|
||||
f.write(f"=== GTI Radio Studio — Show Log ===\n")
|
||||
f.write(f"Exported: {now}\n\n")
|
||||
for tag, text, album, year in self._iter_entries():
|
||||
f.write(f"[{tag}] {text}")
|
||||
if album or year:
|
||||
parts = [p for p in [album,
|
||||
year[:4] if year and len(year) >= 4 else year]
|
||||
if p]
|
||||
parts = [p for p in [album, year[:4] if year and len(year) >= 4 else year] if p]
|
||||
if parts:
|
||||
f.write(f"\n {' · '.join(parts)}")
|
||||
f.write("\n")
|
||||
|
||||
def _export_csv(self):
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self, "Export History (CSV)",
|
||||
os.path.expanduser("~/play_history.csv"),
|
||||
self, "Export Show Log (CSV)",
|
||||
os.path.expanduser("~/show_log.csv"),
|
||||
"CSV Files (*.csv)"
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
with open(path, 'w', newline='') as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow(["#", "Artist", "Title", "Album", "Year"])
|
||||
for idx, artist, title, album, year in self._iter_entries():
|
||||
w.writerow([idx, artist, title, album, year])
|
||||
w.writerow(["#", "Type", "Text", "Album", "Year"])
|
||||
for i, (tag, text, album, year) in enumerate(self._iter_entries()):
|
||||
w.writerow([i + 1, tag, text, album, year])
|
||||
@@ -223,6 +223,129 @@ def icon_export(color: str = "#aaaaaa") -> QIcon:
|
||||
return _make_icon(paint, color)
|
||||
|
||||
|
||||
def icon_mute(color: str = "#ff4444") -> QIcon:
|
||||
def paint(p, r, c):
|
||||
pen = QPen(c, r.width() * 0.12)
|
||||
pen.setCapStyle(Qt.RoundCap)
|
||||
p.setPen(pen)
|
||||
p.setBrush(Qt.NoBrush)
|
||||
# Speaker body
|
||||
cx = r.left() + r.width() * 0.3
|
||||
p.drawLine(QPointF(cx, r.top() + r.height() * 0.25),
|
||||
QPointF(cx, r.bottom() - r.height() * 0.25))
|
||||
p.drawLine(QPointF(cx, r.top() + r.height() * 0.25),
|
||||
QPointF(r.left() + r.width() * 0.5, r.center().y() - r.height() * 0.15))
|
||||
p.drawLine(QPointF(cx, r.bottom() - r.height() * 0.25),
|
||||
QPointF(r.left() + r.width() * 0.5, r.center().y() + r.height() * 0.15))
|
||||
p.drawLine(QPointF(r.left() + r.width() * 0.2, r.center().y() - r.height() * 0.15),
|
||||
QPointF(r.left() + r.width() * 0.5, r.center().y() - r.height() * 0.15))
|
||||
p.drawLine(QPointF(r.left() + r.width() * 0.2, r.center().y() + r.height() * 0.15),
|
||||
QPointF(r.left() + r.width() * 0.5, r.center().y() + r.height() * 0.15))
|
||||
# X over speaker
|
||||
x = r.right() - r.width() * 0.2
|
||||
y1 = r.top() + r.height() * 0.2
|
||||
y2 = r.bottom() - r.height() * 0.2
|
||||
p.drawLine(QPointF(x, y1), QPointF(r.right() - r.width() * 0.05, y2))
|
||||
p.drawLine(QPointF(r.right() - r.width() * 0.05, y1), QPointF(x, y2))
|
||||
return _make_icon(paint, color)
|
||||
|
||||
|
||||
def icon_solo(color: str = "#ffaa00") -> QIcon:
|
||||
def paint(p, r, c):
|
||||
p.setPen(Qt.NoPen)
|
||||
p.setBrush(c)
|
||||
# Circle with "S"
|
||||
center = r.center()
|
||||
radius = min(r.width(), r.height()) * 0.45
|
||||
p.drawEllipse(center, radius, radius)
|
||||
# S letter (white)
|
||||
pen = QPen(QColor("#000000"), r.width() * 0.12)
|
||||
pen.setCapStyle(Qt.RoundCap)
|
||||
p.setPen(pen)
|
||||
p.setBrush(Qt.NoBrush)
|
||||
path = QPainterPath()
|
||||
path.moveTo(r.left() + r.width() * 0.45, r.top() + r.height() * 0.3)
|
||||
path.cubicTo(
|
||||
r.left() + r.width() * 0.7, r.top() + r.height() * 0.15,
|
||||
r.right() - r.width() * 0.25, r.top() + r.height() * 0.35,
|
||||
r.left() + r.width() * 0.55, r.top() + r.height() * 0.5
|
||||
)
|
||||
path.cubicTo(
|
||||
r.left() + r.width() * 0.3, r.top() + r.height() * 0.65,
|
||||
r.right() - r.width() * 0.15, r.top() + r.height() * 0.7,
|
||||
r.left() + r.width() * 0.45, r.bottom() - r.height() * 0.2
|
||||
)
|
||||
p.drawPath(path)
|
||||
return _make_icon(paint, color)
|
||||
|
||||
|
||||
def icon_mic(color: str = "#00ff41") -> QIcon:
|
||||
def paint(p, r, c):
|
||||
p.setPen(Qt.NoPen)
|
||||
p.setBrush(c)
|
||||
# Mic body - pill/rectangle shape
|
||||
bw = r.width() * 0.35
|
||||
bh = r.height() * 0.45
|
||||
bx = r.center().x() - bw / 2
|
||||
by = r.top() + r.height() * 0.05
|
||||
p.drawRoundedRect(QRectF(bx, by, bw, bh), bw * 0.3, bw * 0.3)
|
||||
# Mic arc at top
|
||||
path = QPainterPath()
|
||||
arc_w = bw * 0.6
|
||||
arc_h = r.height() * 0.08
|
||||
path.moveTo(r.center().x() - arc_w / 2, by)
|
||||
path.quadTo(r.center().x(), by - arc_h, r.center().x() + arc_w / 2, by)
|
||||
p.drawPath(path)
|
||||
# Stand lines down from body
|
||||
pen = QPen(c, r.width() * 0.06)
|
||||
pen.setCapStyle(Qt.RoundCap)
|
||||
p.setPen(pen)
|
||||
p.setBrush(Qt.NoBrush)
|
||||
stand_top = by + bh
|
||||
p.drawLine(QPointF(r.center().x(), stand_top),
|
||||
QPointF(r.center().x(), stand_top + r.height() * 0.15))
|
||||
# Base
|
||||
base_w = r.width() * 0.45
|
||||
base_h = r.height() * 0.1
|
||||
p.drawRoundedRect(QRectF(r.center().x() - base_w / 2,
|
||||
stand_top + r.height() * 0.12,
|
||||
base_w, base_h), base_w * 0.15, base_w * 0.15)
|
||||
return _make_icon(paint, color)
|
||||
|
||||
|
||||
def icon_cog(color: str = "#aaaaaa") -> QIcon:
|
||||
def paint(p, r, c):
|
||||
pen = QPen(c, r.width() * 0.1)
|
||||
pen.setCapStyle(Qt.RoundCap)
|
||||
p.setPen(pen)
|
||||
p.setBrush(Qt.NoBrush)
|
||||
# Outer circle (gear ring)
|
||||
cx, cy = r.center().x(), r.center().y()
|
||||
outer_r = min(r.width(), r.height()) * 0.42
|
||||
inner_r = outer_r * 0.6
|
||||
# Draw gear teeth (6 bumps)
|
||||
import math
|
||||
p.setBrush(QColor(c))
|
||||
for i in range(6):
|
||||
angle = i * 60 - 30
|
||||
a_rad = math.radians(angle)
|
||||
bx = cx + outer_r * math.cos(a_rad)
|
||||
by = cy + outer_r * math.sin(a_rad)
|
||||
tooth = QPainterPath()
|
||||
tw = r.width() * 0.12
|
||||
th = r.width() * 0.18
|
||||
tooth.addRect(bx - tw/2, by - th/2, tw, th)
|
||||
p.drawRect(bx - tw/2, by - th/2, tw, th)
|
||||
# Center circle
|
||||
p.setPen(Qt.NoPen)
|
||||
p.setBrush(QColor(c))
|
||||
p.drawEllipse(QPointF(cx, cy), inner_r, inner_r)
|
||||
# Inner hole
|
||||
p.setBrush(QColor("#000000"))
|
||||
p.drawEllipse(QPointF(cx, cy), inner_r * 0.55, inner_r * 0.55)
|
||||
return _make_icon(paint, color)
|
||||
|
||||
|
||||
def icon_theme(color: str = "#00ff41") -> QIcon:
|
||||
def paint(p, r, c):
|
||||
p.setPen(Qt.NoPen)
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
# lufs_meter_widget.py
|
||||
|
||||
from PySide6.QtWidgets import QFrame, QVBoxLayout, QLabel, QWidget
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtGui import QFont, QPainter, QColor, QLinearGradient, QPen
|
||||
|
||||
|
||||
class LUFSMeterWidget(QFrame):
|
||||
"""Vertical LUFS meter with -14 LUFS target for streaming/broadcast."""
|
||||
|
||||
def __init__(self, engine, parent=None):
|
||||
super().__init__(parent)
|
||||
self.engine = engine
|
||||
|
||||
self.setFixedWidth(80)
|
||||
self.setFrameStyle(QFrame.Box | QFrame.Raised)
|
||||
self.setStyleSheet("""
|
||||
QFrame {
|
||||
background-color: #0d0d0d;
|
||||
border: 2px solid #444444;
|
||||
border-radius: 6px;
|
||||
}
|
||||
""")
|
||||
|
||||
self._build_ui()
|
||||
|
||||
self.timer = QTimer(self)
|
||||
self.timer.setInterval(100)
|
||||
self.timer.timeout.connect(self._update_meter)
|
||||
self.timer.start()
|
||||
|
||||
def _build_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(6, 8, 6, 8)
|
||||
|
||||
header = QLabel("LUFS")
|
||||
header.setFont(QFont("Arial", 10, QFont.Bold))
|
||||
header.setStyleSheet("color: #888888; background: transparent; border: none;")
|
||||
header.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(header)
|
||||
|
||||
self.meter_canvas = MeterCanvas(self.engine)
|
||||
layout.addWidget(self.meter_canvas, stretch=1)
|
||||
|
||||
self.readout = QLabel("-70.0")
|
||||
self.readout.setFont(QFont("Courier New", 12, QFont.Bold))
|
||||
self.readout.setStyleSheet("color: #00ff41; background: transparent; border: none;")
|
||||
self.readout.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(self.readout)
|
||||
|
||||
def _update_meter(self):
|
||||
lufs = self.engine.lufs_current
|
||||
self.readout.setText(f"{lufs:.1f}")
|
||||
self.meter_canvas.update()
|
||||
|
||||
|
||||
class MeterCanvas(QWidget):
|
||||
"""Custom painted LUFS bar with -14 LUFS target."""
|
||||
|
||||
def __init__(self, engine, parent=None):
|
||||
super().__init__(parent)
|
||||
self.engine = engine
|
||||
self.setStyleSheet("""
|
||||
QWidget {
|
||||
background-color: #000000;
|
||||
border: 1px solid #333333;
|
||||
border-radius: 3px;
|
||||
}
|
||||
""")
|
||||
self.setMinimumHeight(200)
|
||||
|
||||
def paintEvent(self, event):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.Antialiasing)
|
||||
|
||||
rect = self.rect().adjusted(3, 3, -3, -3)
|
||||
lufs = self.engine.lufs_current
|
||||
|
||||
# Scale: -50 LUFS (bottom) to 0 (top) - makes -14 more readable
|
||||
lufs_min = -50
|
||||
lufs_max = 0
|
||||
lufs_range = lufs_max - lufs_min
|
||||
|
||||
lufs = max(lufs_min, min(lufs_max, lufs))
|
||||
fill_pct = (lufs - lufs_min) / lufs_range
|
||||
fill_h = int(rect.height() * fill_pct)
|
||||
|
||||
# Color zones optimized for -14 LUFS target
|
||||
gradient = QLinearGradient(0, rect.bottom(), 0, rect.top())
|
||||
|
||||
if lufs < -30:
|
||||
# Very quiet - dark green
|
||||
gradient.setColorAt(0, QColor("#002200"))
|
||||
gradient.setColorAt(1, QColor("#004400"))
|
||||
elif lufs < -18:
|
||||
# Quiet - green
|
||||
gradient.setColorAt(0, QColor("#00aa00"))
|
||||
gradient.setColorAt(1, QColor("#00dd00"))
|
||||
elif lufs < -12:
|
||||
# Target zone -18 to -12 LUFS (±4 LU around -14) - bright green
|
||||
gradient.setColorAt(0, QColor("#00ff00"))
|
||||
gradient.setColorAt(1, QColor("#00ff00"))
|
||||
elif lufs < -6:
|
||||
# Getting hot - yellow
|
||||
gradient.setColorAt(0, QColor("#ffcc00"))
|
||||
gradient.setColorAt(1, QColor("#ffaa00"))
|
||||
else:
|
||||
# Too hot - red
|
||||
gradient.setColorAt(0, QColor("#ff0000"))
|
||||
gradient.setColorAt(1, QColor("#ff0000"))
|
||||
|
||||
painter.fillRect(
|
||||
rect.x(),
|
||||
rect.bottom() - fill_h,
|
||||
rect.width(),
|
||||
fill_h,
|
||||
gradient
|
||||
)
|
||||
|
||||
# Draw reference lines
|
||||
painter.setPen(QPen(QColor("#ffffff"), 1))
|
||||
|
||||
# Target line at -14 LUFS (main target)
|
||||
target_pct = ((-14 - lufs_min) / lufs_range)
|
||||
target_y = rect.bottom() - int(rect.height() * target_pct)
|
||||
painter.drawLine(rect.x(), target_y, rect.right(), target_y)
|
||||
|
||||
# Upper boundary at -12 LUFS (dotted)
|
||||
painter.setPen(QPen(QColor("#888888"), 1, Qt.DotLine))
|
||||
upper_pct = ((-12 - lufs_min) / lufs_range)
|
||||
upper_y = rect.bottom() - int(rect.height() * upper_pct)
|
||||
painter.drawLine(rect.x(), upper_y, rect.right(), upper_y)
|
||||
|
||||
# Lower boundary at -16 LUFS (dotted)
|
||||
lower_pct = ((-16 - lufs_min) / lufs_range)
|
||||
lower_y = rect.bottom() - int(rect.height() * lower_pct)
|
||||
painter.drawLine(rect.x(), lower_y, rect.right(), lower_y)
|
||||
|
||||
painter.end()
|
||||
@@ -1,8 +1,14 @@
|
||||
# main.py
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
__version__ = "0.6"
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication, QMainWindow, QWidget,
|
||||
QApplication, QMainWindow, QWidget, QFileDialog,
|
||||
QVBoxLayout, QHBoxLayout, QSplitter
|
||||
)
|
||||
from PySide6.QtCore import Qt
|
||||
@@ -10,12 +16,15 @@ from PySide6.QtGui import QFont, QColor, QPalette
|
||||
|
||||
from audio_engine import AudioEngine
|
||||
from deck_widget import DeckWidget
|
||||
from cart_widget import CartBankWidget
|
||||
from cart_widget import CartPlayerWidget
|
||||
from playlist_widget import PlaylistWidget
|
||||
from midi_engine import MidiEngine
|
||||
from top_bar_widget import TopBarWidget, THEMES
|
||||
from history_widget import HistoryWidget
|
||||
from lufs_meter_widget import LUFSMeterWidget
|
||||
from history_widget import ShowHistoryWidget
|
||||
from mixer_widget import MixerWidget, MicTimerWidget, LUFSExpandedSection
|
||||
from event_logger import SessionLogger
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RadioPanelWindow(QMainWindow):
|
||||
@@ -23,9 +32,11 @@ class RadioPanelWindow(QMainWindow):
|
||||
super().__init__()
|
||||
|
||||
self.engine = AudioEngine()
|
||||
self.session_logger = SessionLogger()
|
||||
self.engine.logger = self.session_logger
|
||||
|
||||
self.setWindowTitle("Getting To It")
|
||||
self.setMinimumSize(1280, 720)
|
||||
self.setWindowTitle(f"Getting To It v{__version__}")
|
||||
self.setMinimumSize(1400, 860)
|
||||
self.resize(1920, 1080)
|
||||
self._apply_global_style(THEMES[0])
|
||||
|
||||
@@ -33,31 +44,28 @@ class RadioPanelWindow(QMainWindow):
|
||||
self.setCentralWidget(central)
|
||||
|
||||
outer = QVBoxLayout(central)
|
||||
outer.setContentsMargins(8, 8, 8, 8)
|
||||
outer.setSpacing(8)
|
||||
outer.setContentsMargins(6, 6, 6, 6)
|
||||
outer.setSpacing(6)
|
||||
|
||||
# Top bar
|
||||
self.top_bar = TopBarWidget()
|
||||
self.top_bar.theme_changed.connect(self._on_theme_changed)
|
||||
self.top_bar.ratio_changed.connect(self._apply_ratio)
|
||||
self.top_bar.save_session.connect(self._save_session)
|
||||
self.top_bar.load_session.connect(self._load_session)
|
||||
self.top_bar.recording_toggled.connect(self._on_recording_toggled)
|
||||
outer.addWidget(self.top_bar)
|
||||
|
||||
# ── Build widgets ─────────────────────────────────────────────────
|
||||
|
||||
self.deck1 = DeckWidget(
|
||||
deck_key="deck1",
|
||||
label="DECK 1",
|
||||
accent_color="#00897b",
|
||||
engine=self.engine
|
||||
)
|
||||
self.deck2 = DeckWidget(
|
||||
deck_key="deck2",
|
||||
label="DECK 2",
|
||||
accent_color="#f9ca24",
|
||||
engine=self.engine
|
||||
)
|
||||
self.deck1 = DeckWidget("deck1", "DECK_001", "#00b894", self.engine, mode='deck')
|
||||
self.deck2 = DeckWidget("deck2", "DECK_002", "#6c5ce7", self.engine, mode='deck')
|
||||
|
||||
self.cart_bank = CartBankWidget(self.engine)
|
||||
self.cart_bank.setFixedHeight(280)
|
||||
self.cart_player = CartPlayerWidget(self.engine)
|
||||
self.cart_player.setFixedHeight(160)
|
||||
|
||||
self.mic_timer = MicTimerWidget(self.engine)
|
||||
self.lufs_section = LUFSExpandedSection(self.engine)
|
||||
|
||||
self.playlist_widget = PlaylistWidget(
|
||||
engine=self.engine,
|
||||
@@ -65,54 +73,65 @@ class RadioPanelWindow(QMainWindow):
|
||||
"deck1": self.deck1,
|
||||
"deck2": self.deck2,
|
||||
},
|
||||
cart_slots=self.cart_bank.slots
|
||||
cart_player=self.cart_player
|
||||
)
|
||||
|
||||
self.history_widget = HistoryWidget()
|
||||
self.history_widget = ShowHistoryWidget()
|
||||
self.history_widget.set_engine(self.engine)
|
||||
|
||||
self.lufs_meter = LUFSMeterWidget(self.engine)
|
||||
self.mixer = MixerWidget(self.engine)
|
||||
|
||||
# ── Centre column ─────────────────────────────────────────────────
|
||||
centre_col = QHBoxLayout()
|
||||
centre_col.setSpacing(8)
|
||||
# ── Centre column with vertical splitter ─────────────────────────
|
||||
centre_col = QVBoxLayout()
|
||||
centre_col.setSpacing(6)
|
||||
|
||||
# Decks column
|
||||
deck_col = QVBoxLayout()
|
||||
deck_col.setSpacing(8)
|
||||
# Top: 2 decks + cart
|
||||
deck_area = QWidget()
|
||||
deck_area.setStyleSheet("background: transparent; border: none;")
|
||||
deck_layout = QVBoxLayout(deck_area)
|
||||
deck_layout.setContentsMargins(0, 0, 0, 0)
|
||||
deck_layout.setSpacing(4)
|
||||
|
||||
deck_row = QHBoxLayout()
|
||||
deck_row.setSpacing(8)
|
||||
deck_row.addWidget(self.deck1)
|
||||
deck_row.addWidget(self.deck2)
|
||||
deck_col.addLayout(deck_row)
|
||||
deck_row1 = QHBoxLayout()
|
||||
deck_row1.setSpacing(4)
|
||||
deck_row1.addWidget(self.deck1)
|
||||
deck_row1.addWidget(self.deck2)
|
||||
deck_layout.addLayout(deck_row1)
|
||||
|
||||
deck_col.addWidget(self.cart_bank)
|
||||
deck_col.addStretch()
|
||||
cart_row = QHBoxLayout()
|
||||
cart_row.setSpacing(4)
|
||||
cart_row.addWidget(self.cart_player)
|
||||
mic_lufs_row = QHBoxLayout()
|
||||
mic_lufs_row.setSpacing(4)
|
||||
mic_lufs_row.addWidget(self.mic_timer, stretch=1)
|
||||
mic_lufs_row.addWidget(self.lufs_section, stretch=1)
|
||||
cart_row.addLayout(mic_lufs_row)
|
||||
deck_layout.addLayout(cart_row)
|
||||
|
||||
centre_col.addLayout(deck_col)
|
||||
|
||||
# LUFS meter on right of centre section
|
||||
centre_col.addWidget(self.lufs_meter)
|
||||
# Mixer fills remaining space below decks
|
||||
centre_col.addWidget(deck_area)
|
||||
centre_col.addWidget(self.mixer, stretch=1)
|
||||
|
||||
centre_widget = QWidget()
|
||||
centre_widget.setStyleSheet("background: transparent; border: none;")
|
||||
centre_widget.setLayout(centre_col)
|
||||
|
||||
# ── Splitter ──────────────────────────────────────────────────────
|
||||
# ── Horizontal splitter ──────────────────────────────────────────
|
||||
self.splitter = QSplitter(Qt.Horizontal)
|
||||
self.splitter.setHandleWidth(6)
|
||||
self.splitter.setHandleWidth(4)
|
||||
self._style_splitter(THEMES[0])
|
||||
|
||||
self.splitter.addWidget(self.playlist_widget)
|
||||
self.splitter.addWidget(centre_widget)
|
||||
self.splitter.addWidget(self.history_widget)
|
||||
self.splitter.setSizes([400, 1120, 400])
|
||||
self.splitter.setSizes([360, 1160, 360])
|
||||
|
||||
outer.addWidget(self.splitter)
|
||||
|
||||
# ── Wire signals ──────────────────────────────────────────────────
|
||||
self.deck1.track_started.connect(self.history_widget.add_track)
|
||||
self.deck2.track_started.connect(self.history_widget.add_track)
|
||||
# ── Wire signals ─────────────────────────────────────────────────
|
||||
self.deck1.track_started.connect(self.history_widget.add_song)
|
||||
self.deck2.track_started.connect(self.history_widget.add_song)
|
||||
self.cart_player.cart_triggered.connect(self.history_widget.add_cart)
|
||||
|
||||
# ── MIDI ──────────────────────────────────────────────────────────
|
||||
self._setup_midi()
|
||||
@@ -120,8 +139,8 @@ class RadioPanelWindow(QMainWindow):
|
||||
def _style_splitter(self, theme: dict):
|
||||
self.splitter.setStyleSheet(f"""
|
||||
QSplitter::handle {{
|
||||
background-color: #333333;
|
||||
border-radius: 3px;
|
||||
background-color: #22242a;
|
||||
border-radius: 2px;
|
||||
}}
|
||||
QSplitter::handle:hover {{
|
||||
background-color: {theme['accent']};
|
||||
@@ -136,8 +155,14 @@ class RadioPanelWindow(QMainWindow):
|
||||
self._style_splitter(theme)
|
||||
self.deck1.apply_theme(theme)
|
||||
self.deck2.apply_theme(theme)
|
||||
self.cart_bank.apply_theme(theme)
|
||||
self.cart_player.apply_theme(theme)
|
||||
self.history_widget.apply_theme(theme)
|
||||
self.mixer.apply_theme(theme)
|
||||
self.mic_timer.apply_theme(theme)
|
||||
self.lufs_section.apply_theme(theme)
|
||||
|
||||
def _apply_ratio(self, w, h):
|
||||
self.resize(w, h)
|
||||
|
||||
def _apply_global_style(self, theme: dict):
|
||||
self.setStyleSheet(f"""
|
||||
@@ -147,7 +172,7 @@ class RadioPanelWindow(QMainWindow):
|
||||
QWidget {{
|
||||
background-color: {theme['bg']};
|
||||
color: {theme['text']};
|
||||
font-family: Arial;
|
||||
font-family: "Segoe UI", Arial, sans-serif;
|
||||
}}
|
||||
QToolTip {{
|
||||
background-color: {theme['bg2']};
|
||||
@@ -157,6 +182,68 @@ class RadioPanelWindow(QMainWindow):
|
||||
}}
|
||||
""")
|
||||
|
||||
def _save_session(self):
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self.top_bar, "Save Session",
|
||||
os.path.expanduser("~/.gti_radiostudio/session.json"),
|
||||
"JSON Files (*.json)"
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
|
||||
state = {
|
||||
'window_size': (self.width(), self.height()),
|
||||
'pfl_hp_blend': self.engine.mixer['master'].pfl_hp_blend,
|
||||
'channels': [],
|
||||
}
|
||||
|
||||
for ch in self.engine.mixer['channels']:
|
||||
state['channels'].append({
|
||||
'name': ch.name,
|
||||
'volume': ch.volume,
|
||||
'gain': ch.gain,
|
||||
'muted': ch.muted,
|
||||
'pfl': ch.pfl,
|
||||
'mic_on': ch.mic_on,
|
||||
})
|
||||
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, 'w') as f:
|
||||
json.dump(state, f, indent=2)
|
||||
|
||||
def _load_session(self):
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self.top_bar, "Load Session",
|
||||
os.path.expanduser("~/.gti_radiostudio/session.json"),
|
||||
"JSON Files (*.json)"
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
|
||||
with open(path) as f:
|
||||
state = json.load(f)
|
||||
|
||||
ws = state.get('window_size')
|
||||
if ws:
|
||||
self.resize(ws[0], ws[1])
|
||||
|
||||
pfl_hp_blend = state.get('pfl_hp_blend')
|
||||
if pfl_hp_blend is not None:
|
||||
self.engine.mixer['master'].pfl_hp_blend = pfl_hp_blend
|
||||
|
||||
for i, ch_state in enumerate(state.get('channels', [])):
|
||||
if i < len(self.engine.mixer['channels']):
|
||||
ch = self.engine.mixer['channels'][i]
|
||||
ch.name = ch_state.get('name', ch.name)
|
||||
ch.volume = ch_state.get('volume', ch.volume)
|
||||
ch.gain = ch_state.get('gain', ch.gain)
|
||||
ch.muted = ch_state.get('muted', ch.muted)
|
||||
ch.solo = ch_state.get('solo', ch.solo)
|
||||
ch.pfl = ch_state.get('pfl', ch.pfl)
|
||||
ch.mic_on = ch_state.get('mic_on', ch.mic_on)
|
||||
|
||||
self.mixer.refresh_strips()
|
||||
|
||||
def _setup_midi(self):
|
||||
self.midi = MidiEngine()
|
||||
|
||||
@@ -164,9 +251,21 @@ class RadioPanelWindow(QMainWindow):
|
||||
self.midi.deck_cue.connect(self._on_midi_cue)
|
||||
self.midi.deck_jog.connect(self._on_midi_jog)
|
||||
self.midi.cart_trigger.connect(self._on_midi_cart)
|
||||
self.midi.mixer_volume.connect(self._on_midi_mixer_volume)
|
||||
self.midi.mixer_mute.connect(self._on_midi_mixer_mute)
|
||||
self.midi.mixer_pfl.connect(self._on_midi_mixer_pfl)
|
||||
self.midi.mixer_mic_on.connect(self._on_midi_mixer_mic_on)
|
||||
self.midi.mixer_gain.connect(self._on_midi_mixer_gain)
|
||||
self.midi.master_control.connect(self._on_midi_master_control)
|
||||
self.midi.master_volume.connect(self._on_midi_master_volume)
|
||||
self.midi.controller_connected.connect(self._on_midi_status)
|
||||
|
||||
self.midi.start()
|
||||
self.top_bar.set_midi_engine(self.midi)
|
||||
|
||||
ports = self.midi.list_ports()
|
||||
if ports:
|
||||
logger.info("Available ports: %s", ports)
|
||||
|
||||
def _on_midi_play_pause(self, deck: str):
|
||||
if deck == 'deck1':
|
||||
@@ -187,19 +286,49 @@ class RadioPanelWindow(QMainWindow):
|
||||
self.deck2.scrub(delta)
|
||||
|
||||
def _on_midi_cart(self, slot: int):
|
||||
self.cart_bank.slots[slot].toggle()
|
||||
self.cart_player.toggle()
|
||||
|
||||
def _on_midi_mixer_volume(self, channel: int, value: float):
|
||||
self.engine.set_channel_volume(channel, value)
|
||||
|
||||
def _on_midi_mixer_mute(self, channel: int, muted: bool):
|
||||
self.engine.set_channel_mute(channel, muted)
|
||||
|
||||
def _on_midi_mixer_pfl(self, channel: int, on: bool):
|
||||
self.engine.set_channel_pfl(channel, on)
|
||||
|
||||
def _on_midi_mixer_mic_on(self, channel: int, on: bool):
|
||||
if channel < 2:
|
||||
current = self.engine.get_channel_mic_on(channel)
|
||||
self.engine.set_channel_mic_on(channel, not current)
|
||||
|
||||
def _on_midi_mixer_gain(self, channel: int, gain: float):
|
||||
self.engine.set_channel_gain(channel, gain)
|
||||
|
||||
def _on_midi_master_control(self, knob: str, val: float):
|
||||
if knob == "pfl_hp_blend":
|
||||
self.engine.mixer['master'].pfl_hp_blend = max(0.0, min(1.0, val))
|
||||
|
||||
def _on_midi_master_volume(self, val: float):
|
||||
self.engine.mixer['master'].volume = max(0.0, min(1.0, val))
|
||||
|
||||
def _on_midi_status(self, connected: bool, name: str):
|
||||
if connected:
|
||||
print(f"[MIDI] Connected: {name}")
|
||||
logger.info("Connected: %s", name)
|
||||
else:
|
||||
print(f"[MIDI] Not connected: {name}")
|
||||
logger.info("Not connected: %s", name)
|
||||
|
||||
def closeEvent(self, event):
|
||||
self.midi.stop()
|
||||
self.engine.shutdown()
|
||||
event.accept()
|
||||
|
||||
def _on_recording_toggled(self, checked):
|
||||
if checked:
|
||||
self.engine.start_recording()
|
||||
else:
|
||||
self.engine.stop_recording()
|
||||
|
||||
|
||||
def main():
|
||||
app = QApplication(sys.argv)
|
||||
@@ -207,14 +336,14 @@ def main():
|
||||
app.setOrganizationName("Getting To It")
|
||||
|
||||
palette = QPalette()
|
||||
palette.setColor(QPalette.Window, QColor("#0d0d0d"))
|
||||
palette.setColor(QPalette.WindowText, QColor("#cccccc"))
|
||||
palette.setColor(QPalette.Base, QColor("#111111"))
|
||||
palette.setColor(QPalette.AlternateBase, QColor("#1a1a1a"))
|
||||
palette.setColor(QPalette.Text, QColor("#cccccc"))
|
||||
palette.setColor(QPalette.Button, QColor("#2a2a2a"))
|
||||
palette.setColor(QPalette.ButtonText, QColor("#cccccc"))
|
||||
palette.setColor(QPalette.Highlight, QColor("#1a3a5a"))
|
||||
palette.setColor(QPalette.Window, QColor("#121418"))
|
||||
palette.setColor(QPalette.WindowText, QColor("#d0d4dc"))
|
||||
palette.setColor(QPalette.Base, QColor("#1a1c23"))
|
||||
palette.setColor(QPalette.AlternateBase, QColor("#22242c"))
|
||||
palette.setColor(QPalette.Text, QColor("#d0d4dc"))
|
||||
palette.setColor(QPalette.Button, QColor("#2a2c35"))
|
||||
palette.setColor(QPalette.ButtonText, QColor("#d0d4dc"))
|
||||
palette.setColor(QPalette.Highlight, QColor("#00b894"))
|
||||
palette.setColor(QPalette.HighlightedText, QColor("#ffffff"))
|
||||
app.setPalette(palette)
|
||||
|
||||
|
||||
+243
-31
@@ -1,10 +1,12 @@
|
||||
# midi_engine.py
|
||||
"""
|
||||
MIDI Engine for Radio Panel
|
||||
Numark DJ2Go controller support.
|
||||
MIDI Engine for GTI Radio Studio.
|
||||
Supports MIDI learn and configurable control mapping.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
@@ -13,34 +15,19 @@ from PySide6.QtCore import QObject, Signal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# MIDI Constants
|
||||
# ─────────────────────────────────────────────
|
||||
NOTE_ON = 0x90
|
||||
NOTE_OFF = 0x80
|
||||
CC = 0xB0
|
||||
PITCH_BEND = 0xE0
|
||||
|
||||
DECK1_PLAY = 0x3B
|
||||
DECK1_CUE = 0x33
|
||||
DECK1_JOG = 0x19
|
||||
|
||||
DECK2_PLAY = 0x42
|
||||
DECK2_CUE = 0x3C
|
||||
DECK2_JOG = 0x18
|
||||
|
||||
CART_NOTES = {
|
||||
0x44: 0,
|
||||
0x43: 1,
|
||||
0x46: 2,
|
||||
0x45: 3,
|
||||
}
|
||||
CONFIG_DIR = os.path.expanduser("~/.gti_radiostudio")
|
||||
CONFIG_FILE = os.path.join(CONFIG_DIR, "midi_map.json")
|
||||
|
||||
JOG_SCRUB_SECONDS = 5.0
|
||||
JOG_DEAD_ZONE = 5 # Ignore movements below this threshold
|
||||
JOG_DEAD_ZONE = 5
|
||||
|
||||
|
||||
def _decode_relative_jog(value: int) -> int:
|
||||
"""Returns movement amount, or 0 if within dead zone."""
|
||||
if 1 <= value <= 63:
|
||||
return value if value >= JOG_DEAD_ZONE else 0
|
||||
elif 65 <= value <= 127:
|
||||
@@ -49,12 +36,71 @@ def _decode_relative_jog(value: int) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
class MidiMapping:
|
||||
def __init__(self):
|
||||
self.bindings = {}
|
||||
|
||||
def add(self, action: str, target_id, midi_type: int, midi_channel: int,
|
||||
midi_note: int, inverse: bool = False, is_relative: bool = False):
|
||||
key = (midi_type, midi_channel, midi_note)
|
||||
self.bindings[key] = {
|
||||
'action': action,
|
||||
'target_id': target_id,
|
||||
'inverse': inverse,
|
||||
'is_relative': is_relative,
|
||||
}
|
||||
|
||||
def lookup(self, midi_type: int, midi_channel: int,
|
||||
midi_note: int) -> Optional[dict]:
|
||||
return self.bindings.get((midi_type, midi_channel, midi_note))
|
||||
|
||||
def save(self, filepath: str = CONFIG_FILE):
|
||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||
data = []
|
||||
for key, val in self.bindings.items():
|
||||
data.append({
|
||||
'midi_type': key[0],
|
||||
'midi_channel': key[1],
|
||||
'midi_note': key[2],
|
||||
'action': val['action'],
|
||||
'target_id': val['target_id'],
|
||||
'inverse': val['inverse'],
|
||||
'is_relative': val.get('is_relative', False),
|
||||
})
|
||||
with open(filepath, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
def load(self, filepath: str = CONFIG_FILE):
|
||||
if not os.path.exists(filepath):
|
||||
return
|
||||
with open(filepath) as f:
|
||||
data = json.load(f)
|
||||
for entry in data:
|
||||
key = (entry['midi_type'], entry['midi_channel'], entry['midi_note'])
|
||||
self.bindings[key] = {
|
||||
'action': entry['action'],
|
||||
'target_id': entry['target_id'],
|
||||
'inverse': entry.get('inverse', False),
|
||||
'is_relative': entry.get('is_relative', False),
|
||||
}
|
||||
|
||||
|
||||
class MidiEngine(QObject):
|
||||
deck_play_pause = Signal(str)
|
||||
deck_cue = Signal(str)
|
||||
deck_jog = Signal(str, float)
|
||||
cart_trigger = Signal(int)
|
||||
mixer_volume = Signal(int, float)
|
||||
mixer_mute = Signal(int, bool)
|
||||
mixer_solo = Signal(int, bool)
|
||||
mixer_pfl = Signal(int, bool)
|
||||
mixer_mic_on = Signal(int, bool)
|
||||
mixer_gain = Signal(int, float)
|
||||
master_control = Signal(str, float)
|
||||
master_volume = Signal(float)
|
||||
controller_connected = Signal(bool, str)
|
||||
mapping_learned = Signal(str, object, int, int, int, bool)
|
||||
raw_midi_event = Signal(str, int, int, int)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
@@ -62,6 +108,32 @@ class MidiEngine(QObject):
|
||||
self._port_name: str = ""
|
||||
self._running = False
|
||||
|
||||
self.mapping = MidiMapping()
|
||||
self.mapping.load()
|
||||
|
||||
self._learn_target = None
|
||||
self._learn_mode = False
|
||||
|
||||
self._last_cc_values = {}
|
||||
self._channel_volumes = {}
|
||||
self._master_knob_values = {}
|
||||
|
||||
@property
|
||||
def learn_mode(self) -> bool:
|
||||
return self._learn_mode
|
||||
|
||||
def set_learn_target(self, target: Optional[tuple]):
|
||||
self._learn_target = target
|
||||
|
||||
def start_learn(self, action: str, target_id):
|
||||
self._learn_mode = True
|
||||
self._learn_target = (action, target_id)
|
||||
logger.info(f"MIDI learn: waiting for {action} ({target_id})...")
|
||||
|
||||
def stop_learn(self):
|
||||
self._learn_mode = False
|
||||
self._learn_target = None
|
||||
|
||||
def start(self, port_index: Optional[int] = None) -> bool:
|
||||
try:
|
||||
self._midi_in = rtmidi.MidiIn()
|
||||
@@ -136,33 +208,173 @@ class MidiEngine(QObject):
|
||||
value = message[2] if len(message) > 2 else 0
|
||||
|
||||
status_type = status & 0xF0
|
||||
channel = status & 0x0F
|
||||
|
||||
type_names = {0x90: "note_on", 0x80: "note_off", 0xB0: "cc", 0xE0: "pitch", 0xD0: "aftertouch"}
|
||||
type_name = type_names.get(status_type, f"0x{status_type:02X}")
|
||||
self.raw_midi_event.emit(type_name, channel, data_byte, value)
|
||||
|
||||
if self._learn_mode and self._learn_target:
|
||||
action, target_id = self._learn_target
|
||||
mapping_type = status_type
|
||||
is_rel = (status_type == 0xB0 and (1 <= value <= 63 or 65 <= value <= 127))
|
||||
note = 0 if status_type == PITCH_BEND else data_byte
|
||||
self.mapping.add(action, target_id, mapping_type, channel, note, is_relative=is_rel)
|
||||
self.mapping.save()
|
||||
self.mapping_learned.emit(action, target_id, mapping_type, channel, note, value > 0)
|
||||
self.stop_learn()
|
||||
return
|
||||
|
||||
if status_type == NOTE_ON and value > 0:
|
||||
self._handle_note_on(data_byte)
|
||||
self._handle_learned_event(status_type, channel, data_byte, value)
|
||||
elif status_type == NOTE_OFF or (status_type == NOTE_ON and value == 0):
|
||||
pass
|
||||
elif status_type == CC:
|
||||
self._handle_cc(data_byte, value)
|
||||
self._handle_learned_event(status_type, channel, data_byte, value)
|
||||
elif status_type == PITCH_BEND:
|
||||
lsb = data_byte
|
||||
msb = message[2] if len(message) > 2 else 0
|
||||
pb_value = (msb << 7) | lsb
|
||||
self._handle_learned_event(status_type, channel, 0, pb_value)
|
||||
|
||||
def _handle_learned_event(self, midi_type: int, channel: int,
|
||||
note: int, value: int):
|
||||
binding = self.mapping.lookup(midi_type, channel, note)
|
||||
if binding is None:
|
||||
return
|
||||
|
||||
action = binding['action']
|
||||
target_id = binding['target_id']
|
||||
inv = binding.get('inverse', False)
|
||||
is_rel = binding.get('is_relative', False)
|
||||
|
||||
if action == 'deck_play_pause' and isinstance(target_id, str):
|
||||
self.deck_play_pause.emit(target_id)
|
||||
elif action == 'deck_cue' and isinstance(target_id, str):
|
||||
self.deck_cue.emit(target_id)
|
||||
elif action == 'cart_trigger':
|
||||
self.cart_trigger.emit(target_id)
|
||||
elif action == 'mixer_volume':
|
||||
if is_rel and 1 <= value <= 63:
|
||||
prev = self._channel_volumes.get(target_id, 1.0)
|
||||
new_vol = min(1.0, prev + value * 0.02)
|
||||
self._channel_volumes[target_id] = new_vol
|
||||
self.mixer_volume.emit(target_id, new_vol)
|
||||
elif is_rel and 65 <= value <= 127:
|
||||
prev = self._channel_volumes.get(target_id, 1.0)
|
||||
steps = value & 0x3F
|
||||
new_vol = max(0.0, prev - steps * 0.02)
|
||||
self._channel_volumes[target_id] = new_vol
|
||||
self.mixer_volume.emit(target_id, new_vol)
|
||||
else:
|
||||
if midi_type == PITCH_BEND:
|
||||
scaled = (16383 - value if inv else value) / 16383.0
|
||||
else:
|
||||
scaled = (127 - value if inv else value) / 127.0
|
||||
self._channel_volumes[target_id] = scaled
|
||||
self.mixer_volume.emit(target_id, scaled)
|
||||
elif action == 'mixer_mute':
|
||||
muted = (value < 64) if inv else (value > 63)
|
||||
self.mixer_mute.emit(target_id, muted)
|
||||
elif action == 'mixer_solo':
|
||||
on = (value > 63) if not inv else (value < 64)
|
||||
self.mixer_solo.emit(target_id, on)
|
||||
elif action == 'mixer_pfl':
|
||||
on = (value > 63) if not inv else (value < 64)
|
||||
self.mixer_pfl.emit(target_id, on)
|
||||
elif action == 'mixer_mic_on':
|
||||
on = (value > 63) if not inv else (value < 64)
|
||||
self.mixer_mic_on.emit(target_id, on)
|
||||
elif action == 'mixer_gain':
|
||||
if is_rel and 1 <= value <= 63:
|
||||
prev = self._channel_volumes.get(f"gain_{target_id}", 1.0)
|
||||
new_val = min(2.0, prev + value * 0.02)
|
||||
self._channel_volumes[f"gain_{target_id}"] = new_val
|
||||
self.mixer_gain.emit(target_id, new_val)
|
||||
elif is_rel and 65 <= value <= 127:
|
||||
prev = self._channel_volumes.get(f"gain_{target_id}", 1.0)
|
||||
steps = value & 0x3F
|
||||
new_val = max(0.0, prev - steps * 0.02)
|
||||
self._channel_volumes[f"gain_{target_id}"] = new_val
|
||||
self.mixer_gain.emit(target_id, new_val)
|
||||
else:
|
||||
if midi_type == PITCH_BEND:
|
||||
scaled = (16383 - value if inv else value) / 16383.0
|
||||
else:
|
||||
scaled = (127 - value if inv else value) / 127.0
|
||||
scaled = scaled * 2.0 # map to 0-2 range
|
||||
self.mixer_gain.emit(target_id, scaled)
|
||||
elif action == 'mixer_gain':
|
||||
if is_rel and 1 <= value <= 63:
|
||||
prev = self._channel_volumes.get(f"gain_{target_id}", 1.0)
|
||||
new_val = min(2.0, prev + value * 0.02)
|
||||
self._channel_volumes[f"gain_{target_id}"] = new_val
|
||||
self.mixer_gain.emit(target_id, new_val)
|
||||
elif is_rel and 65 <= value <= 127:
|
||||
prev = self._channel_volumes.get(f"gain_{target_id}", 1.0)
|
||||
steps = value & 0x3F
|
||||
new_val = max(0.0, prev - steps * 0.02)
|
||||
self._channel_volumes[f"gain_{target_id}"] = new_val
|
||||
self.mixer_gain.emit(target_id, new_val)
|
||||
else:
|
||||
if midi_type == PITCH_BEND:
|
||||
scaled = (16383 - value if inv else value) / 16383.0
|
||||
else:
|
||||
scaled = (127 - value if inv else value) / 127.0
|
||||
scaled = scaled * 2.0 # map to 0-2 range
|
||||
self.mixer_gain.emit(target_id, scaled)
|
||||
elif action == 'master_control':
|
||||
knob = str(target_id)
|
||||
if is_rel and 1 <= value <= 63:
|
||||
prev = self._master_knob_values.get(knob, 0.5)
|
||||
new_val = min(1.0, prev + value * 0.02)
|
||||
self._master_knob_values[knob] = new_val
|
||||
self.master_control.emit(knob, new_val)
|
||||
elif is_rel and 65 <= value <= 127:
|
||||
prev = self._master_knob_values.get(knob, 0.5)
|
||||
steps = value & 0x3F
|
||||
new_val = max(0.0, prev - steps * 0.02)
|
||||
self._master_knob_values[knob] = new_val
|
||||
self.master_control.emit(knob, new_val)
|
||||
else:
|
||||
if midi_type == PITCH_BEND:
|
||||
scaled = (16383 - value if inv else value) / 16383.0
|
||||
else:
|
||||
scaled = (127 - value if inv else value) / 127.0
|
||||
self._master_knob_values[knob] = scaled
|
||||
self.master_control.emit(knob, scaled)
|
||||
elif action == 'master_volume':
|
||||
if midi_type == PITCH_BEND:
|
||||
scaled = (16383 - value if inv else value) / 16383.0
|
||||
else:
|
||||
scaled = (127 - value if inv else value) / 127.0
|
||||
self.master_volume.emit(scaled)
|
||||
|
||||
def _handle_note_on(self, note: int):
|
||||
if note == DECK1_PLAY:
|
||||
# Hardcoded Numark DJ2Go mapping (legacy)
|
||||
NUMARK_CART = {0x44: 0}
|
||||
|
||||
if note == 0x3B:
|
||||
self.deck_play_pause.emit('deck1')
|
||||
elif note == DECK1_CUE:
|
||||
elif note == 0x33:
|
||||
self.deck_cue.emit('deck1')
|
||||
elif note == DECK2_PLAY:
|
||||
elif note == 0x42:
|
||||
self.deck_play_pause.emit('deck2')
|
||||
elif note == DECK2_CUE:
|
||||
elif note == 0x3C:
|
||||
self.deck_cue.emit('deck2')
|
||||
elif note in CART_NOTES:
|
||||
self.cart_trigger.emit(CART_NOTES[note])
|
||||
elif note in NUMARK_CART:
|
||||
self.cart_trigger.emit(NUMARK_CART[note])
|
||||
|
||||
def _handle_cc(self, cc_num: int, value: int):
|
||||
movement = _decode_relative_jog(value)
|
||||
if movement == 0:
|
||||
return
|
||||
|
||||
# Scale movement to seconds
|
||||
delta = (movement / 10.0) * JOG_SCRUB_SECONDS
|
||||
|
||||
if cc_num == DECK1_JOG:
|
||||
if cc_num == 0x19:
|
||||
self.deck_jog.emit('deck1', delta)
|
||||
elif cc_num == DECK2_JOG:
|
||||
self.deck_jog.emit('deck2', delta)
|
||||
elif cc_num == 0x18:
|
||||
self.deck_jog.emit('deck2', delta)
|
||||
|
||||
+630
@@ -0,0 +1,630 @@
|
||||
# mixer_widget.py
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QSlider,
|
||||
QPushButton, QFrame, QScrollArea, QSizePolicy,
|
||||
QInputDialog, QDial
|
||||
)
|
||||
from PySide6.QtCore import Qt, QTimer, QSize
|
||||
from PySide6.QtGui import QFont, QPainter, QColor, QLinearGradient, QPen
|
||||
|
||||
from audio_engine import NUM_MIXER_CHANNELS
|
||||
from icons import icon_mute, icon_mic
|
||||
import math
|
||||
|
||||
|
||||
class VUMeter(QWidget):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._value = 0.0
|
||||
self.setMinimumWidth(28)
|
||||
self.setMinimumHeight(80)
|
||||
|
||||
def set_value(self, value: float):
|
||||
self._value = max(0.0, min(1.0, value))
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.Antialiasing)
|
||||
|
||||
rect = self.rect().adjusted(18, 3, -3, -3)
|
||||
if rect.width() < 1 or rect.height() < 1:
|
||||
painter.end()
|
||||
return
|
||||
|
||||
painter.fillRect(rect, QColor("#080808"))
|
||||
|
||||
db_markings = [(-40,), (-30,), (-20,), (-12,), (-6,), (0,)]
|
||||
for (db,) in db_markings:
|
||||
pct = (db + 46) / 46.0 if db <= 0 else 1.0
|
||||
pct = max(0.0, min(1.0, pct))
|
||||
y = rect.bottom() - int(rect.height() * pct)
|
||||
if db == -6:
|
||||
painter.setPen(QPen(QColor("#00ff88"), 2))
|
||||
painter.drawLine(rect.right() - 3, y, rect.right(), y)
|
||||
painter.setFont(QFont("Segoe UI", 9, QFont.Bold))
|
||||
painter.setPen(QPen(QColor("#ffffff"), 1))
|
||||
else:
|
||||
painter.setPen(QPen(QColor("#2a2a2a"), 1))
|
||||
painter.drawLine(rect.right() - 5, y, rect.right(), y)
|
||||
painter.setPen(QPen(QColor("#cccccc"), 1))
|
||||
painter.setFont(QFont("Segoe UI", 8))
|
||||
painter.drawText(rect.left() - 16, y + 3, f"{db}")
|
||||
|
||||
painter.setPen(QPen(QColor("#1a1a1a"), 1))
|
||||
painter.drawRect(rect)
|
||||
|
||||
val = self._value
|
||||
if val < 1e-10:
|
||||
painter.end()
|
||||
return
|
||||
|
||||
gradient = QLinearGradient(0, rect.bottom(), 0, rect.top())
|
||||
gradient.setColorAt(0.0, QColor("#003300"))
|
||||
gradient.setColorAt(0.5, QColor("#00cc00"))
|
||||
gradient.setColorAt(0.75, QColor("#cccc00"))
|
||||
gradient.setColorAt(0.9, QColor("#ff6600"))
|
||||
gradient.setColorAt(1.0, QColor("#ff0000"))
|
||||
|
||||
db = -46 * (1 - val) if val < 1.0 else 3
|
||||
fill_pct = (db + 46) / 49.0 if db <= 0 else 1.0
|
||||
fill_pct = max(0.0, min(1.0, fill_pct))
|
||||
fill_pct = math.sqrt(fill_pct)
|
||||
|
||||
fill_h = int(rect.height() * fill_pct)
|
||||
painter.fillRect(rect.x(), rect.bottom() - fill_h,
|
||||
rect.width(), fill_h, gradient)
|
||||
|
||||
for (db,) in db_markings:
|
||||
pct = (db + 46) / 46.0 if db <= 0 else 1.0
|
||||
pct = max(0.0, min(1.0, pct))
|
||||
y = rect.bottom() - int(rect.height() * pct)
|
||||
painter.setPen(QPen(QColor("#000000"), 1))
|
||||
painter.drawLine(rect.x(), y, rect.right(), y)
|
||||
|
||||
painter.end()
|
||||
|
||||
|
||||
class ChannelStrip(QFrame):
|
||||
def __init__(self, engine, channel_index, parent=None):
|
||||
super().__init__(parent)
|
||||
self.engine = engine
|
||||
self.channel_index = channel_index
|
||||
self._channel = engine.get_channel(channel_index)
|
||||
|
||||
self.setFixedWidth(100)
|
||||
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
|
||||
self.setStyleSheet("""
|
||||
ChannelStrip {
|
||||
background-color: #181a1e;
|
||||
border: 1px solid #282a30;
|
||||
border-radius: 6px;
|
||||
}
|
||||
""")
|
||||
|
||||
self._build_ui()
|
||||
self._connect_signals()
|
||||
|
||||
self.timer = QTimer(self)
|
||||
self.timer.setInterval(30)
|
||||
self.timer.timeout.connect(self._refresh)
|
||||
self.timer.start()
|
||||
|
||||
def _build_ui(self):
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(4, 6, 4, 6)
|
||||
root.setSpacing(4)
|
||||
|
||||
self.name_btn = QPushButton(self._channel.name)
|
||||
self.name_btn.setFont(QFont("Segoe UI", 9, QFont.Bold))
|
||||
self.name_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: transparent;
|
||||
color: #999aaa;
|
||||
border: none;
|
||||
padding: 2px;
|
||||
font-size: 9pt;
|
||||
}
|
||||
QPushButton:hover {
|
||||
color: #ffffff;
|
||||
background-color: #22252c;
|
||||
border-radius: 3px;
|
||||
}
|
||||
""")
|
||||
root.addWidget(self.name_btn)
|
||||
|
||||
self.gain_knob = QDial()
|
||||
self.gain_knob.setRange(0, 100)
|
||||
self.gain_knob.setValue(50)
|
||||
self.gain_knob.setFixedSize(40, 40)
|
||||
self.gain_knob.setNotchesVisible(True)
|
||||
self.gain_knob.setWrapping(False)
|
||||
self.gain_knob.setToolTip("Pre-fader gain trim")
|
||||
self.gain_knob.setStyleSheet("""
|
||||
QDial {
|
||||
background-color: transparent;
|
||||
color: #8890a0;
|
||||
}
|
||||
""")
|
||||
root.addWidget(self.gain_knob, alignment=Qt.AlignCenter)
|
||||
|
||||
gain_label = QLabel("GAIN")
|
||||
gain_label.setFont(QFont("Segoe UI", 7, QFont.Bold))
|
||||
gain_label.setAlignment(Qt.AlignCenter)
|
||||
gain_label.setStyleSheet("color: #556670; background: transparent; border: none;")
|
||||
root.addWidget(gain_label)
|
||||
|
||||
meter_row = QHBoxLayout()
|
||||
meter_row.setSpacing(2)
|
||||
|
||||
self.vu_meter = VUMeter()
|
||||
meter_row.addWidget(self.vu_meter, stretch=1)
|
||||
|
||||
self.volume_slider = QSlider(Qt.Vertical)
|
||||
self.volume_slider.setRange(0, 100)
|
||||
self.volume_slider.setValue(int(math.sqrt(self._channel.volume) * 100))
|
||||
self.volume_slider.setStyleSheet("""
|
||||
QSlider::groove:vertical {
|
||||
background: #22242a;
|
||||
width: 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
QSlider::handle:vertical {
|
||||
background: #8890a0;
|
||||
height: 12px;
|
||||
width: 14px;
|
||||
margin: -4px -5px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
QSlider::handle:vertical:hover { background: #b0b8c8; }
|
||||
QSlider::add-page:vertical {
|
||||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||||
stop:0 #00cc66, stop:1 #008844);
|
||||
border-radius: 2px;
|
||||
}
|
||||
QSlider::sub-page:vertical {
|
||||
background: transparent;
|
||||
border-radius: 2px;
|
||||
}
|
||||
""")
|
||||
meter_row.addWidget(self.volume_slider, stretch=1)
|
||||
|
||||
root.addLayout(meter_row, stretch=1)
|
||||
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.setSpacing(3)
|
||||
|
||||
is_mic = self.channel_index < 2
|
||||
|
||||
self.mute_btn = QPushButton()
|
||||
self.mute_btn.setFixedSize(28, 24)
|
||||
self.mute_btn.setIconSize(QSize(16, 16))
|
||||
self.mute_btn.setIcon(icon_mute())
|
||||
self.mute_btn.setCheckable(True)
|
||||
self.mute_btn.setToolTip("Mute")
|
||||
self.mute_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #242026;
|
||||
border: 1px solid #3a303a;
|
||||
border-radius: 3px;
|
||||
}
|
||||
QPushButton:checked {
|
||||
background-color: #4a1a1a;
|
||||
border: 1px solid #ff4444;
|
||||
}
|
||||
QPushButton:hover { background-color: #343036; }
|
||||
""")
|
||||
self.mute_btn.setVisible(not is_mic)
|
||||
btn_row.addWidget(self.mute_btn)
|
||||
|
||||
# Mic ON/OFF button for channels 0 and 1
|
||||
self.mic_btn = QPushButton()
|
||||
self.mic_btn.setFixedSize(28, 24)
|
||||
self.mic_btn.setIconSize(QSize(16, 16))
|
||||
self.mic_btn.setIcon(icon_mic("#888"))
|
||||
self.mic_btn.setCheckable(True)
|
||||
self.mic_btn.setToolTip("Mic ON – audio goes to main")
|
||||
self.mic_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #202426;
|
||||
border: 1px solid #303a3a;
|
||||
border-radius: 3px;
|
||||
}
|
||||
QPushButton:checked {
|
||||
background-color: #1a3a1a;
|
||||
border: 1px solid #00cc66;
|
||||
}
|
||||
QPushButton:hover { background-color: #303436; }
|
||||
""")
|
||||
self.mic_btn.setVisible(is_mic)
|
||||
btn_row.addWidget(self.mic_btn)
|
||||
|
||||
self.pfl_btn = QPushButton("PFL")
|
||||
self.pfl_btn.setFixedSize(28, 24)
|
||||
self.pfl_btn.setFont(QFont("Segoe UI", 8, QFont.Bold))
|
||||
self.pfl_btn.setCheckable(True)
|
||||
self.pfl_btn.setToolTip("Pre-Fader Listen")
|
||||
self.pfl_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #202426;
|
||||
border: 1px solid #303a3a;
|
||||
border-radius: 3px;
|
||||
color: #668;
|
||||
}
|
||||
QPushButton:checked {
|
||||
background-color: #1a3a4a;
|
||||
border: 1px solid #00aaff;
|
||||
color: #00ccff;
|
||||
}
|
||||
QPushButton:hover { background-color: #303436; }
|
||||
""")
|
||||
btn_row.addWidget(self.pfl_btn)
|
||||
|
||||
root.addLayout(btn_row)
|
||||
|
||||
def _connect_signals(self):
|
||||
self.name_btn.clicked.connect(self._rename)
|
||||
self.gain_knob.valueChanged.connect(self._on_gain_changed)
|
||||
self.volume_slider.valueChanged.connect(self._on_volume_changed)
|
||||
self.mute_btn.toggled.connect(self._on_mute_toggled)
|
||||
self.mic_btn.toggled.connect(self._on_mic_toggled)
|
||||
self.pfl_btn.toggled.connect(self._on_pfl_toggled)
|
||||
|
||||
def _rename(self):
|
||||
name, ok = QInputDialog.getText(
|
||||
self, "Rename Channel", "Channel name:",
|
||||
text=self._channel.name
|
||||
)
|
||||
if ok and name.strip():
|
||||
self._channel.name = name.strip()
|
||||
self.name_btn.setText(self._channel.name)
|
||||
|
||||
def _on_volume_changed(self, value: int):
|
||||
vol = (value / 100.0) ** 2
|
||||
self.engine.set_channel_volume(self.channel_index, vol)
|
||||
|
||||
def _on_gain_changed(self, value: int):
|
||||
gain = value / 50.0
|
||||
self.engine.set_channel_gain(self.channel_index, gain)
|
||||
|
||||
def _on_mute_toggled(self, checked: bool):
|
||||
self.engine.set_channel_mute(self.channel_index, checked)
|
||||
|
||||
def _on_pfl_toggled(self, checked: bool):
|
||||
self.engine.set_channel_pfl(self.channel_index, checked)
|
||||
|
||||
def _on_mic_toggled(self, checked: bool):
|
||||
self.engine.set_channel_mic_on(self.channel_index, checked)
|
||||
color = "#00cc66" if checked else "#888"
|
||||
self.mic_btn.setIcon(icon_mic(color))
|
||||
|
||||
def _refresh(self):
|
||||
self.vu_meter.set_value(self._channel.vu_peak)
|
||||
self.volume_slider.setValue(int(math.sqrt(self._channel.volume) * 100))
|
||||
self.gain_knob.setValue(int(self._channel.gain * 50))
|
||||
if self.channel_index < 2:
|
||||
color = "#00cc66" if self._channel.mic_on else "#888"
|
||||
self.mic_btn.setIcon(icon_mic(color))
|
||||
|
||||
|
||||
class MasterPreSection(QFrame):
|
||||
def __init__(self, engine, parent=None):
|
||||
super().__init__(parent)
|
||||
self.engine = engine
|
||||
self._master = engine.mixer['master']
|
||||
|
||||
self.setFixedWidth(80)
|
||||
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
|
||||
self.setStyleSheet("""
|
||||
MasterPreSection {
|
||||
background-color: #101216;
|
||||
border: 1px solid #1e2026;
|
||||
border-radius: 6px;
|
||||
}
|
||||
""")
|
||||
|
||||
self._build_ui()
|
||||
|
||||
self.timer = QTimer(self)
|
||||
self.timer.setInterval(30)
|
||||
self.timer.timeout.connect(self._refresh)
|
||||
self.timer.start()
|
||||
|
||||
def _build_ui(self):
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(4, 6, 4, 6)
|
||||
root.setSpacing(4)
|
||||
|
||||
header = QLabel("MASTER")
|
||||
header.setFont(QFont("Segoe UI", 10, QFont.Bold))
|
||||
header.setAlignment(Qt.AlignCenter)
|
||||
header.setStyleSheet("color: #888; background: transparent; border: none;")
|
||||
root.addWidget(header)
|
||||
|
||||
meter_row = QHBoxLayout()
|
||||
meter_row.setSpacing(2)
|
||||
|
||||
self.vu_meter = VUMeter()
|
||||
meter_row.addWidget(self.vu_meter, stretch=1)
|
||||
|
||||
self.volume_slider = QSlider(Qt.Vertical)
|
||||
self.volume_slider.setRange(0, 100)
|
||||
self.volume_slider.setValue(int(math.sqrt(self._master.volume) * 100))
|
||||
self.volume_slider.setStyleSheet("""
|
||||
QSlider::groove:vertical { background: #22242a; width: 4px; border-radius: 2px; }
|
||||
QSlider::handle:vertical { background: #8890a0; height: 12px; width: 14px; margin: -4px -5px; border-radius: 2px; }
|
||||
QSlider::handle:vertical:hover { background: #b0b8c8; }
|
||||
QSlider::add-page:vertical {
|
||||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #00cc66, stop:1 #008844);
|
||||
border-radius: 2px;
|
||||
}
|
||||
QSlider::sub-page:vertical { background: transparent; border-radius: 2px; }
|
||||
""")
|
||||
meter_row.addWidget(self.volume_slider, stretch=1)
|
||||
|
||||
root.addLayout(meter_row, stretch=1)
|
||||
|
||||
self.volume_slider.valueChanged.connect(self._on_volume_changed)
|
||||
|
||||
# PFL / Headphone blend
|
||||
self.blend_label = QLabel("PFL⇄HP")
|
||||
self.blend_label.setFont(QFont("Segoe UI", 7, QFont.Bold))
|
||||
self.blend_label.setAlignment(Qt.AlignCenter)
|
||||
self.blend_label.setStyleSheet("color: #556670; background: transparent; border: none;")
|
||||
root.addWidget(self.blend_label)
|
||||
|
||||
self.pfl_hp_knob = QDial()
|
||||
self.pfl_hp_knob.setRange(0, 100)
|
||||
self.pfl_hp_knob.setValue(int(self._master.pfl_hp_blend * 100))
|
||||
self.pfl_hp_knob.setFixedSize(36, 36)
|
||||
self.pfl_hp_knob.setNotchesVisible(True)
|
||||
self.pfl_hp_knob.setWrapping(False)
|
||||
self.pfl_hp_knob.setToolTip("Left: PFL only | Centre: equal blend | Right: HP only")
|
||||
self.pfl_hp_knob.setStyleSheet("""
|
||||
QDial {
|
||||
background-color: transparent;
|
||||
color: #8890a0;
|
||||
}
|
||||
""")
|
||||
root.addWidget(self.pfl_hp_knob, alignment=Qt.AlignCenter)
|
||||
|
||||
self.pfl_hp_knob.valueChanged.connect(self._on_blend_changed)
|
||||
|
||||
def _on_volume_changed(self, value: int):
|
||||
vol = (value / 100.0) ** 2
|
||||
self._master.volume = vol
|
||||
|
||||
def _on_blend_changed(self, value: int):
|
||||
self._master.pfl_hp_blend = value / 100.0
|
||||
|
||||
def _refresh(self):
|
||||
self.vu_meter.set_value(self._master.vu_peak)
|
||||
self.volume_slider.setValue(int(math.sqrt(self._master.volume) * 100))
|
||||
self.pfl_hp_knob.setValue(int(self._master.pfl_hp_blend * 100))
|
||||
|
||||
|
||||
class LUFSExpandedSection(QFrame):
|
||||
def __init__(self, engine, parent=None):
|
||||
super().__init__(parent)
|
||||
self.engine = engine
|
||||
self._master = engine.mixer['master']
|
||||
|
||||
self.setMinimumWidth(160)
|
||||
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
self.setStyleSheet("""
|
||||
LUFSExpandedSection {
|
||||
background-color: #181a1e;
|
||||
border: 1px solid #282a30;
|
||||
border-radius: 6px;
|
||||
}
|
||||
""")
|
||||
|
||||
self._build_ui()
|
||||
|
||||
self.timer = QTimer(self)
|
||||
self.timer.setInterval(100)
|
||||
self.timer.timeout.connect(self._refresh)
|
||||
self.timer.start()
|
||||
|
||||
def _build_ui(self):
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(8, 12, 8, 12)
|
||||
root.setSpacing(4)
|
||||
|
||||
header = QLabel("LUFS")
|
||||
header.setFont(QFont("Segoe UI", 10, QFont.Bold))
|
||||
header.setAlignment(Qt.AlignCenter)
|
||||
header.setStyleSheet("color: #556670; background: transparent; border: none;")
|
||||
root.addWidget(header)
|
||||
|
||||
self.lufs_short = QLabel("-70.0")
|
||||
self.lufs_short.setFont(QFont("Segoe UI", 28, QFont.Bold))
|
||||
self.lufs_short.setAlignment(Qt.AlignCenter)
|
||||
self.lufs_short.setStyleSheet("color: #00cc88; background: transparent; border: none;")
|
||||
root.addWidget(self.lufs_short, stretch=1)
|
||||
|
||||
short_hint = QLabel("4 s")
|
||||
short_hint.setFont(QFont("Segoe UI", 7))
|
||||
short_hint.setAlignment(Qt.AlignCenter)
|
||||
short_hint.setStyleSheet("color: #445; background: transparent; border: none;")
|
||||
root.addWidget(short_hint)
|
||||
|
||||
self.lufs_long = QLabel("-70.0")
|
||||
self.lufs_long.setFont(QFont("Segoe UI", 18, QFont.Bold))
|
||||
self.lufs_long.setAlignment(Qt.AlignCenter)
|
||||
self.lufs_long.setStyleSheet("color: #5588aa; background: transparent; border: none;")
|
||||
root.addWidget(self.lufs_long)
|
||||
|
||||
long_hint = QLabel("10 min")
|
||||
long_hint.setFont(QFont("Segoe UI", 7))
|
||||
long_hint.setAlignment(Qt.AlignCenter)
|
||||
long_hint.setStyleSheet("color: #445; background: transparent; border: none;")
|
||||
root.addWidget(long_hint)
|
||||
|
||||
self.pfl_indicator = QLabel("PFL\n\u2014")
|
||||
self.pfl_indicator.setFont(QFont("Segoe UI", 8))
|
||||
self.pfl_indicator.setAlignment(Qt.AlignCenter)
|
||||
self.pfl_indicator.setStyleSheet("color: #556; background: transparent; border: none;")
|
||||
root.addWidget(self.pfl_indicator)
|
||||
|
||||
def _refresh(self):
|
||||
self.lufs_short.setText(f"{self._master.lufs_short:.1f}")
|
||||
self.lufs_long.setText(f"{self._master.lufs_long:.1f}")
|
||||
active_pfl = [ch for ch in self.engine.mixer['channels'] if ch.pfl]
|
||||
if active_pfl:
|
||||
names = ", ".join(ch.name[:3] for ch in active_pfl)
|
||||
self.pfl_indicator.setText(f"PFL\n{names}")
|
||||
self.pfl_indicator.setStyleSheet("color: #00aaff; background: transparent; border: none;")
|
||||
else:
|
||||
self.pfl_indicator.setText("PFL\n\u2014")
|
||||
self.pfl_indicator.setStyleSheet("color: #556; background: transparent; border: none;")
|
||||
|
||||
def apply_theme(self, theme: dict):
|
||||
pass
|
||||
|
||||
|
||||
class MicTimerWidget(QFrame):
|
||||
def __init__(self, engine, parent=None):
|
||||
super().__init__(parent)
|
||||
self.engine = engine
|
||||
|
||||
self.setMinimumWidth(120)
|
||||
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
self.setStyleSheet("""
|
||||
MicTimerWidget {
|
||||
background-color: #181a1e;
|
||||
border: 1px solid #282a30;
|
||||
border-radius: 6px;
|
||||
}
|
||||
""")
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(8, 12, 8, 12)
|
||||
root.setSpacing(4)
|
||||
|
||||
header = QLabel("MIC ON TIME")
|
||||
header.setFont(QFont("Segoe UI", 9, QFont.Bold))
|
||||
header.setAlignment(Qt.AlignCenter)
|
||||
header.setStyleSheet("color: #556670; background: transparent; border: none;")
|
||||
root.addWidget(header)
|
||||
|
||||
self.duration_label = QLabel("00:00")
|
||||
self.duration_label.setFont(QFont("Courier New", 28, QFont.Bold))
|
||||
self.duration_label.setAlignment(Qt.AlignCenter)
|
||||
self.duration_label.setStyleSheet("color: #00cc66; background: transparent; border: none;")
|
||||
root.addWidget(self.duration_label, stretch=1)
|
||||
|
||||
self.status_label = QLabel("MIC OFF")
|
||||
self.status_label.setFont(QFont("Segoe UI", 10, QFont.Bold))
|
||||
self.status_label.setAlignment(Qt.AlignCenter)
|
||||
self.status_label.setStyleSheet("color: #444; background: transparent; border: none;")
|
||||
root.addWidget(self.status_label)
|
||||
|
||||
self.timer = QTimer(self)
|
||||
self.timer.setInterval(100)
|
||||
self.timer.timeout.connect(self._refresh)
|
||||
self.timer.start()
|
||||
|
||||
def _refresh(self):
|
||||
dur = self.engine.get_mic_duration()
|
||||
channels = self.engine.mixer['channels']
|
||||
any_on = any(ch.mic_on for ch in channels[:2])
|
||||
|
||||
if any_on:
|
||||
mins = int(dur // 60)
|
||||
secs = int(dur % 60)
|
||||
self.duration_label.setText(f"{mins:02d}:{secs:02d}")
|
||||
self.duration_label.setStyleSheet("color: #00ff41; background: transparent; border: none;")
|
||||
self.status_label.setText("MIC LIVE")
|
||||
self.status_label.setStyleSheet("color: #ff4444; font-weight: bold; background: transparent; border: none;")
|
||||
else:
|
||||
self.duration_label.setText("00:00")
|
||||
self.duration_label.setStyleSheet("color: #333; background: transparent; border: none;")
|
||||
self.status_label.setText("MIC OFF")
|
||||
self.status_label.setStyleSheet("color: #444; background: transparent; border: none;")
|
||||
|
||||
def apply_theme(self, theme: dict):
|
||||
pass
|
||||
|
||||
|
||||
class MixerWidget(QFrame):
|
||||
def __init__(self, engine, parent=None):
|
||||
super().__init__(parent)
|
||||
self.engine = engine
|
||||
self.setStyleSheet("""
|
||||
MixerWidget {
|
||||
background-color: #121318;
|
||||
border: 1px solid #282a30;
|
||||
border-radius: 8px;
|
||||
}
|
||||
""")
|
||||
|
||||
self._build_ui()
|
||||
|
||||
def _build_ui(self):
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(6, 4, 6, 6)
|
||||
root.setSpacing(4)
|
||||
|
||||
header = QLabel("MIXER")
|
||||
header.setFont(QFont("Segoe UI", 11, QFont.Bold))
|
||||
header.setStyleSheet("color: #556670; background: transparent; border: none; "
|
||||
"letter-spacing: 2px;")
|
||||
root.addWidget(header)
|
||||
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||||
scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
scroll.setStyleSheet("""
|
||||
QScrollArea {
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
QScrollBar:horizontal {
|
||||
background: #1a1c22;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
QScrollBar::handle:horizontal {
|
||||
background: #3a3c44;
|
||||
border-radius: 3px;
|
||||
}
|
||||
QScrollBar::handle:horizontal:hover { background: #5a5c64; }
|
||||
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {
|
||||
width: 0px;
|
||||
}
|
||||
""")
|
||||
|
||||
scroll_content = QWidget()
|
||||
scroll_content.setStyleSheet("background: transparent;")
|
||||
channels_layout = QHBoxLayout(scroll_content)
|
||||
channels_layout.setContentsMargins(0, 0, 0, 0)
|
||||
channels_layout.setSpacing(4)
|
||||
|
||||
self._strips = []
|
||||
for i in range(NUM_MIXER_CHANNELS):
|
||||
strip = ChannelStrip(self.engine, i)
|
||||
self._strips.append(strip)
|
||||
channels_layout.addWidget(strip)
|
||||
|
||||
self.master_pre = MasterPreSection(self.engine)
|
||||
channels_layout.addWidget(self.master_pre)
|
||||
|
||||
scroll.setWidget(scroll_content)
|
||||
root.addWidget(scroll)
|
||||
|
||||
def apply_theme(self, theme: dict):
|
||||
for strip in self._strips:
|
||||
strip.apply_theme(theme)
|
||||
self.master_pre.apply_theme(theme)
|
||||
|
||||
def refresh_strips(self):
|
||||
for strip in self._strips:
|
||||
ch = strip._channel
|
||||
strip.name_btn.setText(ch.name)
|
||||
strip.volume_slider.setValue(int(ch.volume * 100))
|
||||
strip.gain_knob.setValue(int(ch.gain * 50))
|
||||
strip.mute_btn.setChecked(ch.muted)
|
||||
strip.pfl_btn.setChecked(ch.pfl)
|
||||
+223
-50
@@ -1,24 +1,20 @@
|
||||
# playlist_widget.py
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QFileDialog, QFrame, QSizePolicy,
|
||||
QListWidget, QListWidgetItem, QAbstractItemView,
|
||||
QMenu, QSplitter
|
||||
QMenu, QSplitter, QTextEdit, QLineEdit
|
||||
)
|
||||
from PySide6.QtCore import Qt, QTimer, QSize, QSize
|
||||
from PySide6.QtCore import Qt, QTimer, QSize, Signal
|
||||
from PySide6.QtGui import QFont, QColor, QAction
|
||||
import json
|
||||
import os
|
||||
|
||||
from icons import icon_save, icon_load
|
||||
|
||||
from icons import icon_save, icon_load
|
||||
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# PLAYLIST ITEM
|
||||
# ─────────────────────────────────────────
|
||||
|
||||
class PlaylistItem(QListWidgetItem):
|
||||
def __init__(self, filepath: str):
|
||||
@@ -31,10 +27,6 @@ class PlaylistItem(QListWidgetItem):
|
||||
self.setToolTip(filepath)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# HOUR DISPLAY + CLOCK
|
||||
# ─────────────────────────────────────────
|
||||
|
||||
class HourDisplay(QFrame):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
@@ -80,10 +72,6 @@ class HourDisplay(QFrame):
|
||||
self.hour_label.setText(f"HOUR {now.strftime('%H:00')}")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# DECK SELECTOR
|
||||
# ─────────────────────────────────────────
|
||||
|
||||
class DeckSelector(QFrame):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
@@ -154,6 +142,11 @@ class DeckSelector(QFrame):
|
||||
return self._selected
|
||||
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# SHOW LOG PANEL
|
||||
# ─────────────────────────────────────────
|
||||
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# BASE PLAYLIST PANEL
|
||||
# ─────────────────────────────────────────
|
||||
@@ -277,8 +270,131 @@ class BasePlaylistPanel(QWidget):
|
||||
paths.append(item.filepath)
|
||||
return paths
|
||||
|
||||
def clear_list(self, lst: QListWidget):
|
||||
lst.clear()
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# PREVIEW BAR
|
||||
# ─────────────────────────────────────────
|
||||
|
||||
class PreviewBar(QFrame):
|
||||
def __init__(self, engine, parent=None):
|
||||
super().__init__(parent)
|
||||
self.engine = engine
|
||||
self.setFixedHeight(40)
|
||||
self.setStyleSheet("""
|
||||
QFrame {
|
||||
background-color: #0a0a12;
|
||||
border: 1px solid #222;
|
||||
border-radius: 3px;
|
||||
}
|
||||
""")
|
||||
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(6, 2, 6, 2)
|
||||
layout.setSpacing(6)
|
||||
|
||||
self.track_label = QLabel("NO TRACK")
|
||||
self.track_label.setFont(QFont("Courier New", 9, QFont.Bold))
|
||||
self.track_label.setStyleSheet("color: #4488ff; background: transparent; border: none;")
|
||||
self.track_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||||
layout.addWidget(self.track_label, stretch=1)
|
||||
|
||||
self.time_label = QLabel("--:--")
|
||||
self.time_label.setFont(QFont("Courier New", 12, QFont.Bold))
|
||||
self.time_label.setStyleSheet("color: #4488ff; background: transparent; border: none;")
|
||||
layout.addWidget(self.time_label)
|
||||
|
||||
self.btn_play = QPushButton("\u25B6")
|
||||
self.btn_play.setFixedSize(30, 26)
|
||||
self.btn_play.setFont(QFont("Arial", 10, QFont.Bold))
|
||||
self.btn_play.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #1a3a5a; color: #4488ff;
|
||||
border: 1px solid #4488ff; border-radius: 3px;
|
||||
}
|
||||
QPushButton:hover { background-color: #2a4a6a; }
|
||||
QPushButton:checked { background-color: #1a5a3a; color: #00ff41; border-color: #00ff41; }
|
||||
""")
|
||||
self.btn_play.setCheckable(True)
|
||||
layout.addWidget(self.btn_play)
|
||||
|
||||
self.btn_forward = QPushButton("+10s")
|
||||
self.btn_forward.setFixedSize(40, 26)
|
||||
self.btn_forward.setFont(QFont("Arial", 8, QFont.Bold))
|
||||
self.btn_forward.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #2a1a0a; color: #aa6622;
|
||||
border: 1px solid #553311; border-radius: 3px;
|
||||
}
|
||||
QPushButton:hover { background-color: #3a2a1a; }
|
||||
""")
|
||||
layout.addWidget(self.btn_forward)
|
||||
|
||||
self.btn_play.clicked.connect(self._toggle_preview)
|
||||
self.btn_forward.clicked.connect(self._skip_forward)
|
||||
|
||||
self.timer = QTimer(self)
|
||||
self.timer.setInterval(100)
|
||||
self.timer.timeout.connect(self._refresh)
|
||||
self._playing = False
|
||||
self._pending_play = False
|
||||
|
||||
def set_playlist(self, playlist):
|
||||
self._playlist = playlist
|
||||
|
||||
def _selected_item(self):
|
||||
item = self._playlist.currentItem()
|
||||
if isinstance(item, PlaylistItem):
|
||||
return item
|
||||
return None
|
||||
|
||||
def _toggle_preview(self):
|
||||
item = self._selected_item()
|
||||
if item is None:
|
||||
self.btn_play.setChecked(False)
|
||||
return
|
||||
|
||||
deck = self.engine.decks['preview']
|
||||
if self._playing:
|
||||
deck.stop()
|
||||
self._playing = False
|
||||
self._pending_play = False
|
||||
self.btn_play.setChecked(False)
|
||||
self.track_label.setText("NO TRACK")
|
||||
self.time_label.setText("--:--")
|
||||
else:
|
||||
self.engine.load_track('preview', item.filepath)
|
||||
self.track_label.setText(item.text()[:30])
|
||||
self.time_label.setText("00:00")
|
||||
self._pending_play = True
|
||||
|
||||
def _skip_forward(self):
|
||||
if not self._playing:
|
||||
return
|
||||
self.engine.decks['preview'].skip_seconds(10)
|
||||
|
||||
def _refresh(self):
|
||||
deck = self.engine.decks['preview']
|
||||
with deck.lock:
|
||||
playing = deck.playing
|
||||
track = deck.track
|
||||
pos = deck.position
|
||||
|
||||
if self._pending_play and track is not None:
|
||||
self._pending_play = False
|
||||
deck.play()
|
||||
self._playing = True
|
||||
|
||||
if playing and track:
|
||||
s = pos / track.sr
|
||||
self.time_label.setText(f"{int(s//60):02d}:{int(s%60):02d}")
|
||||
if pos >= track.num_samples - 1:
|
||||
self._playing = False
|
||||
self.btn_play.setChecked(False)
|
||||
self.track_label.setText("NO TRACK")
|
||||
self.time_label.setText("--:--")
|
||||
elif not playing and self._playing:
|
||||
self._playing = False
|
||||
self.btn_play.setChecked(False)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
@@ -313,15 +429,35 @@ class TrackPlaylistPanel(BasePlaylistPanel):
|
||||
header.setAlignment(Qt.AlignCenter)
|
||||
root.addWidget(header)
|
||||
|
||||
self.preview_bar = PreviewBar(self.engine)
|
||||
root.addWidget(self.preview_bar)
|
||||
|
||||
self.hour_display = HourDisplay()
|
||||
root.addWidget(self.hour_display)
|
||||
|
||||
self.deck_selector = DeckSelector()
|
||||
root.addWidget(self.deck_selector)
|
||||
|
||||
self.search_box = QLineEdit()
|
||||
self.search_box.setPlaceholderText("search...")
|
||||
self.search_box.setFont(QFont("Courier New", 9))
|
||||
self.search_box.setStyleSheet("""
|
||||
QLineEdit {
|
||||
background-color: #0d0d0d;
|
||||
color: #00ff41;
|
||||
border: 1px solid #333;
|
||||
border-radius: 3px;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
QLineEdit:focus { border-color: #00ff41; }
|
||||
""")
|
||||
self.search_box.textChanged.connect(self._filter_list)
|
||||
root.addWidget(self.search_box)
|
||||
|
||||
self.playlist = self._make_list()
|
||||
self.playlist.itemDoubleClicked.connect(self._send_to_deck)
|
||||
self.playlist.customContextMenuRequested.connect(self._context_menu)
|
||||
self.preview_bar.set_playlist(self.playlist)
|
||||
root.addWidget(self.playlist)
|
||||
|
||||
row1 = QHBoxLayout()
|
||||
@@ -367,6 +503,13 @@ class TrackPlaylistPanel(BasePlaylistPanel):
|
||||
self.btn_remove.clicked.connect(self._remove_selected)
|
||||
self.btn_clear.clicked.connect(self._clear_all)
|
||||
|
||||
def _filter_list(self):
|
||||
query = self.search_box.text().lower()
|
||||
for i in range(self.playlist.count()):
|
||||
item = self.playlist.item(i)
|
||||
if isinstance(item, PlaylistItem):
|
||||
item.setHidden(bool(query and query not in item.filename.lower()))
|
||||
|
||||
def _send_to_deck(self, item=None):
|
||||
if item is None:
|
||||
item = self.playlist.currentItem()
|
||||
@@ -459,10 +602,10 @@ class TrackPlaylistPanel(BasePlaylistPanel):
|
||||
# ─────────────────────────────────────────
|
||||
|
||||
class CartPlaylistPanel(BasePlaylistPanel):
|
||||
def __init__(self, engine, cart_slots: list, parent=None):
|
||||
def __init__(self, engine, cart_player, parent=None):
|
||||
super().__init__(parent)
|
||||
self.engine = engine
|
||||
self.cart_slots = cart_slots
|
||||
self.engine = engine
|
||||
self.cart_player = cart_player
|
||||
self._build_ui()
|
||||
|
||||
def _build_ui(self):
|
||||
@@ -486,8 +629,24 @@ class CartPlaylistPanel(BasePlaylistPanel):
|
||||
header.setAlignment(Qt.AlignCenter)
|
||||
root.addWidget(header)
|
||||
|
||||
self.search_box = QLineEdit()
|
||||
self.search_box.setPlaceholderText("search...")
|
||||
self.search_box.setFont(QFont("Courier New", 9))
|
||||
self.search_box.setStyleSheet("""
|
||||
QLineEdit {
|
||||
background-color: #0d0d0d;
|
||||
color: #ffaa00;
|
||||
border: 1px solid #333;
|
||||
border-radius: 3px;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
QLineEdit:focus { border-color: #ffaa00; }
|
||||
""")
|
||||
self.search_box.textChanged.connect(self._filter_list)
|
||||
root.addWidget(self.search_box)
|
||||
|
||||
self.playlist = self._make_list()
|
||||
self.playlist.itemDoubleClicked.connect(self._send_to_next_free_cart)
|
||||
self.playlist.itemDoubleClicked.connect(self._send_to_cart)
|
||||
self.playlist.customContextMenuRequested.connect(self._context_menu)
|
||||
root.addWidget(self.playlist)
|
||||
|
||||
@@ -501,11 +660,19 @@ class CartPlaylistPanel(BasePlaylistPanel):
|
||||
|
||||
row2 = QHBoxLayout()
|
||||
row2.setSpacing(4)
|
||||
self.btn_send_cart = self._make_btn("→ LOAD CART", "#3a5a1a")
|
||||
self.btn_send_queue = self._make_btn("→ QUEUE", "#4a3a00")
|
||||
row2.addWidget(self.btn_send_cart)
|
||||
row2.addWidget(self.btn_send_queue)
|
||||
root.addLayout(row2)
|
||||
|
||||
row3 = QHBoxLayout()
|
||||
row3.setSpacing(4)
|
||||
self.btn_remove = self._make_btn("REMOVE", "#5a1a1a")
|
||||
self.btn_clear = self._make_btn("CLEAR ALL", "#3a1a1a")
|
||||
row2.addWidget(self.btn_remove)
|
||||
row2.addWidget(self.btn_clear)
|
||||
root.addLayout(row2)
|
||||
row3.addWidget(self.btn_remove)
|
||||
row3.addWidget(self.btn_clear)
|
||||
root.addLayout(row3)
|
||||
|
||||
self.count_label = QLabel("0 tracks")
|
||||
self.count_label.setFont(QFont("Courier New", 9))
|
||||
@@ -521,27 +688,33 @@ class CartPlaylistPanel(BasePlaylistPanel):
|
||||
self.btn_add_folder.clicked.connect(
|
||||
lambda: self._add_folder_to(self.playlist)
|
||||
)
|
||||
self.btn_send_cart.clicked.connect(self._send_to_cart)
|
||||
self.btn_send_queue.clicked.connect(self._send_to_cart_queue)
|
||||
self.btn_remove.clicked.connect(self._remove_selected)
|
||||
self.btn_clear.clicked.connect(self._clear_all)
|
||||
|
||||
def _send_to_cart(self, item: PlaylistItem, cart_index: int):
|
||||
if not isinstance(item, PlaylistItem):
|
||||
return
|
||||
slot = self.cart_slots[cart_index]
|
||||
slot.load_track(item.filepath)
|
||||
self._highlight(item, active=True)
|
||||
def _filter_list(self):
|
||||
query = self.search_box.text().lower()
|
||||
for i in range(self.playlist.count()):
|
||||
item = self.playlist.item(i)
|
||||
if isinstance(item, PlaylistItem):
|
||||
item.setHidden(bool(query and query not in item.filename.lower()))
|
||||
|
||||
def _send_to_next_free_cart(self, item=None):
|
||||
def _send_to_cart(self, item=None):
|
||||
if item is None:
|
||||
item = self.playlist.currentItem()
|
||||
if not isinstance(item, PlaylistItem):
|
||||
return
|
||||
for i in range(4):
|
||||
cart_key = f"cart{i + 1}"
|
||||
if self.engine.decks[cart_key].track is None:
|
||||
self._send_to_cart(item, i)
|
||||
return
|
||||
self._send_to_cart(item, 0)
|
||||
self.cart_player.load_track(item.filepath)
|
||||
self._highlight(item, active=True)
|
||||
|
||||
def _send_to_cart_queue(self, item=None):
|
||||
if item is None:
|
||||
item = self.playlist.currentItem()
|
||||
if not isinstance(item, PlaylistItem):
|
||||
return
|
||||
self.engine.add_to_cart_queue(item.filepath)
|
||||
self._highlight(item, queued=True)
|
||||
|
||||
def _remove_selected(self):
|
||||
row = self.playlist.currentRow()
|
||||
@@ -568,12 +741,13 @@ class CartPlaylistPanel(BasePlaylistPanel):
|
||||
QMenu::item:selected { background-color: #1a3a5a; }
|
||||
""")
|
||||
|
||||
for i in range(4):
|
||||
action = QAction(f"Load → Cart {i + 1}", self)
|
||||
action.triggered.connect(
|
||||
lambda checked, idx=i: self._send_to_cart(item, idx)
|
||||
)
|
||||
menu.addAction(action)
|
||||
a1 = QAction("Load → Cart", self)
|
||||
a1.triggered.connect(lambda: self._send_to_cart(item))
|
||||
menu.addAction(a1)
|
||||
|
||||
a2 = QAction("Queue → Cart", self)
|
||||
a2.triggered.connect(lambda: self._send_to_cart_queue(item))
|
||||
menu.addAction(a2)
|
||||
|
||||
menu.addSeparator()
|
||||
ar = QAction("Remove", self)
|
||||
@@ -593,7 +767,7 @@ class PlaylistWidget(QWidget):
|
||||
PLAYLIST_JSON_FILTER = "JSON Files (*.json)"
|
||||
|
||||
def __init__(self, engine, deck_widgets: dict,
|
||||
cart_slots: list, parent=None):
|
||||
cart_player, parent=None):
|
||||
super().__init__(parent)
|
||||
self.engine = engine
|
||||
|
||||
@@ -604,7 +778,6 @@ class PlaylistWidget(QWidget):
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
root.setSpacing(4)
|
||||
|
||||
# Toolbar
|
||||
bar = QHBoxLayout()
|
||||
bar.setSpacing(4)
|
||||
|
||||
@@ -656,11 +829,11 @@ class PlaylistWidget(QWidget):
|
||||
""")
|
||||
|
||||
self.track_panel = TrackPlaylistPanel(engine, deck_widgets)
|
||||
self.cart_panel = CartPlaylistPanel(engine, cart_slots)
|
||||
self.cart_panel = CartPlaylistPanel(engine, cart_player)
|
||||
|
||||
splitter.addWidget(self.track_panel)
|
||||
splitter.addWidget(self.cart_panel)
|
||||
splitter.setSizes([600, 400])
|
||||
splitter.setSizes([400, 400])
|
||||
|
||||
root.addWidget(splitter)
|
||||
|
||||
|
||||
+454
-215
@@ -1,211 +1,62 @@
|
||||
# top_bar_widget.py
|
||||
|
||||
from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QPushButton
|
||||
from PySide6.QtCore import Qt, QTimer, Signal, QSize
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame, QHBoxLayout, QVBoxLayout, QLabel, QPushButton,
|
||||
QComboBox, QScrollArea, QWidget, QListWidget
|
||||
)
|
||||
from PySide6.QtCore import Qt, QTimer, Signal, QSize, QPoint
|
||||
from PySide6.QtGui import QFont
|
||||
from datetime import datetime
|
||||
from functools import partial
|
||||
|
||||
from icons import icon_theme
|
||||
|
||||
from icons import icon_theme
|
||||
from icons import icon_theme, icon_cog, icon_save, icon_load
|
||||
|
||||
|
||||
THEMES = [
|
||||
# Dark themes
|
||||
{
|
||||
'name': 'GREEN',
|
||||
'accent': '#00ff41',
|
||||
'accent_dim': '#00aa2a',
|
||||
'bg': '#0d0d0d',
|
||||
'bg2': '#111111',
|
||||
'border': '#1a3a1a',
|
||||
'text': '#cccccc',
|
||||
'is_light': False,
|
||||
},
|
||||
{
|
||||
'name': 'AMBER',
|
||||
'accent': '#ffaa00',
|
||||
'accent_dim': '#cc8800',
|
||||
'bg': '#0d0d00',
|
||||
'bg2': '#111100',
|
||||
'border': '#3a3000',
|
||||
'text': '#ddddcc',
|
||||
'is_light': False,
|
||||
},
|
||||
{
|
||||
'name': 'BLUE',
|
||||
'accent': '#00aaff',
|
||||
'accent_dim': '#0077cc',
|
||||
'bg': '#00080d',
|
||||
'bg2': '#001122',
|
||||
'border': '#001a3a',
|
||||
'text': '#ccd8dd',
|
||||
'is_light': False,
|
||||
},
|
||||
{
|
||||
'name': 'RED',
|
||||
'accent': '#ff4444',
|
||||
'accent_dim': '#cc2222',
|
||||
'bg': '#0d0000',
|
||||
'bg2': '#110000',
|
||||
'border': '#3a0000',
|
||||
'text': '#ddcccc',
|
||||
'is_light': False,
|
||||
},
|
||||
{
|
||||
'name': 'PURPLE',
|
||||
'accent': '#cc00ff',
|
||||
'accent_dim': '#9900cc',
|
||||
'bg': '#0d000d',
|
||||
'bg2': '#1a001a',
|
||||
'border': '#3a003a',
|
||||
'text': '#ddccdd',
|
||||
'is_light': False,
|
||||
},
|
||||
{
|
||||
'name': 'CYAN',
|
||||
'accent': '#00ffff',
|
||||
'accent_dim': '#00cccc',
|
||||
'bg': '#000d0d',
|
||||
'bg2': '#001a1a',
|
||||
'border': '#003a3a',
|
||||
'text': '#ccdddd',
|
||||
'is_light': False,
|
||||
},
|
||||
{
|
||||
'name': 'ORANGE',
|
||||
'accent': '#ff8800',
|
||||
'accent_dim': '#cc6600',
|
||||
'bg': '#0d0700',
|
||||
'bg2': '#1a1100',
|
||||
'border': '#3a2200',
|
||||
'text': '#ddccbb',
|
||||
'is_light': False,
|
||||
},
|
||||
{
|
||||
'name': 'PINK',
|
||||
'accent': '#ff1493',
|
||||
'accent_dim': '#cc0066',
|
||||
'bg': '#0d0005',
|
||||
'bg2': '#1a000a',
|
||||
'border': '#3a0015',
|
||||
'text': '#ddccdd',
|
||||
'is_light': False,
|
||||
},
|
||||
{
|
||||
'name': 'LIME',
|
||||
'accent': '#ccff00',
|
||||
'accent_dim': '#99cc00',
|
||||
'bg': '#0a0d00',
|
||||
'bg2': '#111a00',
|
||||
'border': '#223a00',
|
||||
'text': '#ddddcc',
|
||||
'is_light': False,
|
||||
},
|
||||
{
|
||||
'name': 'GOLD',
|
||||
'accent': '#ffd700',
|
||||
'accent_dim': '#ccaa00',
|
||||
'bg': '#0d0d05',
|
||||
'bg2': '#1a1a0a',
|
||||
'border': '#3a3a15',
|
||||
'text': '#ddddbb',
|
||||
'is_light': False,
|
||||
},
|
||||
{
|
||||
'name': 'VIOLET',
|
||||
'accent': '#8a2be2',
|
||||
'accent_dim': '#6600cc',
|
||||
'bg': '#05000d',
|
||||
'bg2': '#0a0015',
|
||||
'border': '#15002a',
|
||||
'text': '#ccbbdd',
|
||||
'is_light': False,
|
||||
},
|
||||
{
|
||||
'name': 'TURQUOISE',
|
||||
'accent': '#40e0d0',
|
||||
'accent_dim': '#20b0a0',
|
||||
'bg': '#000d0a',
|
||||
'bg2': '#001a15',
|
||||
'border': '#00382d',
|
||||
'text': '#ccddda',
|
||||
'is_light': False,
|
||||
},
|
||||
{
|
||||
'name': 'CRIMSON',
|
||||
'accent': '#dc143c',
|
||||
'accent_dim': '#aa0022',
|
||||
'bg': '#0d0002',
|
||||
'bg2': '#1a0005',
|
||||
'border': '#3a000a',
|
||||
'text': '#ddcccc',
|
||||
'is_light': False,
|
||||
},
|
||||
{
|
||||
'name': 'CHARTREUSE',
|
||||
'accent': '#7fff00',
|
||||
'accent_dim': '#5fcc00',
|
||||
'bg': '#050d00',
|
||||
'bg2': '#0a1a00',
|
||||
'border': '#153a00',
|
||||
'text': '#ddddcc',
|
||||
'is_light': False,
|
||||
},
|
||||
# Light themes
|
||||
{
|
||||
'name': 'LIGHT BLUE',
|
||||
'accent': '#0066cc',
|
||||
'accent_dim': '#0044aa',
|
||||
'bg': '#f5f5f5',
|
||||
'bg2': '#e8e8e8',
|
||||
'border': '#bbbbbb',
|
||||
'text': '#222222',
|
||||
'is_light': True,
|
||||
},
|
||||
{
|
||||
'name': 'LIGHT GREEN',
|
||||
'accent': '#00aa44',
|
||||
'accent_dim': '#008833',
|
||||
'bg': '#f0f5f0',
|
||||
'bg2': '#e0e8e0',
|
||||
'border': '#aaccaa',
|
||||
'text': '#112211',
|
||||
'is_light': True,
|
||||
},
|
||||
{
|
||||
'name': 'LIGHT AMBER',
|
||||
'accent': '#cc8800',
|
||||
'accent_dim': '#aa6600',
|
||||
'bg': '#f5f3f0',
|
||||
'bg2': '#e8e5e0',
|
||||
'border': '#ccbb99',
|
||||
'text': '#221100',
|
||||
'is_light': True,
|
||||
},
|
||||
{
|
||||
'name': 'LIGHT PURPLE',
|
||||
'accent': '#8844cc',
|
||||
'accent_dim': '#6622aa',
|
||||
'bg': '#f3f0f5',
|
||||
'bg2': '#e5e0e8',
|
||||
'border': '#ccbbdd',
|
||||
'text': '#221122',
|
||||
'is_light': True,
|
||||
},
|
||||
{'name': 'DARK TEAL', 'accent': '#00b894', 'accent_dim': '#00876a', 'bg': '#121418', 'bg2': '#1a1c23', 'border': '#2a2c35', 'text': '#d0d4dc', 'is_light': False},
|
||||
{'name': 'GREEN', 'accent': '#00ff41', 'accent_dim': '#00aa2a', 'bg': '#0d0d0d', 'bg2': '#111111', 'border': '#1a3a1a', 'text': '#cccccc', 'is_light': False},
|
||||
{'name': 'AMBER', 'accent': '#ffaa00', 'accent_dim': '#cc8800', 'bg': '#0d0d00', 'bg2': '#111100', 'border': '#3a3000', 'text': '#ddddcc', 'is_light': False},
|
||||
{'name': 'BLUE', 'accent': '#00aaff', 'accent_dim': '#0077cc', 'bg': '#00080d', 'bg2': '#001122', 'border': '#001a3a', 'text': '#ccd8dd', 'is_light': False},
|
||||
{'name': 'RED', 'accent': '#ff4444', 'accent_dim': '#cc2222', 'bg': '#0d0000', 'bg2': '#110000', 'border': '#3a0000', 'text': '#ddcccc', 'is_light': False},
|
||||
{'name': 'PURPLE', 'accent': '#cc00ff', 'accent_dim': '#9900cc', 'bg': '#0d000d', 'bg2': '#1a001a', 'border': '#3a003a', 'text': '#ddccdd', 'is_light': False},
|
||||
{'name': 'CYAN', 'accent': '#00ffff', 'accent_dim': '#00cccc', 'bg': '#000d0d', 'bg2': '#001a1a', 'border': '#003a3a', 'text': '#ccdddd', 'is_light': False},
|
||||
{'name': 'ORANGE', 'accent': '#ff8800', 'accent_dim': '#cc6600', 'bg': '#0d0700', 'bg2': '#1a1100', 'border': '#3a2200', 'text': '#ddccbb', 'is_light': False},
|
||||
{'name': 'PINK', 'accent': '#ff1493', 'accent_dim': '#cc0066', 'bg': '#0d0005', 'bg2': '#1a000a', 'border': '#3a0015', 'text': '#ddccdd', 'is_light': False},
|
||||
{'name': 'LIME', 'accent': '#ccff00', 'accent_dim': '#99cc00', 'bg': '#0a0d00', 'bg2': '#111a00', 'border': '#223a00', 'text': '#ddddcc', 'is_light': False},
|
||||
{'name': 'GOLD', 'accent': '#ffd700', 'accent_dim': '#ccaa00', 'bg': '#0d0d05', 'bg2': '#1a1a0a', 'border': '#3a3a15', 'text': '#ddddbb', 'is_light': False},
|
||||
{'name': 'VIOLET', 'accent': '#8a2be2', 'accent_dim': '#6600cc', 'bg': '#05000d', 'bg2': '#0a0015', 'border': '#15002a', 'text': '#ccbbdd', 'is_light': False},
|
||||
{'name': 'TURQUOISE', 'accent': '#40e0d0', 'accent_dim': '#20b0a0', 'bg': '#000d0a', 'bg2': '#001a15', 'border': '#00382d', 'text': '#ccddda', 'is_light': False},
|
||||
{'name': 'CRIMSON', 'accent': '#dc143c', 'accent_dim': '#aa0022', 'bg': '#0d0002', 'bg2': '#1a0005', 'border': '#3a000a', 'text': '#ddcccc', 'is_light': False},
|
||||
{'name': 'CHARTREUSE', 'accent': '#7fff00', 'accent_dim': '#5fcc00', 'bg': '#050d00', 'bg2': '#0a1a00', 'border': '#153a00', 'text': '#ddddcc', 'is_light': False},
|
||||
{'name': 'PAPER WHITE', 'accent': '#0066cc', 'accent_dim': '#004488', 'bg': '#ffffff', 'bg2': '#eeeeee', 'border': '#cccccc', 'text': '#111111', 'is_light': True},
|
||||
{'name': 'GREY PAPER', 'accent': '#cc3300', 'accent_dim': '#992200', 'bg': '#e8e8e8', 'bg2': '#d8d8d8', 'border': '#aaaaaa', 'text': '#111111', 'is_light': True},
|
||||
{'name': 'CREAM', 'accent': '#884422', 'accent_dim': '#663311', 'bg': '#f5f0e8', 'bg2': '#e8e0d4', 'border': '#c0b8a8', 'text': '#221100', 'is_light': True},
|
||||
{'name': 'FROST', 'accent': '#0088aa', 'accent_dim': '#006688', 'bg': '#f0f4f8', 'bg2': '#e0e8f0', 'border': '#b0c0d0', 'text': '#001122', 'is_light': True},
|
||||
{'name': 'LIGHT LAVENDER','accent': '#6633cc','accent_dim': '#4422aa', 'bg': '#f5f0fa', 'bg2': '#e8e0f0', 'border': '#c0b8d0', 'text': '#110022', 'is_light': True},
|
||||
]
|
||||
|
||||
RATIOS = [
|
||||
("21:9", 2560, 1080),
|
||||
("16:9", 1920, 1080),
|
||||
("4:3", 1440, 1080),
|
||||
]
|
||||
|
||||
|
||||
class TopBarWidget(QFrame):
|
||||
theme_changed = Signal(dict)
|
||||
ratio_changed = Signal(int, int)
|
||||
save_session = Signal()
|
||||
load_session = Signal()
|
||||
recording_toggled = Signal(bool)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self._theme_index = 0
|
||||
self._ratio_index = 1
|
||||
self._midi_engine = None
|
||||
|
||||
self.setFrameStyle(QFrame.Box | QFrame.Raised)
|
||||
self.setFixedHeight(80)
|
||||
self.setFixedHeight(56)
|
||||
self._apply_bar_style()
|
||||
|
||||
self._build_ui()
|
||||
@@ -214,13 +65,15 @@ class TopBarWidget(QFrame):
|
||||
self.timer.setInterval(1000)
|
||||
self.timer.timeout.connect(self._update)
|
||||
self.timer.start()
|
||||
|
||||
self._update()
|
||||
|
||||
def set_midi_engine(self, engine):
|
||||
self._midi_engine = engine
|
||||
|
||||
def _apply_bar_style(self):
|
||||
theme = THEMES[self._theme_index]
|
||||
self.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
TopBarWidget {{
|
||||
background-color: {theme['bg2']};
|
||||
border: 2px solid {theme['border']};
|
||||
border-radius: 6px;
|
||||
@@ -229,46 +82,96 @@ class TopBarWidget(QFrame):
|
||||
|
||||
def _build_ui(self):
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(20, 8, 20, 8)
|
||||
layout.setSpacing(12)
|
||||
layout.setContentsMargins(16, 4, 16, 4)
|
||||
layout.setSpacing(8)
|
||||
|
||||
# Theme cycle button
|
||||
self.theme_btn = QPushButton()
|
||||
self.theme_btn.setFixedSize(40, 40)
|
||||
self.theme_btn.setIconSize(QSize(22, 22))
|
||||
self.theme_btn.setFixedSize(30, 30)
|
||||
self.theme_btn.setIconSize(QSize(16, 16))
|
||||
self.theme_btn.setIcon(icon_theme())
|
||||
self.theme_btn.setToolTip("Cycle colour theme")
|
||||
self.theme_btn.clicked.connect(self._cycle_theme)
|
||||
self._style_theme_btn()
|
||||
layout.addWidget(self.theme_btn)
|
||||
|
||||
# Station name
|
||||
self.station_label = QLabel("GETTING TO IT")
|
||||
self.station_label.setFont(QFont("Arial", 16, QFont.Bold))
|
||||
self.station_label.setStyleSheet(
|
||||
"color: #444444; background: transparent; border: none;"
|
||||
)
|
||||
self.station_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||||
self.station_label.setFont(QFont("Arial", 13, QFont.Bold))
|
||||
self.station_label.setStyleSheet("color: #555555; background: transparent; border: none;")
|
||||
layout.addWidget(self.station_label)
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
# Date
|
||||
self.record_btn = QPushButton("● REC")
|
||||
self.record_btn.setCheckable(True)
|
||||
self.record_btn.setFixedSize(70, 30)
|
||||
self.record_btn.setFont(QFont("Arial", 8, QFont.Bold))
|
||||
self.record_btn.setToolTip("Record master output to WAV")
|
||||
self.record_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #2a1a1a;
|
||||
color: #664444;
|
||||
border: 1px solid #442222;
|
||||
border-radius: 3px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
QPushButton:checked {
|
||||
background-color: #4a1a1a;
|
||||
color: #ff4444;
|
||||
border: 1px solid #ff4444;
|
||||
}
|
||||
QPushButton:hover { color: #ff8888; }
|
||||
QPushButton:checked:hover { background-color: #5a2020; }
|
||||
""")
|
||||
self.record_btn.toggled.connect(self._on_record_toggled)
|
||||
layout.addWidget(self.record_btn)
|
||||
|
||||
self.settings_btn = QPushButton()
|
||||
self.settings_btn.setFixedSize(24, 24)
|
||||
self.settings_btn.setIconSize(QSize(14, 14))
|
||||
self.settings_btn.setIcon(icon_cog("#666666"))
|
||||
self.settings_btn.setToolTip("Settings")
|
||||
self.settings_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: transparent;
|
||||
border: 1px solid #3a3c44;
|
||||
border-radius: 12px;
|
||||
}
|
||||
QPushButton:hover { background-color: #2a2c34; border-color: #5a5c64; }
|
||||
""")
|
||||
self.settings_btn.clicked.connect(self._toggle_settings_popup)
|
||||
layout.addWidget(self.settings_btn)
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
self.date_label = QLabel("")
|
||||
self.date_label.setFont(QFont("Arial", 20, QFont.Bold))
|
||||
self.date_label.setAlignment(Qt.AlignCenter | Qt.AlignVCenter)
|
||||
self.date_label.setFont(QFont("Arial", 13, QFont.Bold))
|
||||
self.date_label.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(self.date_label)
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
# Clock
|
||||
self.clock_label = QLabel("")
|
||||
self.clock_label.setFont(QFont("Courier New", 42, QFont.Bold))
|
||||
self.clock_label.setFont(QFont("Courier New", 28, QFont.Bold))
|
||||
self.clock_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
self.clock_label.setMinimumWidth(220)
|
||||
self.clock_label.setMinimumWidth(170)
|
||||
self._style_clock_and_date()
|
||||
layout.addWidget(self.clock_label)
|
||||
|
||||
# ── Settings popup ─────────────────────────────────────────────
|
||||
|
||||
def _on_record_toggled(self, checked):
|
||||
self.recording_toggled.emit(checked)
|
||||
|
||||
def _toggle_settings_popup(self):
|
||||
self._popup = SettingsPopup(self._midi_engine, self)
|
||||
self._popup.save_session.connect(self.save_session.emit)
|
||||
self._popup.load_session.connect(self.load_session.emit)
|
||||
pos = self.settings_btn.mapToGlobal(QPoint(0, self.settings_btn.height() + 4))
|
||||
self._popup.move(pos)
|
||||
self._popup.show()
|
||||
|
||||
# ── Theme ──────────────────────────────────────────────────────
|
||||
|
||||
def _style_theme_btn(self):
|
||||
theme = THEMES[self._theme_index]
|
||||
self.theme_btn.setStyleSheet(f"""
|
||||
@@ -276,15 +179,12 @@ class TopBarWidget(QFrame):
|
||||
background-color: transparent;
|
||||
color: {theme['accent']};
|
||||
border: 2px solid {theme['accent']};
|
||||
border-radius: 20px;
|
||||
border-radius: 15px;
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
background-color: {theme['accent']};
|
||||
color: {'#ffffff' if not theme['is_light'] else '#000000'};
|
||||
}}
|
||||
QPushButton:pressed {{
|
||||
background-color: {theme['accent_dim']};
|
||||
}}
|
||||
""")
|
||||
|
||||
def _style_clock_and_date(self):
|
||||
@@ -292,7 +192,6 @@ class TopBarWidget(QFrame):
|
||||
self.clock_label.setStyleSheet(
|
||||
f"color: {theme['accent']}; background: transparent; border: none;"
|
||||
)
|
||||
# Date color adapts to light/dark
|
||||
date_color = '#555555' if theme['is_light'] else '#aaaaaa'
|
||||
self.date_label.setStyleSheet(
|
||||
f"color: {date_color}; background: transparent; border: none;"
|
||||
@@ -308,8 +207,348 @@ class TopBarWidget(QFrame):
|
||||
def _update(self):
|
||||
now = datetime.now()
|
||||
self.clock_label.setText(now.strftime("%H:%M:%S"))
|
||||
self.date_label.setText(now.strftime("%A %d %B %Y"))
|
||||
self.date_label.setText(now.strftime("%a %d %b"))
|
||||
|
||||
@property
|
||||
def current_theme(self) -> dict:
|
||||
return THEMES[self._theme_index]
|
||||
return THEMES[self._theme_index]
|
||||
|
||||
|
||||
# ── Settings Popup ─────────────────────────────────────────────────
|
||||
|
||||
POPUP_STYLE = """
|
||||
QFrame#settingsPopup {
|
||||
background-color: #1a1c23;
|
||||
border: 1px solid #2a2c35;
|
||||
border-radius: 8px;
|
||||
}
|
||||
QPushButton#popupBtn {
|
||||
background-color: #22242a;
|
||||
color: #d0d4dc;
|
||||
border: 1px solid #3a3c44;
|
||||
border-radius: 3px;
|
||||
padding: 3px 8px;
|
||||
font-size: 9pt;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton#popupBtn:hover {
|
||||
background-color: #2a2c34;
|
||||
border-color: #5a5c64;
|
||||
}
|
||||
QPushButton#ratioBtn {
|
||||
background-color: #22242a;
|
||||
color: #8890a0;
|
||||
border: 1px solid #3a3c44;
|
||||
border-radius: 12px;
|
||||
padding: 2px 10px;
|
||||
font-size: 9pt;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton#ratioBtn:hover {
|
||||
background-color: #2a2c34;
|
||||
border-color: #5a5c64;
|
||||
}
|
||||
QComboBox#portCombo {
|
||||
background-color: #22242a;
|
||||
color: #d0d4dc;
|
||||
border: 1px solid #3a3c44;
|
||||
border-radius: 3px;
|
||||
padding: 2px 4px;
|
||||
font-size: 9pt;
|
||||
min-height: 24px;
|
||||
}
|
||||
QComboBox#portCombo:hover { border-color: #5a5c64; }
|
||||
QComboBox#portCombo::drop-down { border: none; width: 14px; }
|
||||
QComboBox#portCombo QAbstractItemView {
|
||||
background-color: #1a1c23;
|
||||
color: #d0d4dc;
|
||||
selection-background-color: #2a2c35;
|
||||
border: 1px solid #3a3c44;
|
||||
}
|
||||
QScrollArea { border: none; background: transparent; }
|
||||
QScrollBar:vertical { background: #1a1c22; width: 4px; border-radius: 2px; }
|
||||
QScrollBar::handle:vertical { background: #3a3c44; border-radius: 2px; }
|
||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0px; }
|
||||
"""
|
||||
|
||||
|
||||
class SettingsPopup(QFrame):
|
||||
save_session = Signal()
|
||||
load_session = Signal()
|
||||
|
||||
def __init__(self, midi_engine, parent=None):
|
||||
super().__init__(parent)
|
||||
self._midi_engine = midi_engine
|
||||
|
||||
self.setWindowFlags(Qt.Popup | Qt.FramelessWindowHint)
|
||||
self.setObjectName("settingsPopup")
|
||||
self.setStyleSheet(POPUP_STYLE)
|
||||
self.setFixedWidth(620)
|
||||
|
||||
self._build_ui()
|
||||
|
||||
def _build_ui(self):
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(14, 12, 14, 12)
|
||||
root.setSpacing(8)
|
||||
|
||||
# Row 1: Save/Load / Clear MIDI
|
||||
row1 = QHBoxLayout()
|
||||
row1.setSpacing(8)
|
||||
save_btn = QPushButton("SAVE")
|
||||
save_btn.setObjectName("popupBtn")
|
||||
save_btn.setFixedHeight(30)
|
||||
save_btn.clicked.connect(self.save_session.emit)
|
||||
row1.addWidget(save_btn)
|
||||
|
||||
load_btn = QPushButton("LOAD")
|
||||
load_btn.setObjectName("popupBtn")
|
||||
load_btn.setFixedHeight(30)
|
||||
load_btn.clicked.connect(self.load_session.emit)
|
||||
row1.addWidget(load_btn)
|
||||
|
||||
row1.addStretch()
|
||||
|
||||
clear_midi_btn = QPushButton("CLEAR MIDI")
|
||||
clear_midi_btn.setObjectName("popupBtn")
|
||||
clear_midi_btn.setFixedHeight(30)
|
||||
clear_midi_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #3a1a1a;
|
||||
color: #ff6666;
|
||||
border: 1px solid #5a2a2a;
|
||||
border-radius: 3px;
|
||||
font-size: 9pt;
|
||||
font-weight: bold;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
QPushButton:hover { background-color: #5a2a2a; }
|
||||
""")
|
||||
clear_midi_btn.clicked.connect(self._clear_all_midi)
|
||||
row1.addWidget(clear_midi_btn)
|
||||
|
||||
root.addLayout(row1)
|
||||
|
||||
# Row 2: MIDI port
|
||||
row2 = QHBoxLayout()
|
||||
row2.setSpacing(6)
|
||||
self._port_combo = QComboBox()
|
||||
self._port_combo.setObjectName("portCombo")
|
||||
self._port_combo.setMinimumWidth(200)
|
||||
row2.addWidget(self._port_combo)
|
||||
|
||||
refresh_btn = QPushButton("⟳")
|
||||
refresh_btn.setObjectName("popupBtn")
|
||||
refresh_btn.setFixedSize(30, 26)
|
||||
refresh_btn.clicked.connect(self._refresh_ports)
|
||||
row2.addWidget(refresh_btn)
|
||||
|
||||
self._connect_btn = QPushButton("CONNECT")
|
||||
self._connect_btn.setObjectName("popupBtn")
|
||||
self._connect_btn.setFixedHeight(26)
|
||||
self._connect_btn.clicked.connect(self._connect_midi)
|
||||
row2.addWidget(self._connect_btn)
|
||||
|
||||
self._midi_status = QLabel("")
|
||||
self._midi_status.setStyleSheet("color: #888; background: transparent; border: none; font-size: 9pt;")
|
||||
row2.addWidget(self._midi_status, stretch=1)
|
||||
root.addLayout(row2)
|
||||
|
||||
# Row 3: MIDI learn scroll
|
||||
learn_label = QLabel("MIDI LEARN")
|
||||
learn_label.setStyleSheet("color: #00b894; background: transparent; border: none; font-size: 10pt; font-weight: bold; padding-top: 4px;")
|
||||
root.addWidget(learn_label)
|
||||
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFixedHeight(400)
|
||||
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
scroll.setStyleSheet(POPUP_STYLE)
|
||||
|
||||
content = QWidget()
|
||||
content.setStyleSheet("background: transparent;")
|
||||
grid = QVBoxLayout(content)
|
||||
grid.setContentsMargins(0, 0, 0, 0)
|
||||
grid.setSpacing(4)
|
||||
|
||||
learn_actions = [
|
||||
("Deck 1 Play/Pause", "deck_play_pause", "deck1"),
|
||||
("Deck 1 Cue", "deck_cue", "deck1"),
|
||||
("Deck 2 Play/Pause", "deck_play_pause", "deck2"),
|
||||
("Deck 2 Cue", "deck_cue", "deck2"),
|
||||
("Cart Trigger", "cart_trigger", 0),
|
||||
("Ch 1 Volume", "mixer_volume", 0),
|
||||
("Ch 2 Volume", "mixer_volume", 1),
|
||||
("Ch 3 Volume", "mixer_volume", 2),
|
||||
("Ch 4 Volume", "mixer_volume", 3),
|
||||
("Ch 5 Volume", "mixer_volume", 4),
|
||||
("Ch 6 Volume", "mixer_volume", 5),
|
||||
("Ch 7 Volume", "mixer_volume", 6),
|
||||
("Ch 8 Volume", "mixer_volume", 7),
|
||||
("Ch 1 Gain", "mixer_gain", 0),
|
||||
("Ch 2 Gain", "mixer_gain", 1),
|
||||
("Ch 3 Gain", "mixer_gain", 2),
|
||||
("Ch 4 Gain", "mixer_gain", 3),
|
||||
("Ch 5 Gain", "mixer_gain", 4),
|
||||
("Ch 6 Gain", "mixer_gain", 5),
|
||||
("Ch 7 Gain", "mixer_gain", 6),
|
||||
("Ch 8 Gain", "mixer_gain", 7),
|
||||
("Ch 1 Mic On", "mixer_mic_on", 0),
|
||||
("Ch 2 Mic On", "mixer_mic_on", 1),
|
||||
("Ch 1 PFL", "mixer_pfl", 0),
|
||||
("Ch 2 PFL", "mixer_pfl", 1),
|
||||
("Ch 3 PFL", "mixer_pfl", 2),
|
||||
("Ch 4 PFL", "mixer_pfl", 3),
|
||||
("Ch 5 PFL", "mixer_pfl", 4),
|
||||
("Ch 6 PFL", "mixer_pfl", 5),
|
||||
("Ch 7 PFL", "mixer_pfl", 6),
|
||||
("Ch 8 PFL", "mixer_pfl", 7),
|
||||
("Ch 3 Mute", "mixer_mute", 2),
|
||||
("Ch 4 Mute", "mixer_mute", 3),
|
||||
("Ch 5 Mute", "mixer_mute", 4),
|
||||
("Ch 6 Mute", "mixer_mute", 5),
|
||||
("Ch 7 Mute", "mixer_mute", 6),
|
||||
("Ch 8 Mute", "mixer_mute", 7),
|
||||
("Master Volume", "master_volume", "master"),
|
||||
("PFL⇄HP Blend", "master_control", "pfl_hp_blend"),
|
||||
]
|
||||
|
||||
self._learn_btns = {}
|
||||
for label, action, target_id in learn_actions:
|
||||
row = QHBoxLayout()
|
||||
row.setSpacing(3)
|
||||
|
||||
lbl = QLabel(label)
|
||||
lbl.setStyleSheet("color: #d0d4dc; background: transparent; border: none; font-size: 9pt;")
|
||||
row.addWidget(lbl, stretch=1)
|
||||
|
||||
btn = QPushButton("LEARN")
|
||||
btn.setObjectName("popupBtn")
|
||||
btn.setFixedSize(64, 24)
|
||||
btn.setCheckable(True)
|
||||
btn.toggled.connect(partial(self._toggle_learn, btn, action, target_id))
|
||||
row.addWidget(btn)
|
||||
self._learn_btns[(action, target_id)] = btn
|
||||
|
||||
clear_btn = QPushButton("X")
|
||||
clear_btn.setObjectName("popupBtn")
|
||||
clear_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #3a1a1a;
|
||||
color: #ff6666;
|
||||
border: 1px solid #5a2a2a;
|
||||
border-radius: 3px;
|
||||
font-size: 9pt;
|
||||
font-weight: bold;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
QPushButton:hover { background-color: #5a2a2a; }
|
||||
""")
|
||||
clear_btn.setFixedSize(28, 24)
|
||||
clear_btn.clicked.connect(partial(self._clear_mapping, action, target_id))
|
||||
row.addWidget(clear_btn)
|
||||
|
||||
grid.addLayout(row)
|
||||
|
||||
scroll.setWidget(content)
|
||||
root.addWidget(scroll)
|
||||
|
||||
monitor_label = QLabel("MIDI MONITOR")
|
||||
monitor_label.setStyleSheet("color: #556670; background: transparent; border: none; font-size: 9pt; font-weight: bold;")
|
||||
root.addWidget(monitor_label)
|
||||
|
||||
self._midi_monitor = QListWidget()
|
||||
self._midi_monitor.setFixedHeight(120)
|
||||
self._midi_monitor.setStyleSheet("""
|
||||
QListWidget {
|
||||
background-color: #0d0e12;
|
||||
border: 1px solid #22242a;
|
||||
border-radius: 3px;
|
||||
color: #8890a0;
|
||||
font-size: 9pt;
|
||||
font-family: monospace;
|
||||
}
|
||||
""")
|
||||
|
||||
self._refresh_ports()
|
||||
|
||||
if self._midi_engine:
|
||||
self._midi_engine.mapping_learned.connect(self._on_mapping_learned)
|
||||
self._midi_engine.raw_midi_event.connect(self._on_raw_midi)
|
||||
|
||||
def _on_raw_midi(self, type_name, channel, control, value):
|
||||
self._midi_monitor.addItem(
|
||||
f"{type_name:>10} ch{channel} ctl={control:>3} val={value:>3}"
|
||||
)
|
||||
while self._midi_monitor.count() > 200:
|
||||
self._midi_monitor.takeItem(0)
|
||||
self._midi_monitor.scrollToBottom()
|
||||
|
||||
# ── MIDI port ──────────────────────────────────────────────────
|
||||
|
||||
def _refresh_ports(self):
|
||||
self._port_combo.clear()
|
||||
if not self._midi_engine:
|
||||
return
|
||||
ports = self._midi_engine.list_ports()
|
||||
if ports:
|
||||
for p in ports:
|
||||
self._port_combo.addItem(p)
|
||||
self._connect_btn.setEnabled(True)
|
||||
self._midi_status.setText("Select port")
|
||||
else:
|
||||
self._port_combo.addItem("No MIDI ports found")
|
||||
self._connect_btn.setEnabled(False)
|
||||
self._midi_status.setText("No MIDI devices")
|
||||
|
||||
def _connect_midi(self):
|
||||
if not self._midi_engine or self._port_combo.currentText() == "No MIDI ports found":
|
||||
return
|
||||
self._midi_engine.stop()
|
||||
idx = self._port_combo.currentIndex()
|
||||
success = self._midi_engine.start(port_index=idx)
|
||||
if success:
|
||||
self._midi_status.setText(f"Connected")
|
||||
self._midi_status.setStyleSheet("color: #00b894; background: transparent; border: none; font-size: 9pt;")
|
||||
else:
|
||||
self._midi_status.setText("Failed")
|
||||
self._midi_status.setStyleSheet("color: #ff6666; background: transparent; border: none; font-size: 9pt;")
|
||||
|
||||
# ── MIDI learn ─────────────────────────────────────────────────
|
||||
|
||||
def _toggle_learn(self, btn, action, target_id, checked):
|
||||
if not self._midi_engine:
|
||||
btn.setChecked(False)
|
||||
return
|
||||
if checked:
|
||||
self._midi_engine.start_learn(action, target_id)
|
||||
for other_btn in self._learn_btns.values():
|
||||
if other_btn is not btn:
|
||||
other_btn.setChecked(False)
|
||||
btn.setText("...")
|
||||
else:
|
||||
self._midi_engine.stop_learn()
|
||||
btn.setText("LEARN")
|
||||
|
||||
def _clear_mapping(self, action, target_id):
|
||||
if not self._midi_engine:
|
||||
return
|
||||
to_remove = []
|
||||
for key, val in self._midi_engine.mapping.bindings.items():
|
||||
if val['action'] == action and val['target_id'] == target_id:
|
||||
to_remove.append(key)
|
||||
for key in to_remove:
|
||||
del self._midi_engine.mapping.bindings[key]
|
||||
self._midi_engine.mapping.save()
|
||||
|
||||
def _clear_all_midi(self):
|
||||
if not self._midi_engine:
|
||||
return
|
||||
self._midi_engine.mapping.bindings.clear()
|
||||
self._midi_engine.mapping.save()
|
||||
|
||||
def _on_mapping_learned(self, action, target_id, midi_type, channel, note, value):
|
||||
btn = self._learn_btns.get((action, target_id))
|
||||
if btn and btn.isChecked():
|
||||
btn.setChecked(False)
|
||||
btn.setText("OK")
|
||||
Reference in New Issue
Block a user