diff --git a/README b/README index 6bcacca..ef195b9 100644 --- a/README +++ b/README @@ -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 diff --git a/audio_engine.py b/audio_engine.py index ba225a3..3584d51 100644 --- a/audio_engine.py +++ b/audio_engine.py @@ -89,6 +89,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: @@ -131,15 +133,17 @@ class DeckState: self.position = self.track.num_samples - 1 def get_elapsed(self) -> str: - if not self.track: - return "00:00" - s = self.position / self.track.sr + with self.lock: + if not self.track: + return "00:00" + s = self.position / self.track.sr return f"{int(s//60):02d}:{int(s%60):02d}" def get_remaining(self) -> str: - if not self.track: - return "-00:00" - remaining = max(0, self.track.duration - (self.position / self.track.sr)) + with self.lock: + if not self.track: + return "-00:00" + remaining = max(0, self.track.duration - (self.position / self.track.sr)) return f"-{int(remaining//60):02d}:{int(remaining%60):02d}" @@ -228,6 +232,8 @@ class MasterBus: self.limiter = Limiter() self.vu_peak = 0.0 self.lufs_current = -70.0 + self.volume = 1.0 + self._lufs_accum = 0 class AudioEngine: @@ -289,6 +295,8 @@ class AudioEngine: self.decks = { 'deck1': DeckState(), 'deck2': DeckState(), + 'deck3': DeckState(), + 'deck4': DeckState(), 'cart1': DeckState(), 'cart2': DeckState(), 'cart3': DeckState(), @@ -301,10 +309,6 @@ class AudioEngine: def get_channel(self, index: int) -> MixerChannel: return self.mixer['channels'][index] - def set_channel_name(self, index: int, name: str): - if 0 <= index < NUM_MIXER_CHANNELS: - self.mixer['channels'][index].name = name - def set_channel_volume(self, index: int, volume: float): if 0 <= index < NUM_MIXER_CHANNELS: self.mixer['channels'][index].volume = max(0.0, min(1.0, volume)) @@ -327,25 +331,30 @@ class AudioEngine: buf_L.fill(0.0) buf_R.fill(0.0) - if not deck.playing or deck.track is None: + with deck.lock: + playing = deck.playing + track = deck.track + pos = deck.position + + if not playing or track is None: return - pos = deck.position - track = deck.track end = min(pos + frames, track.num_samples) n = end - pos if n <= 0: - deck.playing = False + with deck.lock: + deck.playing = False return buf_L[:n] = track.audio[0, pos:end] buf_R[:n] = track.audio[1, pos:end] - deck.position += n - if deck.position >= track.num_samples: - deck.playing = False - deck.position = track.num_samples - 1 + 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): # ── Digital decks ─────────────────────────────────────────────── @@ -360,21 +369,25 @@ class AudioEngine: for cart_key in ('cart1', 'cart2', 'cart3', 'cart4'): 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 - pos = deck.position - track = deck.track end = min(pos + frames, track.num_samples) n = end - pos if n <= 0: - deck.playing = False + with deck.lock: + deck.playing = False 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 + with deck.lock: + deck.position += n + if deck.position >= track.num_samples: + deck.playing = False + deck.position = track.num_samples - 1 # ── External input monitors (deck3, deck4) ───────────────────── 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]))) 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) def _calculate_lufs(self, audio: np.ndarray): diff --git a/cart_widget.py b/cart_widget.py index 67263d4..b552356 100644 --- a/cart_widget.py +++ b/cart_widget.py @@ -234,12 +234,14 @@ class CartSlot(QFrame): def _refresh_display(self): 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.status_label.setText("STOPPED") - if deck.track: + if track: self.elapsed_label.setText(deck.get_elapsed()) self.remaining_label.setText(deck.get_remaining()) diff --git a/debug_audio.py b/debug_audio.py deleted file mode 100644 index ed4e7d0..0000000 --- a/debug_audio.py +++ /dev/null @@ -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() \ No newline at end of file diff --git a/deck_widget.py b/deck_widget.py index 6b41b53..ac0b55a 100644 --- a/deck_widget.py +++ b/deck_widget.py @@ -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, QPainterPath -import numpy as np +from PySide6.QtGui import QFont, QColor import os 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): def __init__(self, parent=None): super().__init__(parent) @@ -186,11 +30,17 @@ class LCDDisplay(QFrame): layout.setContentsMargins(10, 8, 10, 8) layout.setSpacing(4) - 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", 13, 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) @@ -224,14 +74,17 @@ class LCDDisplay(QFrame): 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) @@ -287,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 @@ -295,9 +148,9 @@ class DeckWidget(QWidget): self.current_theme = None self.flash_state = False self._playing_icon = False + self.mode = mode - deck_num = label.split()[-1] - self.deck_label = f"DECK_{int(deck_num):03d}" + self.deck_label = label self._build_ui() self._connect_signals() @@ -339,9 +192,6 @@ class DeckWidget(QWidget): self.lcd = LCDDisplay() root.addWidget(self.lcd) - self.waveform = WaveformWidget() - root.addWidget(self.waveform) - transport = QHBoxLayout() transport.setSpacing(6) @@ -362,6 +212,12 @@ 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'] @@ -381,13 +237,6 @@ class DeckWidget(QWidget): border-radius: 4px; }} """) - self.waveform.setStyleSheet(f""" - QFrame {{ - background-color: {theme['bg']}; - border: 1px solid {border_color}; - border-radius: 3px; - }} - """) def _toggle_flash(self): self.flash_state = not self.flash_state @@ -395,21 +244,39 @@ class DeckWidget(QWidget): def _connect_signals(self): self.btn_play.clicked.connect(self._toggle_play) self.btn_stop.clicked.connect(self._stop_eject) - self.btn_to_start.clicked.connect(self._go_to_start) - self.btn_to_end.clicked.connect(self._go_to_end) - self.btn_back_15.clicked.connect(lambda: self._scrub(-15)) - self.btn_fwd_15.clicked.connect(lambda: self._scrub(15)) + self.lcd.track_btn.clicked.connect(self._edit_track_name) + if self.mode != 'turn': + self.btn_to_start.clicked.connect(self._go_to_start) + self.btn_to_end.clicked.connect(self._go_to_end) + self.btn_back_15.clicked.connect(lambda: self._scrub(-15)) + self.btn_fwd_15.clicked.connect(lambda: self._scrub(15)) + + def _edit_track_name(self): + current = self.lcd.track_btn.text() + if current == "NO TRACK LOADED": + current = "" + name, ok = QInputDialog.getText(self, "Track Name", "Track name:", text=current) + if ok: + if not name.strip(): + name = "NO TRACK LOADED" + self.lcd.update_track(name.strip()) + if self.mode == 'turn': + deck = self.engine.decks[self.deck_key] + if self.mode == 'turn' and name.strip() != "NO TRACK LOADED": + dur, ok2 = QInputDialog.getText(self, "Duration", "Duration (MM:SS):", text="00:00") + if ok2 and ':' in dur: + parts = dur.split(':') + try: + total_sec = int(parts[0]) * 60 + int(parts[1]) + deck = self.engine.decks[self.deck_key] + deck.manual_duration = float(total_sec) + except ValueError: + pass def set_track_name(self, name: str): self.lcd.update_track(name) self.lcd.update_status("LOADED") self.lcd.update_time("00:00", "-00:00") - QTimer.singleShot(200, self._update_waveform) - - def _update_waveform(self): - deck = self.engine.decks[self.deck_key] - if deck.track: - self.waveform.set_waveform(deck.track.audio) def set_queue_name(self, name: str): self.lcd.update_queue(name) @@ -418,6 +285,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 @@ -442,10 +311,13 @@ class DeckWidget(QWidget): def _toggle_play(self): deck = self.engine.decks[self.deck_key] - if deck.track is None: + if deck.track is None and self.mode != 'turn': return if deck.playing: - deck.pause() + if self.mode == 'turn': + deck.playing = False + else: + deck.pause() self.btn_play.setIcon(icon_play("#00ff41")) self._playing_icon = False self.btn_play.setStyleSheet(""" @@ -456,10 +328,13 @@ class DeckWidget(QWidget): } QPushButton:hover { background-color: #2a7b4a; } """) - self.lcd.update_status("PAUSED") + self.lcd.update_status("PAUSED" if self.mode != 'turn' else "STOPPED") self.lcd.set_playing(False) else: - deck.play() + if self.mode == 'turn': + deck.playing = True + else: + deck.play() self.btn_play.setIcon(icon_pause("#ffffff")) self._playing_icon = True self.btn_play.setStyleSheet(""" @@ -472,12 +347,21 @@ class DeckWidget(QWidget): """) self.lcd.update_status("PLAYING") self.lcd.set_playing(True) - if deck.track: + if self.mode == 'turn': + deck.manual_elapsed = 0.0 + name = self.lcd.track_btn.text() + if name and name != "NO TRACK LOADED": + self.track_started.emit(f"[TURN] {name}") + elif deck.track: self.track_started.emit(deck.track.filepath) def _stop_eject(self): deck = self.engine.decks[self.deck_key] - deck.stop() + if self.mode == 'turn': + deck.playing = False + deck.manual_elapsed = 0.0 + else: + deck.stop() self.btn_play.setIcon(icon_play("#00ff41")) self._playing_icon = False self.btn_play.setStyleSheet(""" @@ -492,7 +376,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() @@ -506,17 +389,42 @@ class DeckWidget(QWidget): def _refresh_display(self): deck = self.engine.decks[self.deck_key] - 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") - 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(""" @@ -530,13 +438,13 @@ class DeckWidget(QWidget): self.lcd.update_status("STOPPED") self.lcd.set_playing(False) - if deck.track: + if 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) + position_pct = position / track.num_samples + remaining_sec = track.duration - (position / track.sr) 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 self.header.setStyleSheet(f""" color: {flash_color}; @@ -554,120 +462,5 @@ class DeckWidget(QWidget): """) 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)) - - -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; - }} - """) + if queued: + self.lcd.update_queue(os.path.basename(queued.filepath)) diff --git a/history_widget.py b/history_widget.py index 0cb4916..87a76b4 100644 --- a/history_widget.py +++ b/history_widget.py @@ -1,7 +1,6 @@ # history_widget.py import csv -import io import os import threading diff --git a/icons.py b/icons.py index 9b17fd4..4dc7620 100644 --- a/icons.py +++ b/icons.py @@ -312,39 +312,6 @@ def icon_cog(color: str = "#aaaaaa") -> QIcon: 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) diff --git a/lufs_meter_widget.py b/lufs_meter_widget.py deleted file mode 100644 index 51f606a..0000000 --- a/lufs_meter_widget.py +++ /dev/null @@ -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() \ No newline at end of file diff --git a/main.py b/main.py index 9ee58f5..9c99403 100644 --- a/main.py +++ b/main.py @@ -1,8 +1,12 @@ # main.py import json +import logging import os import sys + +__version__ = "0.5" + from PySide6.QtWidgets import ( QApplication, QMainWindow, QWidget, QFileDialog, QVBoxLayout, QHBoxLayout, QSplitter @@ -11,7 +15,7 @@ from PySide6.QtCore import Qt from PySide6.QtGui import QFont, QColor, QPalette from audio_engine import AudioEngine -from deck_widget import DeckWidget, ExternalDeckWidget +from deck_widget import DeckWidget from cart_widget import CartBankWidget from playlist_widget import PlaylistWidget from midi_engine import MidiEngine @@ -19,6 +23,8 @@ from top_bar_widget import TopBarWidget, THEMES from history_widget import HistoryWidget from mixer_widget import MixerWidget +logger = logging.getLogger(__name__) + class RadioPanelWindow(QMainWindow): def __init__(self): @@ -26,7 +32,7 @@ class RadioPanelWindow(QMainWindow): self.engine = AudioEngine() - self.setWindowTitle("Getting To It") + self.setWindowTitle(f"Getting To It v{__version__}") self.setMinimumSize(1400, 860) self.resize(1920, 1080) self._apply_global_style(THEMES[0]) @@ -48,10 +54,10 @@ class RadioPanelWindow(QMainWindow): # ── Build widgets ───────────────────────────────────────────────── - self.deck1 = DeckWidget("deck1", "DECK 1", "#00b894", self.engine) - self.deck2 = DeckWidget("deck2", "DECK 2", "#6c5ce7", self.engine) - self.deck3 = ExternalDeckWidget("deck3", "DECK 3", "#fdcb6e", self.engine) - self.deck4 = ExternalDeckWidget("deck4", "DECK 4", "#e17055", 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.deck3 = DeckWidget("deck3", "TURN_001", "#fdcb6e", self.engine, mode='turn') + self.deck4 = DeckWidget("deck4", "TURN_002", "#e17055", self.engine, mode='turn') self.cart_bank = CartBankWidget(self.engine) self.cart_bank.setFixedHeight(180) @@ -117,6 +123,8 @@ class RadioPanelWindow(QMainWindow): # ── Wire signals ───────────────────────────────────────────────── self.deck1.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 ────────────────────────────────────────────────────────── self._setup_midi() @@ -233,6 +241,7 @@ class RadioPanelWindow(QMainWindow): self.midi.mixer_solo.connect(self._on_midi_mixer_solo) self.midi.mixer_pfl.connect(self._on_midi_mixer_pfl) 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() @@ -240,7 +249,7 @@ class RadioPanelWindow(QMainWindow): ports = self.midi.list_ports() if ports: - print(f"[MIDI] Available ports: {ports}") + logger.info("Available ports: %s", ports) def _on_midi_play_pause(self, deck: str): if deck == 'deck1': @@ -298,11 +307,14 @@ class RadioPanelWindow(QMainWindow): v = round(max(lo, min(hi, v)), 1) 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): 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() diff --git a/midi_engine.py b/midi_engine.py index ed943d5..0e9439c 100644 --- a/midi_engine.py +++ b/midi_engine.py @@ -19,7 +19,6 @@ NOTE_ON = 0x90 NOTE_OFF = 0x80 CC = 0xB0 PITCH_BEND = 0xE0 -PITCH_BEND = 0xE0 CONFIG_DIR = os.path.expanduser("~/.gti_radiostudio") CONFIG_FILE = os.path.join(CONFIG_DIR, "midi_map.json") @@ -96,6 +95,7 @@ class MidiEngine(QObject): mixer_solo = Signal(int, bool) mixer_pfl = Signal(int, bool) 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) @@ -115,7 +115,6 @@ class MidiEngine(QObject): self._last_cc_values = {} self._channel_volumes = {} self._master_knob_values = {} - self._master_knob_values = {} @property def learn_mode(self) -> bool: @@ -303,6 +302,12 @@ class MidiEngine(QObject): 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): # Hardcoded Numark DJ2Go mapping (legacy) diff --git a/mixer_widget.py b/mixer_widget.py index aafbdad..d56fc7c 100644 --- a/mixer_widget.py +++ b/mixer_widget.py @@ -93,8 +93,8 @@ class ChannelStrip(QFrame): self.channel_index = channel_index self._channel = engine.get_channel(channel_index) - self.setMinimumWidth(90) - self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self.setFixedWidth(90) + self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) self.setStyleSheet(""" ChannelStrip { background-color: #181a1e; @@ -269,32 +269,25 @@ class ChannelStrip(QFrame): def _refresh(self): 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)) - - def apply_theme(self, theme: dict): - pass -class MasterSection(QFrame): +class MasterPreSection(QFrame): def __init__(self, engine, parent=None): super().__init__(parent) self.engine = engine self._master = engine.mixer['master'] - self._knobs = {} - - self.setMinimumWidth(180) - self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self.setFixedWidth(80) + self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) self.setStyleSheet(""" - MasterSection { - background-color: #181a1e; - border: 1px solid #282a30; + MasterPreSection { + background-color: #101216; + border: 1px solid #1e2026; border-radius: 6px; } """) self._build_ui() - self._connect_signals() self.timer = QTimer(self) self.timer.setInterval(30) @@ -303,158 +296,158 @@ class MasterSection(QFrame): def _build_ui(self): root = QVBoxLayout(self) - root.setContentsMargins(8, 6, 8, 6) + root.setContentsMargins(4, 6, 4, 6) root.setSpacing(4) header = QLabel("MASTER") - header.setFont(QFont("Segoe UI", 10, QFont.Bold)) + header.setFont(QFont("Segoe UI", 8, QFont.Bold)) header.setAlignment(Qt.AlignCenter) - header.setStyleSheet("color: #00cc88; background: transparent; border: none;") + header.setStyleSheet("color: #888; background: transparent; border: none;") root.addWidget(header) + meter_row = QHBoxLayout() + meter_row.setSpacing(2) + 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.lufs_label.setFont(QFont("Segoe UI", 9, QFont.Bold)) - self.lufs_label.setAlignment(Qt.AlignCenter) - self.lufs_label.setStyleSheet("color: #00cc88; background: transparent; border: none;") - root.addWidget(self.lufs_label) - - pfl_indicator = QLabel("PFL: —") - self.pfl_indicator = pfl_indicator - pfl_indicator.setFont(QFont("Segoe UI", 8)) - 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; + 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; } - QPushButton:checked { - background-color: #4a1a1a; - color: #ff6666; - border: 1px solid #ff4444; + 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) + + 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() - knobs.setSpacing(4) + 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(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 [ ("THR", "threshold_db", -20, -60, 0, "dB"), ("RAT", "ratio", 4, 1, 20, ":1"), ("ATK", "attack_ms", 5, 0.1, 50, "ms"), ("REL", "release_ms", 100, 10, 1000, "ms"), ("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, - default, lo, hi, suffix) - knobs.addWidget(k) - self._knobs[('compressor', attr)] = (dial, val_label, lo, hi, suffix) - comp_layout.addLayout(knobs) - 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() + obj = self._master.compressor if attr != "ceiling_db" else self._master.limiter + k, dial, val_label = self._make_knob(label, obj, attr, default, lo, hi, suffix) + knob_col.addWidget(k) + self._knobs[('compressor' if attr != 'ceiling_db' else 'limiter', attr)] = (dial, val_label, lo, hi, suffix) + root.addLayout(knob_col) def _make_knob(self, label, obj, attr, default, lo, hi, suffix): frame = QFrame() frame.setStyleSheet("background: transparent; border: none;") - layout = QVBoxLayout(frame) + layout = QHBoxLayout(frame) layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(1) - - 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) + layout.setSpacing(2) dial = QDial() - dial.setFixedSize(44, 44) + dial.setFixedSize(30, 30) dial.setRange(0, 1000) dial.setNotchesVisible(False) dial.setStyleSheet(""" - QDial { - background-color: #121318; - border: 1px solid #2a2c33; - border-radius: 22px; - } + QDial { background-color: #121318; border: 1px solid #2a2c33; border-radius: 15px; } """) def _val_to_pos(v): @@ -465,10 +458,15 @@ class MasterSection(QFrame): 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.setFont(QFont("Segoe UI", 10, QFont.Bold)) - val_label.setAlignment(Qt.AlignCenter) - val_label.setStyleSheet("color: #ccd; background: transparent; border: none;") + val_label.setFont(QFont("Segoe UI", 7)) + val_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + val_label.setStyleSheet("color: #667; background: transparent; border: none;") def _on_dial(v): val = _pos_to_val(v) @@ -488,53 +486,29 @@ class MasterSection(QFrame): val_label.setText(f"{val}{suffix}") dial.valueChanged.connect(_on_dial) - layout.addWidget(dial, alignment=Qt.AlignCenter) + layout.addWidget(dial) + layout.addWidget(lbl, 1) layout.addWidget(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): - self.vu_meter.set_value(self._master.vu_peak) - self.lufs_label.setText(f"{self._master.lufs_current:.1f} LUFS") + 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}") + + self.lufs_value.setText(f"{self._master.lufs_current:.1f}") active_pfl = [ch for ch in self.engine.mixer['channels'] if ch.pfl] if active_pfl: - names = ", ".join(ch.name[:6] for ch in active_pfl) - self.pfl_indicator.setText(f"PFL: {names}") - self.pfl_indicator.setStyleSheet( - "color: #00aaff; background: transparent; border: none;" - ) + 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: —") - self.pfl_indicator.setStyleSheet( - "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 - + self.pfl_indicator.setText("PFL\n—") + self.pfl_indicator.setStyleSheet("color: #556; background: transparent; border: none;") class MixerWidget(QFrame): def __init__(self, engine, parent=None): @@ -597,8 +571,11 @@ class MixerWidget(QFrame): self._strips.append(strip) channels_layout.addWidget(strip) - self.master_section = MasterSection(self.engine) - channels_layout.addWidget(self.master_section) + self.master_pre = MasterPreSection(self.engine) + channels_layout.addWidget(self.master_pre) + + self.effects = EffectSection(self.engine) + channels_layout.addWidget(self.effects) scroll.setWidget(scroll_content) root.addWidget(scroll) @@ -606,7 +583,8 @@ class MixerWidget(QFrame): def apply_theme(self, theme: dict): for strip in self._strips: 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): for strip in self._strips: diff --git a/playlist_widget.py b/playlist_widget.py index c85a7d9..d26edbc 100644 --- a/playlist_widget.py +++ b/playlist_widget.py @@ -6,15 +6,13 @@ from PySide6.QtWidgets import ( QListWidget, QListWidgetItem, QAbstractItemView, QMenu, QSplitter ) -from PySide6.QtCore import Qt, QTimer, QSize, QSize +from PySide6.QtCore import Qt, QTimer, QSize 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 @@ -277,8 +275,7 @@ class BasePlaylistPanel(QWidget): paths.append(item.filepath) return paths - def clear_list(self, lst: QListWidget): - lst.clear() + # ───────────────────────────────────────── diff --git a/top_bar_widget.py b/top_bar_widget.py index d42fe8b..6c21a46 100644 --- a/top_bar_widget.py +++ b/top_bar_widget.py @@ -354,6 +354,7 @@ class SettingsPopup(QFrame): ("Master REL", "master_control", "release_ms"), ("Master MKG", "master_control", "makeup_gain_db"), ("Master CEIL", "master_control", "ceiling_db"), + ("Master Volume", "master_volume", "master"), ] self._learn_btns = {}