Compare commits

...
10 Commits
Author SHA1 Message Date
nickmin 0277089b2d Overhaul: 5-task implementation
- Remove TURN decks (3/4), clean up MIDI, layout
- Single cart player with 3-item autoload queue + continue toggle
- Event logging (SessionLogger), mic tracking, large mic timer widget
- LUFS metering moved from external ports to internal master mix
- Master output WAV recording with top-bar REC toggle
- Music mix output (ch 3-8 only, no mics)
- Preview bar for playlist auditioning
- Merged show log with [SONG][CART][TALK] prefixes
- Live CSV writer for OBS song display (~/live_songs.csv)
- Misc fixes: preview race condition, datetime import for recording
2026-07-17 20:44:15 +10:00
nickmin da8240a9e2 Initial commit: snapshot of current working state 2026-06-12 15:48:59 +10:00
nickmin 90d5ac26ec v0.6 — smooth VU metering decay, short/long-term LUFS display, deduplicate PFL blend and MIDI mic toggle logic 2026-05-16 22:41:25 +10:00
nickmin 2b81a80a39 Strip compressor/limiter DSP, add per-channel pre-fader gain, PFL/HP blend, LUFS metering from external port, MIDI gain binding 2026-05-15 16:42:10 +10:00
nickmin 4d6a639c08 Remove solo buttons, hide mute on mics, fix master volume, add mic_on MIDI, resize fonts
- Removed solo buttons from all mixer channels
- Mute button hidden on mic channels (0,1) — mic_on toggle replaces it
- Fixed master volume fader not being applied in audio _process()
- Added mixer_mic_on MIDI signal and learnable action
- Added CLEAR MIDI button to settings popup
- Increased settings popup fonts 7pt→9pt, popup size 460→620px
- Reduced deck fonts (header 24→16, elapsed 32→22, etc.) and spacing
- Increased mixer fonts (names 8→9, headers 9→11, etc.)
- Added missing MIDI learn entries (mute ch3-8, pfl ch3-8, mic_on ch1-2)
2026-05-15 08:41:40 +10:00
nickmin 9e4315886d Add mic ON/OFF toggle, headphone and studio output busses
- Channels 0-1 are now dedicated Mic 1/Mic 2 with mic_on toggle
- Mic audio only routes to main when mic_on=True, never to headphone
- New headphone_out_L/R JACK ports (all channels except mics)
- New studio_out_L/R JACK ports (copies main, mutes when any mic is on)
- Mic ON/OFF toggle button in mixer UI (hidden on non-mic channels)
- Session save/load includes mic_on state
2026-05-15 00:36:48 +10:00
nickmin 89fb7010fb v0.5 — cleanup orphan code, add MIDI master volume learn, fix turn-deck crashes
- Remove vestigial lufs_meter_widget.py and debug_audio.py
- Delete unused WaveformWidget/WaveformMonitor classes, dead stubs, duplicate code
- Fix turn-mode deck crashes (deck3/deck4 missing from engine.decks, uninitialized manual_elapsed/duration)
- Replace raw print() with logging in main.py
- Add master_volume MIDI learn signal + settings popup entry
- Bump version to 0.5
2026-05-14 22:25:10 +10:00
nickmin 6067f442b0 v0.4 - cockybastard 2026-05-13 20:53:15 +10:00
nickmin 7de0cc3de0 Fix AttributeError: remove redundant mapping_learned connection in main.py
TopBarWidget already connects mapping_learned to _on_mapping_learned internally
in set_midi_engine, so the external connection in main.py was both redundant
and referenced a non-existent method.
2026-05-13 16:47:02 +10:00
nickmin d2240c61c3 Add 8-channel mixer with master compressor/limiter, MIDI learn system, separate deck outputs
- Mixer: 8 renamable stereo input channels with VU meter, volume fader, mute/solo
- Master bus: compressor (threshold, ratio, attack, release, makeup) + limiter (ceiling), LUFS metering
- Decks/carts keep their own JACK outputs, completely separate from mixer
- MIDI learn: configurable bindings saved to ~/.gti_radiostudio/midi_map.json
- mixer_widget.py: QDial-based DSP controls, scrollable channel strips
2026-05-12 19:29:16 +10:00
15 changed files with 2930 additions and 1039 deletions
+1
View File
@@ -1,3 +1,4 @@
__pycache__/
*.pyc
venv/
obs_overlays/
+1 -2
View File
@@ -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
+377 -57
View File
@@ -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:
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:
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:
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
with deck.lock:
deck.position += n
if deck.position >= track.num_samples:
deck.playing = False
deck.position = track.num_samples - 1
def _process(self, frames: int):
# Decks
# ── Digital decks ───────────────────────────────────────────────
self._fill_port(*self.ports['deck1'], self.decks['deck1'], frames)
self._fill_port(*self.ports['deck2'], self.decks['deck2'], frames)
# Mix carts
# ── Preview deck ─────────────────────────────────────────────
self._fill_port(*self.ports['preview'], self.decks['preview'], frames)
# ── Mix cart ───────────────────────────────────────────────────
buf_L = self.ports['cart'][0].get_array()
buf_R = self.ports['cart'][1].get_array()
buf_L.fill(0.0)
buf_R.fill(0.0)
for cart_key in ('cart1', 'cart2', 'cart3', 'cart4'):
deck = self.decks[cart_key]
if not deck.playing or deck.track is None:
continue
pos = deck.position
track = deck.track
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:
deck.playing = False
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
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
# LUFS metering
lufs_in_L = self.lufs_inports[0].get_array()
lufs_in_R = self.lufs_inports[1].get_array()
factor = g * ch.volume
goes_to_main = not is_mic or ch.mic_on
goes_to_headphone = not is_mic
# 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
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
# Calculate LUFS every 512 frames (reduces CPU)
if frames % 512 < frames:
self._calculate_lufs()
if ch.index >= 2:
music_buf_L[:] += in_L[:] * factor
music_buf_R[:] += in_R[:] * factor
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)
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
if mean_square < 1e-12: # Silence threshold
self.lufs_current = -70.0
# ── 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()
+198 -131
View File
@@ -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._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)
self.engine.load_cart_track("cart1", filepath)
name = os.path.basename(filepath)
short = name[:28] + "..." if len(name) > 28 else name
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;
}}
""")
-26
View File
@@ -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()
+166 -221
View File
@@ -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.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,9 +326,12 @@ 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:
if self.mode == 'turn':
deck.playing = False
else:
deck.pause()
self.btn_play.setIcon(icon_play("#00ff41"))
self._playing_icon = False
@@ -412,12 +339,15 @@ class DeckWidget(QWidget):
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:
if self.mode == 'turn':
deck.playing = True
else:
deck.play()
self.btn_play.setIcon(icon_pause("#ffffff"))
@@ -426,17 +356,26 @@ class DeckWidget(QWidget):
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]
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
@@ -444,7 +383,7 @@ class DeckWidget(QWidget):
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))
+72
View File
@@ -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
+212 -66
View File
@@ -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._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)
if entry_type == 'song':
filepath, metadata = entry_data
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)
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
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 idx, artist, title, album, year
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])
+123
View File
@@ -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)
-139
View File
@@ -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()
+192 -63
View File
@@ -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)
+242 -30
View File
@@ -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:
elif cc_num == 0x18:
self.deck_jog.emit('deck2', delta)
+630
View File
@@ -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)
+221 -48
View File
@@ -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.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)
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._send_to_cart(item, 0)
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)
+453 -214
View File
@@ -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]
# ── 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")