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
This commit is contained in:
2026-05-14 22:25:10 +10:00
parent 6067f442b0
commit 89fb7010fb
13 changed files with 356 additions and 752 deletions
+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 | | **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 | | **CART Playlist** | ✅ | Separate playlist panel, auto-fills next free cart slot |
| **MIDI Controller** | ✅ | Numark DJ2Go - Play/Pause, Cue, Jog wheels, 4 Cart buttons | | **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 | | **Play History** | ✅ | Scrollable session history, file tags + MusicBrainz metadata |
| **Top Bar** | ✅ | 24hr clock, full date display | | **Top Bar** | ✅ | 24hr clock, full date display |
| **Colour Themes** | ✅ | 4 themes - Green, Amber, Blue, Red - cycle with ◈ button | | **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 ├── deck_widget.py # CDJ-style deck UI + transport controls
├── cart_widget.py # 4-slot CART machine ├── cart_widget.py # 4-slot CART machine
├── playlist_widget.py # Playlist + CART playlist panels ├── playlist_widget.py # Playlist + CART playlist panels
├── fingerprint_widget.py # AcoustID audio fingerprinting
├── midi_engine.py # Numark DJ2Go MIDI mapping ├── midi_engine.py # Numark DJ2Go MIDI mapping
├── top_bar_widget.py # Clock, date, theme switcher ├── top_bar_widget.py # Clock, date, theme switcher
├── history_widget.py # Session play history ├── history_widget.py # Session play history
+43 -27
View File
@@ -89,6 +89,8 @@ class DeckState:
self.position = 0 self.position = 0
self.playing = False self.playing = False
self.lock = threading.Lock() self.lock = threading.Lock()
self.manual_elapsed = 0.0
self.manual_duration = 0.0
def load(self, track: Track): def load(self, track: Track):
with self.lock: with self.lock:
@@ -131,15 +133,17 @@ class DeckState:
self.position = self.track.num_samples - 1 self.position = self.track.num_samples - 1
def get_elapsed(self) -> str: def get_elapsed(self) -> str:
if not self.track: with self.lock:
return "00:00" if not self.track:
s = self.position / self.track.sr return "00:00"
s = self.position / self.track.sr
return f"{int(s//60):02d}:{int(s%60):02d}" return f"{int(s//60):02d}:{int(s%60):02d}"
def get_remaining(self) -> str: def get_remaining(self) -> str:
if not self.track: with self.lock:
return "-00:00" if not self.track:
remaining = max(0, self.track.duration - (self.position / self.track.sr)) return "-00:00"
remaining = max(0, self.track.duration - (self.position / self.track.sr))
return f"-{int(remaining//60):02d}:{int(remaining%60):02d}" return f"-{int(remaining//60):02d}:{int(remaining%60):02d}"
@@ -228,6 +232,8 @@ class MasterBus:
self.limiter = Limiter() self.limiter = Limiter()
self.vu_peak = 0.0 self.vu_peak = 0.0
self.lufs_current = -70.0 self.lufs_current = -70.0
self.volume = 1.0
self._lufs_accum = 0
class AudioEngine: class AudioEngine:
@@ -289,6 +295,8 @@ class AudioEngine:
self.decks = { self.decks = {
'deck1': DeckState(), 'deck1': DeckState(),
'deck2': DeckState(), 'deck2': DeckState(),
'deck3': DeckState(),
'deck4': DeckState(),
'cart1': DeckState(), 'cart1': DeckState(),
'cart2': DeckState(), 'cart2': DeckState(),
'cart3': DeckState(), 'cart3': DeckState(),
@@ -301,10 +309,6 @@ class AudioEngine:
def get_channel(self, index: int) -> MixerChannel: def get_channel(self, index: int) -> MixerChannel:
return self.mixer['channels'][index] return self.mixer['channels'][index]
def set_channel_name(self, index: int, name: str):
if 0 <= index < NUM_MIXER_CHANNELS:
self.mixer['channels'][index].name = name
def set_channel_volume(self, index: int, volume: float): def set_channel_volume(self, index: int, volume: float):
if 0 <= index < NUM_MIXER_CHANNELS: if 0 <= index < NUM_MIXER_CHANNELS:
self.mixer['channels'][index].volume = max(0.0, min(1.0, volume)) self.mixer['channels'][index].volume = max(0.0, min(1.0, volume))
@@ -327,25 +331,30 @@ class AudioEngine:
buf_L.fill(0.0) buf_L.fill(0.0)
buf_R.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 return
pos = deck.position
track = deck.track
end = min(pos + frames, track.num_samples) end = min(pos + frames, track.num_samples)
n = end - pos n = end - pos
if n <= 0: if n <= 0:
deck.playing = False with deck.lock:
deck.playing = False
return return
buf_L[:n] = track.audio[0, pos:end] buf_L[:n] = track.audio[0, pos:end]
buf_R[:n] = track.audio[1, pos:end] buf_R[:n] = track.audio[1, pos:end]
deck.position += n
if deck.position >= track.num_samples: with deck.lock:
deck.playing = False deck.position += n
deck.position = track.num_samples - 1 if deck.position >= track.num_samples:
deck.playing = False
deck.position = track.num_samples - 1
def _process(self, frames: int): def _process(self, frames: int):
# ── Digital decks ─────────────────────────────────────────────── # ── Digital decks ───────────────────────────────────────────────
@@ -360,21 +369,25 @@ class AudioEngine:
for cart_key in ('cart1', 'cart2', 'cart3', 'cart4'): for cart_key in ('cart1', 'cart2', 'cart3', 'cart4'):
deck = self.decks[cart_key] deck = self.decks[cart_key]
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:
continue continue
pos = deck.position
track = deck.track
end = min(pos + frames, track.num_samples) end = min(pos + frames, track.num_samples)
n = end - pos n = end - pos
if n <= 0: if n <= 0:
deck.playing = False with deck.lock:
deck.playing = False
continue continue
buf_L[:n] += track.audio[0, pos:end] buf_L[:n] += track.audio[0, pos:end]
buf_R[:n] += track.audio[1, pos:end] buf_R[:n] += track.audio[1, pos:end]
deck.position += n with deck.lock:
if deck.position >= track.num_samples: deck.position += n
deck.playing = False if deck.position >= track.num_samples:
deck.position = track.num_samples - 1 deck.playing = False
deck.position = track.num_samples - 1
# ── External input monitors (deck3, deck4) ───────────────────── # ── External input monitors (deck3, deck4) ─────────────────────
gate_linear = 10 ** (-46.0 / 20.0) gate_linear = 10 ** (-46.0 / 20.0)
@@ -456,7 +469,10 @@ class AudioEngine:
master_peak = max(np.max(np.abs(master_audio[0])), np.max(np.abs(master_audio[1]))) master_peak = max(np.max(np.abs(master_audio[0])), np.max(np.abs(master_audio[1])))
self.mixer['master'].vu_peak = master_peak self.mixer['master'].vu_peak = master_peak
if frames % 512 < frames: master = self.mixer['master']
master._lufs_accum += frames
if master._lufs_accum >= 512:
master._lufs_accum -= 512
self._calculate_lufs(master_audio) self._calculate_lufs(master_audio)
def _calculate_lufs(self, audio: np.ndarray): def _calculate_lufs(self, audio: np.ndarray):
+4 -2
View File
@@ -234,12 +234,14 @@ class CartSlot(QFrame):
def _refresh_display(self): def _refresh_display(self):
deck = self.engine.decks[self.cart_key] deck = self.engine.decks[self.cart_key]
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._set_ui_stopped()
self.status_label.setText("STOPPED") self.status_label.setText("STOPPED")
if deck.track: if track:
self.elapsed_label.setText(deck.get_elapsed()) self.elapsed_label.setText(deck.get_elapsed())
self.remaining_label.setText(deck.get_remaining()) self.remaining_label.setText(deck.get_remaining())
-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()
+116 -323
View File
@@ -2,11 +2,10 @@
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QFrame, QSizePolicy QPushButton, QFrame, QSizePolicy, QInputDialog
) )
from PySide6.QtCore import Qt, QTimer, Signal, QSize from PySide6.QtCore import Qt, QTimer, Signal, QSize
from PySide6.QtGui import QFont, QColor, QPainter, QPen, QPainterPath from PySide6.QtGui import QFont, QColor
import numpy as np
import os import os
from icons import ( from icons import (
@@ -15,161 +14,6 @@ from icons import (
) )
class WaveformWidget(QFrame):
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedHeight(60)
self.setStyleSheet("""
QFrame {
background-color: #050505;
border: 1px solid #1a1a1a;
border-radius: 4px;
}
""")
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 WaveformMonitor(QFrame):
def __init__(self, parent=None):
super().__init__(parent)
self.setStyleSheet("""
QFrame {
background-color: #050a05;
border: 1px solid #1a2a1a;
border-radius: 4px;
}
""")
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
rect = self.rect().adjusted(3, 3, -3, -3)
if rect.width() < 2 or rect.height() < 2:
painter.end()
return
painter.fillRect(rect, QColor("#050a05"))
pen = QPen(QColor("#1a2a1a"), 1)
painter.setPen(pen)
painter.drawRect(rect)
engine = getattr(self, '_engine', None)
monitor_key = getattr(self, '_monitor_key', None)
if engine is None or monitor_key is None:
painter.end()
return
monitor = engine.ext_monitors.get(monitor_key)
if monitor is None or not monitor.monitoring:
pen = QPen(QColor("#0a1a0a"), 1)
painter.setPen(pen)
mid_y = rect.center().y()
painter.drawLine(rect.left(), mid_y, rect.right(), mid_y)
painter.end()
return
buf = monitor.buffer
w = rect.width()
h = rect.height()
mid_y = rect.y() + h // 2
path = QPainterPath()
path.moveTo(rect.right(), mid_y)
for i in range(len(buf)):
x = rect.right() - int((i / len(buf)) * w)
val = buf[(monitor.write_pos - i) % len(buf)]
y = mid_y - int(val * (h * 0.45))
if i == 0:
path.lineTo(x, y)
else:
path.lineTo(x, y)
path.lineTo(rect.right(), mid_y)
path.closeSubpath()
painter.setPen(QPen(QColor("#00ff41"), 1))
painter.setBrush(QColor(0, 255, 65, 30))
painter.drawPath(path)
pen = QPen(QColor("#00ff41"), 1)
painter.setPen(pen)
for i in range(len(buf)):
x = rect.right() - int((i / len(buf)) * w)
val = buf[(monitor.write_pos - i) % len(buf)]
y = mid_y - int(val * (h * 0.45))
if val > 0.01:
painter.drawLine(x, y, x, mid_y)
painter.end()
class LCDDisplay(QFrame): class LCDDisplay(QFrame):
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
@@ -186,11 +30,17 @@ class LCDDisplay(QFrame):
layout.setContentsMargins(10, 8, 10, 8) layout.setContentsMargins(10, 8, 10, 8)
layout.setSpacing(4) layout.setSpacing(4)
self.track_label = QLabel("NO TRACK LOADED") self.track_btn = QPushButton("NO TRACK LOADED")
self.track_label.setFont(QFont("Courier New", 13, QFont.Bold)) self.track_btn.setFont(QFont("Courier New", 13, QFont.Bold))
self.track_label.setStyleSheet("color: #00ff41; background: transparent;") self.track_btn.setStyleSheet("""
self.track_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) QPushButton {
self.track_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) 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 = QHBoxLayout()
time_row.setSpacing(4) time_row.setSpacing(4)
@@ -224,14 +74,17 @@ class LCDDisplay(QFrame):
status_row.addStretch() status_row.addStretch()
status_row.addWidget(self.queue_label) status_row.addWidget(self.queue_label)
layout.addWidget(self.track_label) layout.addWidget(self.track_btn)
layout.addLayout(time_row) layout.addLayout(time_row)
layout.addLayout(status_row) layout.addLayout(status_row)
def update_track(self, name: str): def update_track(self, name: str):
if len(name) > 40: if len(name) > 40:
name = name[:37] + "..." 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): def update_time(self, elapsed: str, remaining: str):
self.elapsed_label.setText(elapsed) self.elapsed_label.setText(elapsed)
@@ -287,7 +140,7 @@ class DeckWidget(QWidget):
track_started = Signal(str) track_started = Signal(str)
def __init__(self, deck_key: str, label: str, accent_color: str, def __init__(self, deck_key: str, label: str, accent_color: str,
engine, parent=None): engine, parent=None, mode='deck'):
super().__init__(parent) super().__init__(parent)
self.deck_key = deck_key self.deck_key = deck_key
self.engine = engine self.engine = engine
@@ -295,9 +148,9 @@ class DeckWidget(QWidget):
self.current_theme = None self.current_theme = None
self.flash_state = False self.flash_state = False
self._playing_icon = False self._playing_icon = False
self.mode = mode
deck_num = label.split()[-1] self.deck_label = label
self.deck_label = f"DECK_{int(deck_num):03d}"
self._build_ui() self._build_ui()
self._connect_signals() self._connect_signals()
@@ -339,9 +192,6 @@ class DeckWidget(QWidget):
self.lcd = LCDDisplay() self.lcd = LCDDisplay()
root.addWidget(self.lcd) root.addWidget(self.lcd)
self.waveform = WaveformWidget()
root.addWidget(self.waveform)
transport = QHBoxLayout() transport = QHBoxLayout()
transport.setSpacing(6) transport.setSpacing(6)
@@ -362,6 +212,12 @@ class DeckWidget(QWidget):
root.addLayout(transport) 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): def apply_theme(self, theme: dict):
self.current_theme = theme self.current_theme = theme
border_color = theme['border'] border_color = theme['border']
@@ -381,13 +237,6 @@ class DeckWidget(QWidget):
border-radius: 4px; border-radius: 4px;
}} }}
""") """)
self.waveform.setStyleSheet(f"""
QFrame {{
background-color: {theme['bg']};
border: 1px solid {border_color};
border-radius: 3px;
}}
""")
def _toggle_flash(self): def _toggle_flash(self):
self.flash_state = not self.flash_state self.flash_state = not self.flash_state
@@ -395,21 +244,39 @@ class DeckWidget(QWidget):
def _connect_signals(self): def _connect_signals(self):
self.btn_play.clicked.connect(self._toggle_play) self.btn_play.clicked.connect(self._toggle_play)
self.btn_stop.clicked.connect(self._stop_eject) self.btn_stop.clicked.connect(self._stop_eject)
self.btn_to_start.clicked.connect(self._go_to_start) self.lcd.track_btn.clicked.connect(self._edit_track_name)
self.btn_to_end.clicked.connect(self._go_to_end) if self.mode != 'turn':
self.btn_back_15.clicked.connect(lambda: self._scrub(-15)) self.btn_to_start.clicked.connect(self._go_to_start)
self.btn_fwd_15.clicked.connect(lambda: self._scrub(15)) 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): def set_track_name(self, name: str):
self.lcd.update_track(name) self.lcd.update_track(name)
self.lcd.update_status("LOADED") self.lcd.update_status("LOADED")
self.lcd.update_time("00:00", "-00:00") 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): def set_queue_name(self, name: str):
self.lcd.update_queue(name) self.lcd.update_queue(name)
@@ -418,6 +285,8 @@ class DeckWidget(QWidget):
self._toggle_play() self._toggle_play()
def cue(self): def cue(self):
if self.mode == 'turn':
return
deck = self.engine.decks[self.deck_key] deck = self.engine.decks[self.deck_key]
if deck.track is None: if deck.track is None:
return return
@@ -442,10 +311,13 @@ class DeckWidget(QWidget):
def _toggle_play(self): def _toggle_play(self):
deck = self.engine.decks[self.deck_key] deck = self.engine.decks[self.deck_key]
if deck.track is None: if deck.track is None and self.mode != 'turn':
return return
if deck.playing: if deck.playing:
deck.pause() if self.mode == 'turn':
deck.playing = False
else:
deck.pause()
self.btn_play.setIcon(icon_play("#00ff41")) self.btn_play.setIcon(icon_play("#00ff41"))
self._playing_icon = False self._playing_icon = False
self.btn_play.setStyleSheet(""" self.btn_play.setStyleSheet("""
@@ -456,10 +328,13 @@ class DeckWidget(QWidget):
} }
QPushButton:hover { background-color: #2a7b4a; } 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) self.lcd.set_playing(False)
else: else:
deck.play() if self.mode == 'turn':
deck.playing = True
else:
deck.play()
self.btn_play.setIcon(icon_pause("#ffffff")) self.btn_play.setIcon(icon_pause("#ffffff"))
self._playing_icon = True self._playing_icon = True
self.btn_play.setStyleSheet(""" self.btn_play.setStyleSheet("""
@@ -472,12 +347,21 @@ class DeckWidget(QWidget):
""") """)
self.lcd.update_status("PLAYING") self.lcd.update_status("PLAYING")
self.lcd.set_playing(True) 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) self.track_started.emit(deck.track.filepath)
def _stop_eject(self): def _stop_eject(self):
deck = self.engine.decks[self.deck_key] deck = self.engine.decks[self.deck_key]
deck.stop() if self.mode == 'turn':
deck.playing = False
deck.manual_elapsed = 0.0
else:
deck.stop()
self.btn_play.setIcon(icon_play("#00ff41")) self.btn_play.setIcon(icon_play("#00ff41"))
self._playing_icon = False self._playing_icon = False
self.btn_play.setStyleSheet(""" self.btn_play.setStyleSheet("""
@@ -492,7 +376,6 @@ class DeckWidget(QWidget):
self.lcd.update_time("00:00", "-00:00") self.lcd.update_time("00:00", "-00:00")
self.lcd.update_status("STOPPED") self.lcd.update_status("STOPPED")
self.lcd.set_playing(False) self.lcd.set_playing(False)
self.waveform.set_waveform(None)
def _go_to_start(self): def _go_to_start(self):
self.engine.decks[self.deck_key].go_to_start() self.engine.decks[self.deck_key].go_to_start()
@@ -506,17 +389,42 @@ class DeckWidget(QWidget):
def _refresh_display(self): def _refresh_display(self):
deck = self.engine.decks[self.deck_key] deck = self.engine.decks[self.deck_key]
if not deck.playing and deck.track is not None: if self.mode == 'turn':
if deck.position >= deck.track.num_samples - 1: if deck.playing:
if deck.queued: deck.manual_elapsed += 0.1
deck.load(deck.queued) if deck.manual_duration > 0 and deck.manual_elapsed >= deck.manual_duration:
self.lcd.update_track(os.path.splitext(os.path.basename(deck.queued.filepath))[0]) deck.playing = False
QTimer.singleShot(200, self._update_waveform) 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 deck.queued = None
self.lcd.update_queue("") self.lcd.update_queue("")
self.lcd.update_status("LOADED") self.lcd.update_status("LOADED")
if not deck.playing and self._playing_icon: if not playing and self._playing_icon:
self.btn_play.setIcon(icon_play("#00ff41")) self.btn_play.setIcon(icon_play("#00ff41"))
self._playing_icon = False self._playing_icon = False
self.btn_play.setStyleSheet(""" self.btn_play.setStyleSheet("""
@@ -530,13 +438,13 @@ class DeckWidget(QWidget):
self.lcd.update_status("STOPPED") self.lcd.update_status("STOPPED")
self.lcd.set_playing(False) self.lcd.set_playing(False)
if deck.track: if track:
self.lcd.update_time(deck.get_elapsed(), deck.get_remaining()) self.lcd.update_time(deck.get_elapsed(), deck.get_remaining())
position_pct = deck.position / deck.track.num_samples position_pct = position / track.num_samples
remaining_sec = deck.track.duration - (deck.position / deck.track.sr) remaining_sec = track.duration - (position / track.sr)
in_warning = (remaining_sec < 20) or (position_pct > 0.85) in_warning = (remaining_sec < 20) or (position_pct > 0.85)
if deck.playing and in_warning: if playing and in_warning:
flash_color = '#ff4444' if self.flash_state else self.accent_color flash_color = '#ff4444' if self.flash_state else self.accent_color
self.header.setStyleSheet(f""" self.header.setStyleSheet(f"""
color: {flash_color}; color: {flash_color};
@@ -554,120 +462,5 @@ class DeckWidget(QWidget):
""") """)
self.lcd.flash_warning(False) self.lcd.flash_warning(False)
self.waveform.set_position(position_pct, in_warning and self.flash_state) if queued:
self.lcd.update_queue(os.path.basename(queued.filepath))
if deck.queued:
self.lcd.update_queue(os.path.basename(deck.queued.filepath))
class ExternalDeckWidget(QWidget):
def __init__(self, deck_key: str, label: str, accent_color: str,
engine, parent=None):
super().__init__(parent)
self.deck_key = deck_key
self.engine = engine
self.accent_color = accent_color
self._monitoring = False
deck_num = label.split()[-1]
self.deck_label = f"EXT_{int(deck_num):03d}"
self._build_ui()
def _build_ui(self):
self.setStyleSheet(f"""
QWidget {{
background-color: #1c1c1c;
border: 2px solid {self.accent_color};
border-radius: 6px;
}}
""")
root = QVBoxLayout(self)
root.setContentsMargins(10, 10, 10, 10)
root.setSpacing(8)
self.header = QLabel(self.deck_label)
self.header.setFont(QFont("Courier New", 24, QFont.Bold))
self.header.setStyleSheet(f"""
color: {self.accent_color};
background: transparent;
border: none;
padding: 4px;
""")
self.header.setAlignment(Qt.AlignCenter)
root.addWidget(self.header)
self.waveform = WaveformMonitor()
self.waveform._engine = self.engine
self.waveform._monitor_key = self.deck_key
self.waveform.setMinimumHeight(100)
root.addWidget(self.waveform, stretch=1)
transport = QHBoxLayout()
transport.setSpacing(6)
self.btn_play = make_icon_button(icon_play(), "#1a6b3a", "#00ff41", 68, 48)
transport.addStretch()
transport.addWidget(self.btn_play)
transport.addStretch()
root.addLayout(transport)
self._connect_signals()
def _connect_signals(self):
self.btn_play.clicked.connect(self._toggle_monitor)
def toggle_play(self):
self._toggle_monitor()
def _toggle_monitor(self):
monitor = self.engine.ext_monitors[self.deck_key]
self._monitoring = not self._monitoring
monitor.monitoring = self._monitoring
if self._monitoring:
self.btn_play.setIcon(icon_pause("#ffffff"))
self.btn_play.setStyleSheet("""
QPushButton {
background-color: #00aa33;
border: 1px solid #00ff41;
border-radius: 4px;
}
QPushButton:hover { background-color: #00cc44; }
""")
self.header.setStyleSheet(f"""
color: #00ff41;
background: transparent;
border: none;
padding: 4px;
""")
else:
self.btn_play.setIcon(icon_play("#00ff41"))
self.btn_play.setStyleSheet("""
QPushButton {
background-color: #1a6b3a;
border: 1px solid #555;
border-radius: 4px;
}
QPushButton:hover { background-color: #2a7b4a; }
""")
self.header.setStyleSheet(f"""
color: {self.accent_color};
background: transparent;
border: none;
padding: 4px;
""")
self.waveform.update()
def apply_theme(self, theme: dict):
border_color = theme['border']
bg_color = theme['bg2']
self.setStyleSheet(f"""
QWidget {{
background-color: {bg_color};
border: 2px solid {border_color};
border-radius: 6px;
}}
""")
-1
View File
@@ -1,7 +1,6 @@
# history_widget.py # history_widget.py
import csv import csv
import io
import os import os
import threading import threading
-33
View File
@@ -312,39 +312,6 @@ def icon_cog(color: str = "#aaaaaa") -> QIcon:
return _make_icon(paint, color) 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 icon_theme(color: str = "#00ff41") -> QIcon:
def paint(p, r, c): def paint(p, r, c):
p.setPen(Qt.NoPen) 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()
+21 -9
View File
@@ -1,8 +1,12 @@
# main.py # main.py
import json import json
import logging
import os import os
import sys import sys
__version__ = "0.5"
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QFileDialog, QApplication, QMainWindow, QWidget, QFileDialog,
QVBoxLayout, QHBoxLayout, QSplitter QVBoxLayout, QHBoxLayout, QSplitter
@@ -11,7 +15,7 @@ from PySide6.QtCore import Qt
from PySide6.QtGui import QFont, QColor, QPalette from PySide6.QtGui import QFont, QColor, QPalette
from audio_engine import AudioEngine from audio_engine import AudioEngine
from deck_widget import DeckWidget, ExternalDeckWidget from deck_widget import DeckWidget
from cart_widget import CartBankWidget from cart_widget import CartBankWidget
from playlist_widget import PlaylistWidget from playlist_widget import PlaylistWidget
from midi_engine import MidiEngine from midi_engine import MidiEngine
@@ -19,6 +23,8 @@ from top_bar_widget import TopBarWidget, THEMES
from history_widget import HistoryWidget from history_widget import HistoryWidget
from mixer_widget import MixerWidget from mixer_widget import MixerWidget
logger = logging.getLogger(__name__)
class RadioPanelWindow(QMainWindow): class RadioPanelWindow(QMainWindow):
def __init__(self): def __init__(self):
@@ -26,7 +32,7 @@ class RadioPanelWindow(QMainWindow):
self.engine = AudioEngine() self.engine = AudioEngine()
self.setWindowTitle("Getting To It") self.setWindowTitle(f"Getting To It v{__version__}")
self.setMinimumSize(1400, 860) self.setMinimumSize(1400, 860)
self.resize(1920, 1080) self.resize(1920, 1080)
self._apply_global_style(THEMES[0]) self._apply_global_style(THEMES[0])
@@ -48,10 +54,10 @@ class RadioPanelWindow(QMainWindow):
# ── Build widgets ───────────────────────────────────────────────── # ── Build widgets ─────────────────────────────────────────────────
self.deck1 = DeckWidget("deck1", "DECK 1", "#00b894", self.engine) self.deck1 = DeckWidget("deck1", "DECK_001", "#00b894", self.engine, mode='deck')
self.deck2 = DeckWidget("deck2", "DECK 2", "#6c5ce7", self.engine) self.deck2 = DeckWidget("deck2", "DECK_002", "#6c5ce7", self.engine, mode='deck')
self.deck3 = ExternalDeckWidget("deck3", "DECK 3", "#fdcb6e", self.engine) self.deck3 = DeckWidget("deck3", "TURN_001", "#fdcb6e", self.engine, mode='turn')
self.deck4 = ExternalDeckWidget("deck4", "DECK 4", "#e17055", self.engine) self.deck4 = DeckWidget("deck4", "TURN_002", "#e17055", self.engine, mode='turn')
self.cart_bank = CartBankWidget(self.engine) self.cart_bank = CartBankWidget(self.engine)
self.cart_bank.setFixedHeight(180) self.cart_bank.setFixedHeight(180)
@@ -117,6 +123,8 @@ class RadioPanelWindow(QMainWindow):
# ── Wire signals ───────────────────────────────────────────────── # ── Wire signals ─────────────────────────────────────────────────
self.deck1.track_started.connect(self.history_widget.add_track) self.deck1.track_started.connect(self.history_widget.add_track)
self.deck2.track_started.connect(self.history_widget.add_track) self.deck2.track_started.connect(self.history_widget.add_track)
self.deck3.track_started.connect(self.history_widget.add_track)
self.deck4.track_started.connect(self.history_widget.add_track)
# ── MIDI ────────────────────────────────────────────────────────── # ── MIDI ──────────────────────────────────────────────────────────
self._setup_midi() self._setup_midi()
@@ -233,6 +241,7 @@ class RadioPanelWindow(QMainWindow):
self.midi.mixer_solo.connect(self._on_midi_mixer_solo) self.midi.mixer_solo.connect(self._on_midi_mixer_solo)
self.midi.mixer_pfl.connect(self._on_midi_mixer_pfl) self.midi.mixer_pfl.connect(self._on_midi_mixer_pfl)
self.midi.master_control.connect(self._on_midi_master_control) 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.controller_connected.connect(self._on_midi_status)
self.midi.start() self.midi.start()
@@ -240,7 +249,7 @@ class RadioPanelWindow(QMainWindow):
ports = self.midi.list_ports() ports = self.midi.list_ports()
if ports: if ports:
print(f"[MIDI] Available ports: {ports}") logger.info("Available ports: %s", ports)
def _on_midi_play_pause(self, deck: str): def _on_midi_play_pause(self, deck: str):
if deck == 'deck1': if deck == 'deck1':
@@ -298,11 +307,14 @@ class RadioPanelWindow(QMainWindow):
v = round(max(lo, min(hi, v)), 1) v = round(max(lo, min(hi, v)), 1)
setattr(master.compressor if knob != "ceiling_db" else master.limiter, knob, v) setattr(master.compressor if knob != "ceiling_db" else master.limiter, knob, v)
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): def _on_midi_status(self, connected: bool, name: str):
if connected: if connected:
print(f"[MIDI] Connected: {name}") logger.info("Connected: %s", name)
else: else:
print(f"[MIDI] Not connected: {name}") logger.info("Not connected: %s", name)
def closeEvent(self, event): def closeEvent(self, event):
self.midi.stop() self.midi.stop()
+7 -2
View File
@@ -19,7 +19,6 @@ NOTE_ON = 0x90
NOTE_OFF = 0x80 NOTE_OFF = 0x80
CC = 0xB0 CC = 0xB0
PITCH_BEND = 0xE0 PITCH_BEND = 0xE0
PITCH_BEND = 0xE0
CONFIG_DIR = os.path.expanduser("~/.gti_radiostudio") CONFIG_DIR = os.path.expanduser("~/.gti_radiostudio")
CONFIG_FILE = os.path.join(CONFIG_DIR, "midi_map.json") CONFIG_FILE = os.path.join(CONFIG_DIR, "midi_map.json")
@@ -96,6 +95,7 @@ class MidiEngine(QObject):
mixer_solo = Signal(int, bool) mixer_solo = Signal(int, bool)
mixer_pfl = Signal(int, bool) mixer_pfl = Signal(int, bool)
master_control = Signal(str, float) master_control = Signal(str, float)
master_volume = Signal(float)
controller_connected = Signal(bool, str) controller_connected = Signal(bool, str)
mapping_learned = Signal(str, object, int, int, int, bool) mapping_learned = Signal(str, object, int, int, int, bool)
raw_midi_event = Signal(str, int, int, int) raw_midi_event = Signal(str, int, int, int)
@@ -115,7 +115,6 @@ class MidiEngine(QObject):
self._last_cc_values = {} self._last_cc_values = {}
self._channel_volumes = {} self._channel_volumes = {}
self._master_knob_values = {} self._master_knob_values = {}
self._master_knob_values = {}
@property @property
def learn_mode(self) -> bool: def learn_mode(self) -> bool:
@@ -303,6 +302,12 @@ class MidiEngine(QObject):
scaled = (127 - value if inv else value) / 127.0 scaled = (127 - value if inv else value) / 127.0
self._master_knob_values[knob] = scaled self._master_knob_values[knob] = scaled
self.master_control.emit(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): def _handle_note_on(self, note: int):
# Hardcoded Numark DJ2Go mapping (legacy) # Hardcoded Numark DJ2Go mapping (legacy)
+161 -183
View File
@@ -93,8 +93,8 @@ class ChannelStrip(QFrame):
self.channel_index = channel_index self.channel_index = channel_index
self._channel = engine.get_channel(channel_index) self._channel = engine.get_channel(channel_index)
self.setMinimumWidth(90) self.setFixedWidth(90)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
self.setStyleSheet(""" self.setStyleSheet("""
ChannelStrip { ChannelStrip {
background-color: #181a1e; background-color: #181a1e;
@@ -269,32 +269,25 @@ class ChannelStrip(QFrame):
def _refresh(self): def _refresh(self):
self.vu_meter.set_value(self._channel.vu_peak) self.vu_meter.set_value(self._channel.vu_peak)
self.volume_slider.setValue(int(math.sqrt(self._channel.volume) * 100)) self.volume_slider.setValue(int(math.sqrt(self._channel.volume) * 100))
self.volume_slider.setValue(int(math.sqrt(self._channel.volume) * 100))
def apply_theme(self, theme: dict):
pass
class MasterSection(QFrame): class MasterPreSection(QFrame):
def __init__(self, engine, parent=None): def __init__(self, engine, parent=None):
super().__init__(parent) super().__init__(parent)
self.engine = engine self.engine = engine
self._master = engine.mixer['master'] self._master = engine.mixer['master']
self._knobs = {} self.setFixedWidth(80)
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
self.setMinimumWidth(180)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.setStyleSheet(""" self.setStyleSheet("""
MasterSection { MasterPreSection {
background-color: #181a1e; background-color: #101216;
border: 1px solid #282a30; border: 1px solid #1e2026;
border-radius: 6px; border-radius: 6px;
} }
""") """)
self._build_ui() self._build_ui()
self._connect_signals()
self.timer = QTimer(self) self.timer = QTimer(self)
self.timer.setInterval(30) self.timer.setInterval(30)
@@ -303,158 +296,158 @@ class MasterSection(QFrame):
def _build_ui(self): def _build_ui(self):
root = QVBoxLayout(self) root = QVBoxLayout(self)
root.setContentsMargins(8, 6, 8, 6) root.setContentsMargins(4, 6, 4, 6)
root.setSpacing(4) root.setSpacing(4)
header = QLabel("MASTER") header = QLabel("MASTER")
header.setFont(QFont("Segoe UI", 10, QFont.Bold)) header.setFont(QFont("Segoe UI", 8, QFont.Bold))
header.setAlignment(Qt.AlignCenter) header.setAlignment(Qt.AlignCenter)
header.setStyleSheet("color: #00cc88; background: transparent; border: none;") header.setStyleSheet("color: #888; background: transparent; border: none;")
root.addWidget(header) root.addWidget(header)
meter_row = QHBoxLayout()
meter_row.setSpacing(2)
self.vu_meter = VUMeter() self.vu_meter = VUMeter()
root.addWidget(self.vu_meter) meter_row.addWidget(self.vu_meter, stretch=1)
self.lufs_label = QLabel("-70.0 LUFS") self.volume_slider = QSlider(Qt.Vertical)
self.lufs_label.setFont(QFont("Segoe UI", 9, QFont.Bold)) self.volume_slider.setRange(0, 100)
self.lufs_label.setAlignment(Qt.AlignCenter) self.volume_slider.setValue(int(math.sqrt(self._master.volume) * 100))
self.lufs_label.setStyleSheet("color: #00cc88; background: transparent; border: none;") self.volume_slider.setStyleSheet("""
root.addWidget(self.lufs_label) 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; }
pfl_indicator = QLabel("PFL: —") QSlider::handle:vertical:hover { background: #b0b8c8; }
self.pfl_indicator = pfl_indicator QSlider::add-page:vertical {
pfl_indicator.setFont(QFont("Segoe UI", 8)) background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #00cc66, stop:1 #008844);
pfl_indicator.setAlignment(Qt.AlignCenter)
pfl_indicator.setStyleSheet("color: #556; background: transparent; border: none;")
root.addWidget(pfl_indicator)
comp_frame = QFrame()
comp_frame.setStyleSheet("""
QFrame {
background-color: #121318;
border: 1px solid #22242a;
border-radius: 4px;
}
""")
comp_layout = QVBoxLayout(comp_frame)
comp_layout.setContentsMargins(4, 4, 4, 4)
comp_layout.setSpacing(2)
comp_header = QHBoxLayout()
cl = QLabel("COMPRESSOR")
cl.setFont(QFont("Segoe UI", 7, QFont.Bold))
cl.setStyleSheet("color: #778; background: transparent; border: none;")
comp_header.addWidget(cl)
self.comp_bypass = QPushButton("B")
self.comp_bypass.setFixedSize(22, 18)
self.comp_bypass.setCheckable(True)
self.comp_bypass.setFont(QFont("Segoe UI", 7, QFont.Bold))
self.comp_bypass.setStyleSheet("""
QPushButton {
background-color: #22242a;
color: #778;
border: 1px solid #333;
border-radius: 2px; border-radius: 2px;
} }
QPushButton:checked { QSlider::sub-page:vertical { background: transparent; border-radius: 2px; }
background-color: #4a1a1a; """)
color: #ff6666; meter_row.addWidget(self.volume_slider, stretch=1)
border: 1px solid #ff4444;
root.addLayout(meter_row, stretch=1)
self.volume_slider.valueChanged.connect(self._on_volume_changed)
def _on_volume_changed(self, value: int):
vol = (value / 100.0) ** 2
self._master.volume = vol
def _refresh(self):
self.vu_meter.set_value(self._master.vu_peak)
self.volume_slider.setValue(int(math.sqrt(self._master.volume) * 100))
class EffectSection(QFrame):
def __init__(self, engine, parent=None):
super().__init__(parent)
self.engine = engine
self._master = engine.mixer['master']
self._knobs = {}
self.setFixedWidth(110)
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
self.setStyleSheet("""
EffectSection {
background-color: #181a1e;
border: 1px solid #282a30;
border-radius: 6px;
} }
""") """)
self.comp_bypass.setToolTip("Bypass compressor")
comp_header.addWidget(self.comp_bypass)
comp_layout.addLayout(comp_header)
knobs = QHBoxLayout() self._build_ui()
knobs.setSpacing(4)
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(3, 6, 3, 6)
root.setSpacing(2)
header = QLabel("FX")
header.setFont(QFont("Segoe UI", 8, QFont.Bold))
header.setAlignment(Qt.AlignCenter)
header.setStyleSheet("color: #556670; background: transparent; border: none;")
root.addWidget(header)
lufs_frame = QFrame()
lufs_frame.setStyleSheet("QFrame { background-color: #121318; border: 1px solid #22242a; border-radius: 4px; }")
lufs_layout = QVBoxLayout(lufs_frame)
lufs_layout.setContentsMargins(3, 4, 3, 4)
lufs_layout.setSpacing(2)
self.lufs_value = QLabel("-70.0")
self.lufs_value.setFont(QFont("Segoe UI", 13, QFont.Bold))
self.lufs_value.setAlignment(Qt.AlignCenter)
self.lufs_value.setStyleSheet("color: #00cc88; background: transparent; border: none;")
lufs_layout.addWidget(self.lufs_value)
lufs_unit = QLabel("LUFS")
lufs_unit.setFont(QFont("Segoe UI", 6))
lufs_unit.setAlignment(Qt.AlignCenter)
lufs_unit.setStyleSheet("color: #556; background: transparent; border: none;")
lufs_layout.addWidget(lufs_unit)
self.pfl_indicator = QLabel("PFL\n")
self.pfl_indicator.setFont(QFont("Segoe UI", 5))
self.pfl_indicator.setAlignment(Qt.AlignCenter)
self.pfl_indicator.setStyleSheet("color: #556; background: transparent; border: none;")
lufs_layout.addWidget(self.pfl_indicator)
root.addWidget(lufs_frame)
knob_col = QVBoxLayout()
knob_col.setSpacing(1)
bypass_row = QHBoxLayout()
bypass_row.setSpacing(2)
for label, btn_ref, obj, attr in [
("C", "comp_bypass", self._master.compressor, 'bypassed'),
("L", "lim_bypass", self._master.limiter, 'bypassed'),
]:
btn = QPushButton(label)
btn.setFixedSize(24, 18)
btn.setCheckable(True)
btn.setFont(QFont("Segoe UI", 7, QFont.Bold))
btn.setStyleSheet("""
QPushButton { background-color: #22242a; color: #889; border: 1px solid #333; border-radius: 3px; }
QPushButton:checked { background-color: #4a1a1a; color: #ff6666; border: 1px solid #ff4444; }
""")
setattr(self, btn_ref, btn)
btn.toggled.connect(lambda c, o=obj, a=attr: setattr(o, a, c))
bypass_row.addWidget(btn)
knob_col.addLayout(bypass_row)
for label, attr, default, lo, hi, suffix in [ for label, attr, default, lo, hi, suffix in [
("THR", "threshold_db", -20, -60, 0, "dB"), ("THR", "threshold_db", -20, -60, 0, "dB"),
("RAT", "ratio", 4, 1, 20, ":1"), ("RAT", "ratio", 4, 1, 20, ":1"),
("ATK", "attack_ms", 5, 0.1, 50, "ms"), ("ATK", "attack_ms", 5, 0.1, 50, "ms"),
("REL", "release_ms", 100, 10, 1000, "ms"), ("REL", "release_ms", 100, 10, 1000, "ms"),
("MKG", "makeup_gain_db", 0, 0, 24, "dB"), ("MKG", "makeup_gain_db", 0, 0, 24, "dB"),
("CEIL", "ceiling_db", -0.1, -10, 0, "dB"),
]: ]:
k, dial, val_label = self._make_knob(label, self._master.compressor, attr, obj = self._master.compressor if attr != "ceiling_db" else self._master.limiter
default, lo, hi, suffix) k, dial, val_label = self._make_knob(label, obj, attr, default, lo, hi, suffix)
knobs.addWidget(k) knob_col.addWidget(k)
self._knobs[('compressor', attr)] = (dial, val_label, lo, hi, suffix) self._knobs[('compressor' if attr != 'ceiling_db' else 'limiter', attr)] = (dial, val_label, lo, hi, suffix)
comp_layout.addLayout(knobs) root.addLayout(knob_col)
root.addWidget(comp_frame)
lim_frame = QFrame()
lim_frame.setStyleSheet("""
QFrame {
background-color: #121318;
border: 1px solid #22242a;
border-radius: 4px;
}
""")
lim_layout = QVBoxLayout(lim_frame)
lim_layout.setContentsMargins(4, 4, 4, 4)
lim_layout.setSpacing(2)
lim_header = QHBoxLayout()
ll = QLabel("LIMITER")
ll.setFont(QFont("Segoe UI", 7, QFont.Bold))
ll.setStyleSheet("color: #778; background: transparent; border: none;")
lim_header.addWidget(ll)
self.lim_bypass = QPushButton("B")
self.lim_bypass.setFixedSize(22, 18)
self.lim_bypass.setCheckable(True)
self.lim_bypass.setFont(QFont("Segoe UI", 7, QFont.Bold))
self.lim_bypass.setStyleSheet("""
QPushButton {
background-color: #22242a;
color: #778;
border: 1px solid #333;
border-radius: 2px;
}
QPushButton:checked {
background-color: #4a1a1a;
color: #ff6666;
border: 1px solid #ff4444;
}
""")
self.lim_bypass.setToolTip("Bypass limiter")
lim_header.addWidget(self.lim_bypass)
lim_layout.addLayout(lim_header)
lim_knobs = QHBoxLayout()
lim_knobs.setSpacing(4)
k, dial, val_label = self._make_knob("CEIL", self._master.limiter, "ceiling_db",
-0.1, -10, 0, "dB")
lim_knobs.addWidget(k)
self._knobs[('limiter', 'ceiling_db')] = (dial, val_label, -10, 0, "dB")
lim_layout.addLayout(lim_knobs)
root.addWidget(lim_frame)
root.addStretch()
def _make_knob(self, label, obj, attr, default, lo, hi, suffix): def _make_knob(self, label, obj, attr, default, lo, hi, suffix):
frame = QFrame() frame = QFrame()
frame.setStyleSheet("background: transparent; border: none;") frame.setStyleSheet("background: transparent; border: none;")
layout = QVBoxLayout(frame) layout = QHBoxLayout(frame)
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(1) layout.setSpacing(2)
lbl = QLabel(label)
lbl.setFont(QFont("Segoe UI", 7, QFont.Bold))
lbl.setAlignment(Qt.AlignCenter)
lbl.setStyleSheet("color: #556; background: transparent; border: none;")
layout.addWidget(lbl)
dial = QDial() dial = QDial()
dial.setFixedSize(44, 44) dial.setFixedSize(30, 30)
dial.setRange(0, 1000) dial.setRange(0, 1000)
dial.setNotchesVisible(False) dial.setNotchesVisible(False)
dial.setStyleSheet(""" dial.setStyleSheet("""
QDial { QDial { background-color: #121318; border: 1px solid #2a2c33; border-radius: 15px; }
background-color: #121318;
border: 1px solid #2a2c33;
border-radius: 22px;
}
""") """)
def _val_to_pos(v): def _val_to_pos(v):
@@ -465,10 +458,15 @@ class MasterSection(QFrame):
dial.setValue(_val_to_pos(default)) dial.setValue(_val_to_pos(default))
lbl = QLabel(label)
lbl.setFont(QFont("Segoe UI", 8, QFont.Bold))
lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
lbl.setStyleSheet("color: #889; background: transparent; border: none;")
val_label = QLabel(f"{default}{suffix}") val_label = QLabel(f"{default}{suffix}")
val_label.setFont(QFont("Segoe UI", 10, QFont.Bold)) val_label.setFont(QFont("Segoe UI", 7))
val_label.setAlignment(Qt.AlignCenter) val_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
val_label.setStyleSheet("color: #ccd; background: transparent; border: none;") val_label.setStyleSheet("color: #667; background: transparent; border: none;")
def _on_dial(v): def _on_dial(v):
val = _pos_to_val(v) val = _pos_to_val(v)
@@ -488,53 +486,29 @@ class MasterSection(QFrame):
val_label.setText(f"{val}{suffix}") val_label.setText(f"{val}{suffix}")
dial.valueChanged.connect(_on_dial) dial.valueChanged.connect(_on_dial)
layout.addWidget(dial, alignment=Qt.AlignCenter) layout.addWidget(dial)
layout.addWidget(lbl, 1)
layout.addWidget(val_label) layout.addWidget(val_label)
return frame, dial, val_label return frame, dial, val_label
def _connect_signals(self):
self.comp_bypass.toggled.connect(
lambda c: setattr(self._master.compressor, 'bypassed', c)
)
self.lim_bypass.toggled.connect(
lambda c: setattr(self._master.limiter, 'bypassed', c)
)
def _refresh(self): def _refresh(self):
self.vu_meter.set_value(self._master.vu_peak) for (section, attr), (dial, val_label, lo, hi, suffix) in self._knobs.items():
self.lufs_label.setText(f"{self._master.lufs_current:.1f} LUFS") obj = self._master.compressor if section == 'compressor' else self._master.limiter
val = getattr(obj, attr, lo)
dial.blockSignals(True)
dial.setValue(int((val - lo) / (hi - lo) * 1000))
dial.blockSignals(False)
val_label.setText(f"{val}{suffix}")
self.lufs_value.setText(f"{self._master.lufs_current:.1f}")
active_pfl = [ch for ch in self.engine.mixer['channels'] if ch.pfl] active_pfl = [ch for ch in self.engine.mixer['channels'] if ch.pfl]
if active_pfl: if active_pfl:
names = ", ".join(ch.name[:6] for ch in active_pfl) names = ", ".join(ch.name[:3] for ch in active_pfl)
self.pfl_indicator.setText(f"PFL: {names}") self.pfl_indicator.setText(f"PFL\n{names}")
self.pfl_indicator.setStyleSheet( self.pfl_indicator.setStyleSheet("color: #00aaff; background: transparent; border: none;")
"color: #00aaff; background: transparent; border: none;"
)
else: else:
self.pfl_indicator.setText("PFL: ") self.pfl_indicator.setText("PFL\n")
self.pfl_indicator.setStyleSheet( self.pfl_indicator.setStyleSheet("color: #556; background: transparent; border: none;")
"color: #556; background: transparent; border: none;"
)
for (section, attr), (dial, val_label, lo, hi, suffix) in self._knobs.items():
obj = self._master.compressor if section == 'compressor' else self._master.limiter
val = getattr(obj, attr, lo)
dial.blockSignals(True)
dial.setValue(int((val - lo) / (hi - lo) * 1000))
dial.blockSignals(False)
val_label.setText(f"{val}{suffix}")
for (section, attr), (dial, val_label, lo, hi, suffix) in self._knobs.items():
obj = self._master.compressor if section == 'compressor' else self._master.limiter
val = getattr(obj, attr, lo)
dial.blockSignals(True)
dial.setValue(int((val - lo) / (hi - lo) * 1000))
dial.blockSignals(False)
val_label.setText(f"{val}{suffix}")
def apply_theme(self, theme: dict):
pass
class MixerWidget(QFrame): class MixerWidget(QFrame):
def __init__(self, engine, parent=None): def __init__(self, engine, parent=None):
@@ -597,8 +571,11 @@ class MixerWidget(QFrame):
self._strips.append(strip) self._strips.append(strip)
channels_layout.addWidget(strip) channels_layout.addWidget(strip)
self.master_section = MasterSection(self.engine) self.master_pre = MasterPreSection(self.engine)
channels_layout.addWidget(self.master_section) channels_layout.addWidget(self.master_pre)
self.effects = EffectSection(self.engine)
channels_layout.addWidget(self.effects)
scroll.setWidget(scroll_content) scroll.setWidget(scroll_content)
root.addWidget(scroll) root.addWidget(scroll)
@@ -606,7 +583,8 @@ class MixerWidget(QFrame):
def apply_theme(self, theme: dict): def apply_theme(self, theme: dict):
for strip in self._strips: for strip in self._strips:
strip.apply_theme(theme) strip.apply_theme(theme)
self.master_section.apply_theme(theme) self.master_pre.apply_theme(theme)
self.effects.apply_theme(theme)
def refresh_strips(self): def refresh_strips(self):
for strip in self._strips: for strip in self._strips:
+2 -5
View File
@@ -6,15 +6,13 @@ from PySide6.QtWidgets import (
QListWidget, QListWidgetItem, QAbstractItemView, QListWidget, QListWidgetItem, QAbstractItemView,
QMenu, QSplitter QMenu, QSplitter
) )
from PySide6.QtCore import Qt, QTimer, QSize, QSize from PySide6.QtCore import Qt, QTimer, QSize
from PySide6.QtGui import QFont, QColor, QAction from PySide6.QtGui import QFont, QColor, QAction
import json import json
import os import os
from icons import icon_save, icon_load from icons import icon_save, icon_load
from icons import icon_save, icon_load
# ───────────────────────────────────────── # ─────────────────────────────────────────
# PLAYLIST ITEM # PLAYLIST ITEM
@@ -277,8 +275,7 @@ class BasePlaylistPanel(QWidget):
paths.append(item.filepath) paths.append(item.filepath)
return paths return paths
def clear_list(self, lst: QListWidget):
lst.clear()
# ───────────────────────────────────────── # ─────────────────────────────────────────
+1
View File
@@ -354,6 +354,7 @@ class SettingsPopup(QFrame):
("Master REL", "master_control", "release_ms"), ("Master REL", "master_control", "release_ms"),
("Master MKG", "master_control", "makeup_gain_db"), ("Master MKG", "master_control", "makeup_gain_db"),
("Master CEIL", "master_control", "ceiling_db"), ("Master CEIL", "master_control", "ceiling_db"),
("Master Volume", "master_volume", "master"),
] ]
self._learn_btns = {} self._learn_btns = {}