diff --git a/.gitignore b/.gitignore index 9b5f630..97399b7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ __pycache__/ *.pyc venv/ +obs_overlays/ diff --git a/CAPITOL-IP-PLUS-PLATA2_SF-1024x706-350949840.png b/CAPITOL-IP-PLUS-PLATA2_SF-1024x706-350949840.png deleted file mode 100644 index 1947648..0000000 Binary files a/CAPITOL-IP-PLUS-PLATA2_SF-1024x706-350949840.png and /dev/null differ diff --git a/audio_engine.py b/audio_engine.py index b3265e1..6726ef6 100644 --- a/audio_engine.py +++ b/audio_engine.py @@ -6,7 +6,9 @@ import jack import threading import subprocess import os +import time import pyloudnorm as pyln +from datetime import datetime LUFS_TARGET = -14.0 @@ -195,12 +197,12 @@ class AudioEngine: self.client.outports.register("cart_L"), self.client.outports.register("cart_R"), ), + 'preview': ( + self.client.outports.register("preview_out_L"), + self.client.outports.register("preview_out_R"), + ), } - # LUFS metering input (external processed signal) - self.lufs_in_L = self.client.inports.register("lufs_in_L") - self.lufs_in_R = self.client.inports.register("lufs_in_R") - # Mixer channels: 8 stereo input ports self.mixer = { 'channels': [], @@ -230,6 +232,12 @@ class AudioEngine: self.client.outports.register("studio_out_R"), ) + # Music mix output (aux channels 3-8 only, no mics) + self.music_mix_out = ( + self.client.outports.register("music_mix_L"), + self.client.outports.register("music_mix_R"), + ) + # PFL output ports self.pfl_out = ( self.client.outports.register("pfl_out_L"), @@ -240,13 +248,22 @@ class AudioEngine: self.decks = { 'deck1': DeckState(), 'deck2': DeckState(), - 'deck3': DeckState(), - 'deck4': DeckState(), 'cart1': DeckState(), - 'cart2': DeckState(), - 'cart3': DeckState(), - 'cart4': DeckState(), + 'preview': DeckState(), } + self.cart_queue = [] + self.cart_continue = False + + self.logger = None + self.mic_active_start = None + self._mic_duration = 0.0 + self._mic_was_on = False + + self.recording = False + self._record_buf = [] + self._record_fade_samples = int(self.sr * 0.01) + self._record_fade_in_count = 0 + self._record_fade_out_pending = False self.client.set_process_callback(self._process) self.client.activate() @@ -319,33 +336,40 @@ class AudioEngine: self._fill_port(*self.ports['deck1'], self.decks['deck1'], frames) self._fill_port(*self.ports['deck2'], self.decks['deck2'], frames) - # ── Mix carts ─────────────────────────────────────────────────── + # ── Preview deck ───────────────────────────────────────────── + self._fill_port(*self.ports['preview'], self.decks['preview'], frames) + + # ── Mix cart ─────────────────────────────────────────────────── buf_L = self.ports['cart'][0].get_array() buf_R = self.ports['cart'][1].get_array() buf_L.fill(0.0) buf_R.fill(0.0) - for cart_key in ('cart1', 'cart2', 'cart3', 'cart4'): - deck = self.decks[cart_key] - with deck.lock: - playing = deck.playing - track = deck.track - pos = deck.position - if not playing or track is None: - continue - end = min(pos + frames, track.num_samples) - n = end - pos - if n <= 0: - with deck.lock: - deck.playing = False - continue - buf_L[:n] += track.audio[0, pos:end] - buf_R[:n] += track.audio[1, pos:end] - with deck.lock: - deck.position += n - if deck.position >= track.num_samples: - deck.playing = False - deck.position = track.num_samples - 1 + cart_deck = self.decks['cart1'] + with cart_deck.lock: + playing = cart_deck.playing + track = cart_deck.track + pos = cart_deck.position + if playing and track is not None: + end = min(pos + frames, track.num_samples) + n = end - pos + if n > 0: + buf_L[:n] = track.audio[0, pos:end] + buf_R[:n] = track.audio[1, pos:end] + with cart_deck.lock: + cart_deck.position += max(n, 0) + if cart_deck.position >= track.num_samples: + cart_deck.playing = False + cart_deck.position = track.num_samples - 1 + if self.cart_continue and self.cart_queue: + next_track = self.cart_queue.pop(0) + cart_deck.track = next_track + cart_deck.position = 0 + cart_deck.playing = True + if self.logger: + self.logger.log_cart_trigger(next_track.filename) + elif not self.cart_queue: + self.cart_continue = False # ── Mixer ─────────────────────────────────────────────────────── dt = frames / self.sr @@ -372,11 +396,33 @@ class AudioEngine: pfl_buf_L.fill(0.0) pfl_buf_R.fill(0.0) + music_buf_L = self.music_mix_out[0].get_array() + music_buf_R = self.music_mix_out[1].get_array() + music_buf_L.fill(0.0) + music_buf_R.fill(0.0) + channels = self.mixer['channels'] any_solo = any(ch.solo for ch in channels) any_mic_on = any(ch.mic_on for ch in channels[:2]) any_pfl = any(ch.pfl for ch in channels) + now = time.time() + if any_mic_on and not self._mic_was_on: + self.mic_active_start = now + if self.logger: + active_idx = next((ch.index for ch in channels[:2] if ch.mic_on), 0) + self.logger.log_mic_on(active_idx) + self._mic_duration = 0.0 + elif not any_mic_on and self._mic_was_on and self.mic_active_start is not None: + duration = now - self.mic_active_start + self._mic_duration = 0.0 + self.mic_active_start = None + if self.logger: + self.logger.log_mic_off(0, duration) + elif any_mic_on and self.mic_active_start is not None: + self._mic_duration = now - self.mic_active_start + self._mic_was_on = any_mic_on + for ch in channels: in_L = ch.in_port_L.get_array() in_R = ch.in_port_R.get_array() @@ -405,6 +451,10 @@ class AudioEngine: headphone_buf_L[:] += in_L[:] * factor headphone_buf_R[:] += in_R[:] * factor + if ch.index >= 2: + music_buf_L[:] += in_L[:] * factor + music_buf_R[:] += in_R[:] * factor + peak = max(np.max(np.abs(in_L)), np.max(np.abs(in_R))) * factor ch._vu_smooth = ch._vu_smooth * vu_decay + peak * (1 - vu_decay) ch.vu_peak = ch._vu_smooth @@ -440,14 +490,12 @@ class AudioEngine: master_buf_L[:] *= vol master_buf_R[:] *= vol - # ── LUFS metering from external processing input ──────────────── + # ── LUFS metering from master mix ────────────────────────── master._lufs_accum += frames if master._lufs_accum >= 512: - lufs_dt = master._lufs_accum / self.sr + lufs_dt = 512.0 / self.sr master._lufs_accum -= 512 - lufs_L = self.lufs_in_L.get_array() - lufs_R = self.lufs_in_R.get_array() - ms = (np.mean(lufs_L ** 2) + np.mean(lufs_R ** 2)) / 2.0 + ms = (np.mean(master_buf_L[:] ** 2) + np.mean(master_buf_R[:] ** 2)) / 2.0 if ms < 1e-12: master.lufs_current = -70.0 else: @@ -457,6 +505,28 @@ class AudioEngine: master.lufs_short = master.lufs_short * decay_short + master.lufs_current * (1 - decay_short) master.lufs_long = master.lufs_long * decay_long + master.lufs_current * (1 - decay_long) + # ── Recording ───────────────────────────────────────────────── + if self.recording or self._record_fade_out_pending: + chunk_L = master_buf_L[:].copy() + chunk_R = master_buf_R[:].copy() + + if self._record_fade_in_count < self._record_fade_samples: + n = min(frames, self._record_fade_samples - self._record_fade_in_count) + ramp = np.linspace(0, 1, n) + chunk_L[:n] *= ramp + chunk_R[:n] *= ramp + self._record_fade_in_count += n + + if self._record_fade_out_pending: + fade_total = self._record_fade_samples + ramp = np.linspace(1, 0, frames) + chunk_L *= ramp + chunk_R *= ramp + self._record_buf.append(np.vstack([chunk_L, chunk_R])) + self._finish_recording() + else: + self._record_buf.append(np.vstack([chunk_L, chunk_R])) + def load_track(self, deck_key: str, filepath: str): def _load(): audio = load_audio(filepath, self.sr) @@ -479,6 +549,66 @@ class AudioEngine: self.decks[deck_key].load_queue(track) threading.Thread(target=_load, daemon=True).start() + def add_to_cart_queue(self, filepath: str): + if len(self.cart_queue) >= 3: + return + def _load(): + audio = load_audio(filepath, self.sr) + audio = normalize_lufs(audio, self.sr) + track = Track(filepath, audio, self.sr) + self.cart_queue.append(track) + threading.Thread(target=_load, daemon=True).start() + + def clear_cart_queue(self): + self.cart_queue.clear() + + def log_cart_trigger(self, track_name: str): + if self.logger: + self.logger.log_cart_trigger(track_name) + + def get_mic_duration(self) -> float: + if self.mic_active_start is not None: + return time.time() - self.mic_active_start + return self._mic_duration + + def start_recording(self): + self.recording = True + self._record_buf = [] + self._record_fade_in_count = 0 + self._record_fade_out_pending = False + if self.logger: + self.logger._write_raw({ + "event": "recording_start", + "timestamp": time.time(), + "iso": datetime.now().isoformat(), + }) + + def stop_recording(self): + if not self.recording: + return + self.recording = False + self._record_fade_out_pending = True + + def _finish_recording(self): + self._record_fade_out_pending = False + if not self._record_buf: + return + filename = datetime.now().strftime("RadioPanel_%Y-%m-%d_%H%M%S.wav") + filepath = os.path.join(os.path.expanduser("~"), filename) + try: + full = np.concatenate(self._record_buf, axis=1) + sf.write(filepath, full.T, self.sr, subtype='PCM_16') + if self.logger: + self.logger._write_raw({ + "event": "recording_stop", + "timestamp": time.time(), + "iso": datetime.now().isoformat(), + "filepath": filepath, + "duration_sec": round(full.shape[1] / self.sr, 3), + }) + except Exception: + pass + def shutdown(self): self.client.deactivate() self.client.close() diff --git a/cart_widget.py b/cart_widget.py index b552356..18c2165 100644 --- a/cart_widget.py +++ b/cart_widget.py @@ -2,35 +2,27 @@ from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, - QPushButton, QFrame, QSizePolicy, QGridLayout + QPushButton, QFrame, QSizePolicy, QCheckBox, QScrollArea ) -from PySide6.QtCore import Qt, QTimer, QSize +from PySide6.QtCore import Qt, QTimer, QSize, Signal from PySide6.QtGui import QFont import os -from icons import icon_play, icon_pause, icon_eject +from icons import icon_play, icon_pause, icon_eject, icon_delete -class CartSlot(QFrame): - def __init__(self, cart_key: str, slot_number: int, engine, parent=None): +class CartPlayerWidget(QFrame): + """Single active cart player with visible 3-item autoload queue.""" + cart_triggered = Signal(str) + + def __init__(self, engine, parent=None): super().__init__(parent) - self.cart_key = cart_key - self.slot_number = slot_number - self.engine = engine + self.engine = engine self._playing_icon = False self.setFrameStyle(QFrame.Box | QFrame.Raised) - self.setLineWidth(1) - self.setStyleSheet(""" - QFrame { - background-color: #1e1e1e; - border: 1px solid #444; - border-radius: 5px; - } - """) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - self.setFixedHeight(80) - + self.setFixedHeight(160) self._build_ui() self._connect_signals() @@ -39,23 +31,78 @@ class CartSlot(QFrame): self.timer.timeout.connect(self._refresh_display) self.timer.start() - def _build_ui(self): - root = QHBoxLayout(self) - root.setContentsMargins(6, 4, 6, 4) - root.setSpacing(6) + self.queue_timer = QTimer(self) + self.queue_timer.setInterval(500) + self.queue_timer.timeout.connect(self._refresh_queue) + self.queue_timer.start() - badge = QLabel(str(self.slot_number)) - badge.setFixedSize(22, 22) + def _build_ui(self): + self.setStyleSheet(""" + QFrame { + background-color: #111; + border: 2px solid #cc3300; + border-radius: 6px; + } + """) + root = QHBoxLayout(self) + root.setContentsMargins(10, 8, 10, 8) + root.setSpacing(10) + + # ── Left: Cart player box ────────────────────────────────── + player_frame = QFrame() + player_frame.setStyleSheet(""" + QFrame { + background-color: #1a1a1a; + border: 1px solid #333; + border-radius: 5px; + } + """) + player_frame.setFixedWidth(320) + player_layout = QVBoxLayout(player_frame) + player_layout.setContentsMargins(10, 8, 10, 8) + player_layout.setSpacing(6) + + header_row = QHBoxLayout() + badge = QLabel("1") + badge.setFixedSize(26, 26) badge.setAlignment(Qt.AlignCenter) - badge.setFont(QFont("Arial", 10, QFont.Bold)) + badge.setFont(QFont("Arial", 12, QFont.Bold)) badge.setStyleSheet(""" background-color: #cc3300; color: white; - border-radius: 14px; + border-radius: 15px; border: none; """) - root.addWidget(badge, alignment=Qt.AlignVCenter) + header_row.addWidget(badge) + cart_label = QLabel("CART") + cart_label.setFont(QFont("Arial", 12, QFont.Bold)) + cart_label.setStyleSheet("color: #cc3300; background: transparent; border: none;") + header_row.addWidget(cart_label) + + self.continue_toggle = QCheckBox("CONTINUE") + self.continue_toggle.setFont(QFont("Arial", 9, QFont.Bold)) + self.continue_toggle.setStyleSheet(""" + QCheckBox { + color: #888; background: transparent; border: none; spacing: 4px; + } + QCheckBox::indicator { + width: 16px; height: 16px; + border: 1px solid #555; border-radius: 3px; + background-color: #1a1a1a; + } + QCheckBox::indicator:checked { + background-color: #cc3300; border-color: #ff6644; + } + QCheckBox::indicator:hover { border-color: #888; } + """) + self.continue_toggle.setToolTip("Auto-play next track from queue after current finishes") + self.continue_toggle.stateChanged.connect(self._on_continue_toggled) + header_row.addWidget(self.continue_toggle) + header_row.addStretch() + player_layout.addLayout(header_row) + + # LCD display self.lcd_frame = QFrame() self.lcd_frame.setStyleSheet(""" QFrame { @@ -64,32 +111,26 @@ class CartSlot(QFrame): border-radius: 3px; } """) - self.lcd_frame.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + lcd_layout = QHBoxLayout(self.lcd_frame) + lcd_layout.setContentsMargins(8, 4, 8, 4) + lcd_layout.setSpacing(6) - lcd_layout = QVBoxLayout(self.lcd_frame) - lcd_layout.setContentsMargins(4, 2, 4, 2) - lcd_layout.setSpacing(1) + lcd_left = QVBoxLayout() + lcd_left.setSpacing(2) self.track_label = QLabel("EMPTY") - self.track_label.setFont(QFont("Courier New", 8, QFont.Bold)) - self.track_label.setStyleSheet( - "color: #00ff41; background: transparent; border: none;" - ) - self.track_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + self.track_label.setFont(QFont("Courier New", 9, QFont.Bold)) + self.track_label.setStyleSheet("color: #00ff41; background: transparent; border: none;") + self.track_label.setMinimumWidth(180) time_row = QHBoxLayout() - self.elapsed_label = QLabel("00:00") - self.elapsed_label.setFont(QFont("Courier New", 11, QFont.Bold)) - self.elapsed_label.setStyleSheet( - "color: #00ff41; background: transparent; border: none;" - ) + self.elapsed_label.setFont(QFont("Courier New", 18, QFont.Bold)) + self.elapsed_label.setStyleSheet("color: #00ff41; background: transparent; border: none;") self.remaining_label = QLabel("-00:00") - self.remaining_label.setFont(QFont("Courier New", 8)) - self.remaining_label.setStyleSheet( - "color: #00cc33; background: transparent; border: none;" - ) + self.remaining_label.setFont(QFont("Courier New", 10, QFont.Bold)) + self.remaining_label.setStyleSheet("color: #00cc33; background: transparent; border: none;") self.remaining_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) time_row.addWidget(self.elapsed_label) @@ -98,22 +139,19 @@ class CartSlot(QFrame): self.status_label = QLabel("STOPPED") self.status_label.setFont(QFont("Courier New", 8)) - self.status_label.setStyleSheet( - "color: #008822; background: transparent; border: none;" - ) + self.status_label.setStyleSheet("color: #008822; background: transparent; border: none;") - lcd_layout.addWidget(self.track_label) - lcd_layout.addLayout(time_row) - lcd_layout.addWidget(self.status_label) - - root.addWidget(self.lcd_frame) + lcd_left.addWidget(self.track_label) + lcd_left.addLayout(time_row) + lcd_left.addWidget(self.status_label) + lcd_layout.addLayout(lcd_left) btn_col = QVBoxLayout() btn_col.setSpacing(3) self.btn_play = QPushButton() - self.btn_play.setFixedSize(50, 32) - self.btn_play.setIconSize(QSize(18, 18)) + self.btn_play.setFixedSize(56, 36) + self.btn_play.setIconSize(QSize(22, 22)) self.btn_play.setIcon(icon_play("#00ff41")) self.btn_play.setStyleSheet(""" QPushButton { @@ -126,8 +164,8 @@ class CartSlot(QFrame): """) self.btn_eject = QPushButton() - self.btn_eject.setFixedSize(50, 24) - self.btn_eject.setIconSize(QSize(14, 14)) + self.btn_eject.setFixedSize(56, 28) + self.btn_eject.setIconSize(QSize(16, 16)) self.btn_eject.setIcon(icon_eject("#ff4444")) self.btn_eject.setStyleSheet(""" QPushButton { @@ -141,36 +179,85 @@ class CartSlot(QFrame): btn_col.addWidget(self.btn_play) btn_col.addWidget(self.btn_eject) + lcd_layout.addLayout(btn_col) - root.addLayout(btn_col) + player_layout.addWidget(self.lcd_frame) + root.addWidget(player_frame) - def apply_theme(self, theme: dict): - self.lcd_frame.setStyleSheet(f""" - QFrame {{ - background-color: {theme['bg']}; - border: 1px solid {theme['border']}; - border-radius: 3px; - }} + # ── Right: Queue panel ────────────────────────────────────── + queue_frame = QFrame() + queue_frame.setStyleSheet(""" + QFrame { + background-color: #111; + border: 1px solid #2a2010; + border-radius: 5px; + } """) + queue_frame.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + queue_layout = QVBoxLayout(queue_frame) + queue_layout.setContentsMargins(8, 6, 8, 6) + queue_layout.setSpacing(2) + + queue_header = QHBoxLayout() + ql = QLabel("AUTOLOAD QUEUE") + ql.setFont(QFont("Arial", 9, QFont.Bold)) + ql.setStyleSheet("color: #ffaa00; background: transparent; border: none;") + queue_header.addWidget(ql) + queue_header.addStretch() + + self.queue_count = QLabel("0/3") + self.queue_count.setFont(QFont("Courier New", 9, QFont.Bold)) + self.queue_count.setStyleSheet("color: #555; background: transparent; border: none;") + queue_header.addWidget(self.queue_count) + + clear_btn = QPushButton("CLR") + clear_btn.setFixedSize(40, 20) + clear_btn.setFont(QFont("Arial", 7, QFont.Bold)) + clear_btn.setStyleSheet(""" + QPushButton { + background-color: #3a1a1a; + color: #ff6666; + border: 1px solid #555; + border-radius: 3px; + } + QPushButton:hover { background-color: #5a2a2a; } + """) + clear_btn.clicked.connect(self.engine.clear_cart_queue) + queue_header.addWidget(clear_btn) + queue_layout.addLayout(queue_header) + + self.queue_items = [] + for i in range(3): + item = QLabel(f" — ") + item.setFont(QFont("Courier New", 9)) + item.setStyleSheet("color: #555; background: transparent; border: none; padding: 2px 4px;") + item.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + queue_layout.addWidget(item) + self.queue_items.append(item) + + queue_layout.addStretch() + root.addWidget(queue_frame) def _connect_signals(self): self.btn_play.clicked.connect(self._toggle_play) self.btn_eject.clicked.connect(self._eject) + def _on_continue_toggled(self, state): + self.engine.cart_continue = bool(state) + def load_track(self, filepath: str): - self.engine.load_cart_track(self.cart_key, filepath) - name = os.path.basename(filepath) - short = name[:28] + "..." if len(name) > 28 else name + self.engine.load_cart_track("cart1", filepath) + name = os.path.basename(filepath) + short = name[:32] + "..." if len(name) > 32 else name self.track_label.setText(short) self.status_label.setText("LOADED") self.elapsed_label.setText("00:00") self.remaining_label.setText("-00:00") def toggle(self): - deck = self.engine.decks[self.cart_key] + deck = self.engine.decks["cart1"] if deck.track is None: return - if deck.playing: deck.stop() deck.go_to_start() @@ -179,42 +266,36 @@ class CartSlot(QFrame): else: deck.play() self._set_ui_playing() + self.engine.log_cart_trigger(deck.track.filename) + self.cart_triggered.emit(deck.track.filename) def _set_ui_playing(self): self.btn_play.setIcon(icon_pause("#ffffff")) self._playing_icon = True self.btn_play.setStyleSheet(""" QPushButton { - background-color: #00aa33; - border: 1px solid #00ff41; - border-radius: 3px; + background-color: #00aa33; border: 1px solid #00ff41; border-radius: 3px; } QPushButton:hover { background-color: #00cc44; } """) self.status_label.setText("PLAYING") - self.elapsed_label.setStyleSheet( - "color: #00ff41; background: transparent; border: none;" - ) + self.elapsed_label.setStyleSheet("color: #00ff41; background: transparent; border: none;") def _set_ui_stopped(self): self.btn_play.setIcon(icon_play("#00ff41")) self._playing_icon = False self.btn_play.setStyleSheet(""" QPushButton { - background-color: #1a6b3a; - border: 1px solid #555; - border-radius: 3px; + background-color: #1a6b3a; border: 1px solid #555; border-radius: 3px; } QPushButton:hover { background-color: #2a7b4a; } """) self.elapsed_label.setText("00:00") self.remaining_label.setText("-00:00") - self.elapsed_label.setStyleSheet( - "color: #00aa2a; background: transparent; border: none;" - ) + self.elapsed_label.setStyleSheet("color: #00aa2a; background: transparent; border: none;") def _toggle_play(self): - deck = self.engine.decks[self.cart_key] + deck = self.engine.decks["cart1"] if deck.track is None: return if deck.playing: @@ -224,16 +305,18 @@ class CartSlot(QFrame): else: deck.play() self._set_ui_playing() + self.engine.log_cart_trigger(deck.track.filename) + self.cart_triggered.emit(deck.track.filename) def _eject(self): - deck = self.engine.decks[self.cart_key] + deck = self.engine.decks["cart1"] deck.stop() self._set_ui_stopped() self.track_label.setText("EMPTY") self.status_label.setText("STOPPED") def _refresh_display(self): - deck = self.engine.decks[self.cart_key] + deck = self.engine.decks["cart1"] track = deck.track playing = deck.playing @@ -245,51 +328,37 @@ class CartSlot(QFrame): self.elapsed_label.setText(deck.get_elapsed()) self.remaining_label.setText(deck.get_remaining()) + def _refresh_queue(self): + q = self.engine.cart_queue + qlen = len(q) + self.queue_count.setText(f"{qlen}/3") + if qlen == 0: + self.queue_count.setStyleSheet("color: #555; background: transparent; border: none;") + elif qlen < 3: + self.queue_count.setStyleSheet("color: #ffaa00; background: transparent; border: none;") + else: + self.queue_count.setStyleSheet("color: #ff4444; background: transparent; border: none;") -class CartBankWidget(QWidget): - def __init__(self, engine, parent=None): - super().__init__(parent) - self.engine = engine - self._build_ui() + for i in range(3): + if i < qlen: + name = q[i].filename + name = name[:35] + "..." if len(name) > 35 else name + self.queue_items[i].setText(f" {i+1}. {name}") + self.queue_items[i].setStyleSheet("color: #ffaa00; background: transparent; border: none; padding: 2px 4px;") + else: + self.queue_items[i].setText(" — ") + self.queue_items[i].setStyleSheet("color: #333; background: transparent; border: none; padding: 2px 4px;") - def _build_ui(self): - self.setStyleSheet(""" - QWidget { - background-color: #111; - border: 2px solid #cc3300; - border-radius: 6px; - } - """) - - root = QVBoxLayout(self) - root.setContentsMargins(8, 6, 8, 6) - root.setSpacing(6) - - header = QLabel("CARTS") - header.setFont(QFont("Arial", 11, QFont.Bold)) - header.setStyleSheet(""" - color: #cc3300; - background: transparent; - border: none; - padding: 2px; - """) - header.setAlignment(Qt.AlignCenter) - root.addWidget(header) - - grid = QGridLayout() - grid.setSpacing(6) - - self.slots = [] - for i in range(4): - cart_key = f"cart{i + 1}" - slot = CartSlot(cart_key, i + 1, self.engine) - row = i // 2 - col = i % 2 - grid.addWidget(slot, row, col) - self.slots.append(slot) - - root.addLayout(grid) + if not self.engine.cart_continue and self.continue_toggle.isChecked(): + self.continue_toggle.blockSignals(True) + self.continue_toggle.setChecked(False) + self.continue_toggle.blockSignals(False) def apply_theme(self, theme: dict): - for slot in self.slots: - slot.apply_theme(theme) \ No newline at end of file + self.lcd_frame.setStyleSheet(f""" + QFrame {{ + background-color: {theme['bg']}; + border: 1px solid {theme['border']}; + border-radius: 3px; + }} + """) \ No newline at end of file diff --git a/event_logger.py b/event_logger.py new file mode 100644 index 0000000..04020a3 --- /dev/null +++ b/event_logger.py @@ -0,0 +1,72 @@ +# event_logger.py + +import json +import os +import threading +import time +from datetime import datetime + +LOG_DIR = os.path.expanduser("~/.gti_radiostudio/sessions") + + +class SessionLogger: + def __init__(self): + self._lock = threading.Lock() + self._log_path = None + self._start_new_session() + + def _start_new_session(self): + os.makedirs(LOG_DIR, exist_ok=True) + ts = datetime.now().strftime("%Y-%m-%d_%H%M%S") + self._log_path = os.path.join(LOG_DIR, f"{ts}.jsonl") + self._write_raw({ + "event": "session_start", + "timestamp": time.time(), + "iso": datetime.now().isoformat(), + }) + + def _write_raw(self, entry: dict): + with self._lock: + try: + with open(self._log_path, 'a') as f: + f.write(json.dumps(entry) + "\n") + except Exception: + pass + + def log_cart_trigger(self, track_name: str): + self._write_raw({ + "event": "cart_trigger", + "timestamp": time.time(), + "iso": datetime.now().isoformat(), + "track": track_name, + }) + + def log_mic_on(self, channel: int): + self._write_raw({ + "event": "mic_on", + "timestamp": time.time(), + "iso": datetime.now().isoformat(), + "channel": channel, + }) + + def log_mic_off(self, channel: int, duration_sec: float): + self._write_raw({ + "event": "mic_off", + "timestamp": time.time(), + "iso": datetime.now().isoformat(), + "channel": channel, + "duration_sec": round(duration_sec, 3), + }) + + def log_deck_play(self, deck_key: str, track_name: str): + self._write_raw({ + "event": "deck_play", + "timestamp": time.time(), + "iso": datetime.now().isoformat(), + "deck": deck_key, + "track": track_name, + }) + + @property + def log_path(self): + return self._log_path \ No newline at end of file diff --git a/history_widget.py b/history_widget.py index 87a76b4..b52f472 100644 --- a/history_widget.py +++ b/history_widget.py @@ -3,6 +3,10 @@ import csv import os import threading +import time +from datetime import datetime + +LIVE_CSV_PATH = os.path.expanduser("~/.gti_radiostudio/live_songs.csv") import musicbrainzngs from mutagen import File as MutagenFile @@ -12,8 +16,8 @@ from PySide6.QtWidgets import ( QLineEdit, QFormLayout, QDialogButtonBox, QFileDialog ) -from PySide6.QtCore import Qt, Signal, QSize -from PySide6.QtGui import QFont +from PySide6.QtCore import Qt, QTimer, Signal, QSize +from PySide6.QtGui import QFont, QColor from icons import icon_plus, icon_delete, icon_export @@ -63,12 +67,12 @@ def _lookup_musicbrainz(artist: str, title: str) -> dict: class HistoryEntry(QFrame): deleted = Signal(object) - def __init__(self, index: int, filepath: str, metadata: dict, + def __init__(self, index: int, entry_type: str, entry_data: tuple, parent=None): super().__init__(parent) - self._index = index - self._filepath = filepath - self._metadata = metadata + self._index = index + self._entry_type = entry_type + self._entry_data = entry_data self.setFrameStyle(QFrame.Box) self.setStyleSheet(""" @@ -86,29 +90,17 @@ class HistoryEntry(QFrame): text_col = QVBoxLayout() text_col.setSpacing(2) - artist = metadata.get('artist') or 'Unknown Artist' - title = metadata.get('title') or self._filename(filepath) - - top = QLabel(f"{index}. {artist} — {title}") - top.setFont(QFont("Arial", 11, QFont.Bold)) - top.setStyleSheet( - "color: #00ff41; background: transparent; border: none;" - ) - top.setWordWrap(True) - text_col.addWidget(top) - - album = metadata.get('album') or '' - year = metadata.get('year') or '' - parts = [p for p in [album, year[:4] if year and len(year) >= 4 else year] if p] - - if parts: - sub = QLabel(" " + " · ".join(parts)) - sub.setFont(QFont("Arial", 9)) - sub.setStyleSheet( - "color: #888888; background: transparent; border: none;" - ) - sub.setWordWrap(True) - text_col.addWidget(sub) + if entry_type == 'song': + filepath, metadata = entry_data + artist = metadata.get('artist') or 'Unknown Artist' + title = metadata.get('title') or self._filename(filepath) + self._render_song(text_col, artist, title, metadata) + elif entry_type == 'cart': + track_name, = entry_data + self._render_cart(text_col, track_name) + elif entry_type == 'talk': + duration, = entry_data + self._render_talk(text_col, duration) root.addLayout(text_col, stretch=1) @@ -134,13 +126,57 @@ class HistoryEntry(QFrame): name = os.path.basename(filepath) return os.path.splitext(name)[0] + def _render_song(self, layout, artist: str, title: str, metadata: dict): + top = QLabel(f"[SONG] {artist} — {title}") + top.setFont(QFont("Arial", 11, QFont.Bold)) + top.setStyleSheet("color: #00ff41; background: transparent; border: none;") + top.setWordWrap(True) + layout.addWidget(top) + + album = metadata.get('album') or '' + year = metadata.get('year') or '' + parts = [p for p in [album, year[:4] if year and len(year) >= 4 else year] if p] + if parts: + sub = QLabel(" " + " · ".join(parts)) + sub.setFont(QFont("Arial", 9)) + sub.setStyleSheet("color: #888888; background: transparent; border: none;") + sub.setWordWrap(True) + layout.addWidget(sub) + + def _render_cart(self, layout, track_name: str): + top = QLabel(f"[CART] {track_name}") + top.setFont(QFont("Arial", 11, QFont.Bold)) + top.setStyleSheet("color: #ffaa00; background: transparent; border: none;") + top.setWordWrap(True) + layout.addWidget(top) + + def _render_talk(self, layout, duration: float): + dur_str = f"{int(duration//60):02d}:{int(duration%60):02d}" + top = QLabel(f"[TALK] {dur_str}") + top.setFont(QFont("Arial", 11, QFont.Bold)) + top.setStyleSheet("color: #4488ff; background: transparent; border: none;") + top.setWordWrap(True) + layout.addWidget(top) + @property def filepath(self) -> str: - return self._filepath + if self._entry_type == 'song': + return self._entry_data[0] + return "" @property def metadata(self) -> dict: - return self._metadata + if self._entry_type == 'song': + return self._entry_data[1] + return {} + + @property + def entry_type(self) -> str: + return self._entry_type + + @property + def entry_data(self) -> tuple: + return self._entry_data class AddEntryDialog(QDialog): @@ -205,14 +241,13 @@ class AddEntryDialog(QDialog): } -class HistoryWidget(QFrame): - _metadata_ready = Signal(str, dict) - +class ShowHistoryWidget(QFrame): def __init__(self, parent=None): super().__init__(parent) self._history = [] - self._pending = {} self._entry_index = 0 + self._session_start = None + self._mic_was_on = False self.setFrameStyle(QFrame.Box | QFrame.Raised) self.setStyleSheet(""" @@ -226,13 +261,19 @@ class HistoryWidget(QFrame): self._build_ui() self._metadata_ready.connect(self._on_metadata_ready) + self._mic_timer = QTimer(self) + self._mic_timer.setInterval(500) + self._mic_timer.timeout.connect(self._poll_mic) + self._mic_timer.start() + + _metadata_ready = Signal(str, dict) + def _build_ui(self): root = QVBoxLayout(self) root.setContentsMargins(8, 8, 8, 8) root.setSpacing(6) - # Header - header = QLabel("PLAY HISTORY") + header = QLabel("SHOW LOG") header.setFont(QFont("Arial", 11, QFont.Bold)) header.setStyleSheet( "color: #336633; background: transparent; border: none; padding: 2px;" @@ -240,7 +281,6 @@ class HistoryWidget(QFrame): header.setAlignment(Qt.AlignCenter) root.addWidget(header) - # Toolbar bar = QHBoxLayout() bar.setSpacing(4) @@ -262,10 +302,25 @@ class HistoryWidget(QFrame): self._add_btn.clicked.connect(self._add_manual_entry) bar.addWidget(self._add_btn) + self._reset_btn = QPushButton("RESET LIVE") + self._reset_btn.setFixedHeight(28) + self._reset_btn.setFont(QFont("Arial", 9, QFont.Bold)) + self._reset_btn.setStyleSheet(""" + QPushButton { + background-color: #3a1a1a; + color: #ff6666; + border: 1px solid #553333; + border-radius: 3px; + padding: 0 10px; + } + QPushButton:hover { background-color: #5a2a2a; } + """) + self._reset_btn.clicked.connect(self._reset_live_csv) + bar.addWidget(self._reset_btn) + bar.addStretch() root.addLayout(bar) - # Scroll area self._scroll = QScrollArea() self._scroll.setWidgetResizable(True) self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) @@ -295,7 +350,6 @@ class HistoryWidget(QFrame): self._scroll.setWidget(self._list_widget) root.addWidget(self._scroll) - # Export bar export_bar = QHBoxLayout() export_bar.setSpacing(4) @@ -347,13 +401,28 @@ class HistoryWidget(QFrame): }} """) - def add_track(self, filepath: str): + def _elapsed_str(self, ts: datetime = None) -> str: + now = datetime.now() + if self._session_start is None: + self._session_start = now + delta = (ts or now) - self._session_start + total_s = int(delta.total_seconds()) + return f"{total_s//3600:02d}:{(total_s%3600)//60:02d}:{total_s%60:02d}" + + def add_song(self, filepath: str): idx = self._entry_index + 1 metadata = _read_file_tags(filepath) - self._history.append((filepath, metadata)) + self._history.append(('song', (filepath, metadata))) self._entry_index = idx - entry = self._insert_entry(idx, filepath, metadata) + ts = self._elapsed_str() + self._insert_entry(idx, 'song', (filepath, metadata), ts=ts) + self._append_live_csv( + metadata.get('artist') or 'Unknown Artist', + metadata.get('title') or (os.path.splitext(os.path.basename(filepath))[0] if filepath else 'Unknown'), + metadata.get('album') or '', + metadata.get('year') or '', + ) if not metadata.get('artist') or not metadata.get('album'): self._pending[filepath] = idx @@ -363,6 +432,38 @@ class HistoryWidget(QFrame): daemon=True ).start() + def add_cart(self, track_name: str): + idx = self._entry_index + 1 + self._history.append(('cart', (track_name,))) + self._entry_index = idx + ts = self._elapsed_str() + self._insert_entry(idx, 'cart', (track_name,), ts) + + def add_talk(self, duration: float): + if duration < 1: + return + idx = self._entry_index + 1 + self._history.append(('talk', (duration,))) + self._entry_index = idx + ts = self._elapsed_str() + self._insert_entry(idx, 'talk', (duration,), ts) + + def _poll_mic(self): + if not hasattr(self, '_engine'): + return + channels = self._engine.mixer['channels'] + any_on = any(ch.mic_on for ch in channels[:2]) + if any_on and not self._mic_was_on: + pass + elif not any_on and self._mic_was_on: + dur = self._engine.get_mic_duration() + if dur > 1: + self.add_talk(dur) + self._mic_was_on = any_on + + def set_engine(self, engine): + self._engine = engine + def _add_manual_entry(self): dlg = AddEntryDialog(self) if dlg.exec() != QDialog.Accepted: @@ -379,13 +480,16 @@ class HistoryWidget(QFrame): 'album': None, 'year': None, } - self._history.append(("", metadata)) + self._history.append(('song', ("", metadata))) self._entry_index = idx - self._insert_entry(idx, "", metadata) + ts = self._elapsed_str() + self._insert_entry(idx, 'song', ("", metadata), ts) - def _insert_entry(self, index: int, filepath: str, - metadata: dict) -> HistoryEntry: - entry = HistoryEntry(index, filepath, metadata) + _pending = {} + + def _insert_entry(self, index: int, entry_type: str, + entry_data: tuple, ts: str = "") -> HistoryEntry: + entry = HistoryEntry(index, entry_type, entry_data) entry.deleted.connect(self._delete_entry) self._list_layout.insertWidget(0, entry) self._scroll.verticalScrollBar().setValue(0) @@ -397,6 +501,8 @@ class HistoryWidget(QFrame): self._history.pop(idx) entry.deleteLater() self._rebuild_list() + if entry.entry_type == 'song': + self._rewrite_live_csv() def _enrich_metadata(self, filepath: str, existing: dict): artist = existing.get('artist') @@ -414,9 +520,9 @@ class HistoryWidget(QFrame): self._metadata_ready.emit(filepath, merged) def _on_metadata_ready(self, filepath: str, metadata: dict): - for i, (fp, _) in enumerate(self._history): - if fp == filepath: - self._history[i] = (fp, metadata) + for i, (entry_type, entry_data) in enumerate(self._history): + if entry_type == 'song' and entry_data[0] == filepath: + self._history[i] = ('song', (filepath, metadata)) break self._rebuild_list() @@ -426,52 +532,93 @@ class HistoryWidget(QFrame): if item.widget(): item.widget().deleteLater() - for i, (filepath, metadata) in enumerate(reversed(self._history)): + for i, (entry_type, entry_data) in enumerate(reversed(self._history)): index = len(self._history) - i - entry = HistoryEntry(index, filepath, metadata) + entry = HistoryEntry(index, entry_type, entry_data) entry.deleted.connect(self._delete_entry) self._list_layout.insertWidget( self._list_layout.count() - 1, entry ) def _iter_entries(self): - for i, (fp, md) in enumerate(reversed(self._history)): - idx = len(self._history) - i - artist = md.get('artist') or 'Unknown Artist' - title = md.get('title') or (os.path.splitext(os.path.basename(fp))[0] if fp else 'Unknown') - album = md.get('album') or '' - year = md.get('year') or '' - yield idx, artist, title, album, year + for entry_type, entry_data in reversed(self._history): + if entry_type == 'song': + fp, md = entry_data + artist = md.get('artist') or 'Unknown Artist' + title = md.get('title') or (os.path.splitext(os.path.basename(fp))[0] if fp else 'Unknown') + album = md.get('album') or '' + year = md.get('year') or '' + yield ('SONG', f"{artist} — {title}", album, year) + elif entry_type == 'cart': + track_name, = entry_data + yield ('CART', track_name, '', '') + elif entry_type == 'talk': + duration, = entry_data + dur_str = f"{int(duration//60):02d}:{int(duration%60):02d}" + yield ('TALK', dur_str, '', '') + + def _append_live_csv(self, artist: str, title: str, + album: str, year: str): + os.makedirs(os.path.dirname(LIVE_CSV_PATH), exist_ok=True) + write_header = not os.path.exists(LIVE_CSV_PATH) + with open(LIVE_CSV_PATH, 'a', newline='') as f: + w = csv.writer(f) + if write_header: + w.writerow(["Artist", "Title", "Album", "Year"]) + w.writerow([artist, title, album, year]) + + def _rewrite_live_csv(self): + if not os.path.exists(LIVE_CSV_PATH): + return + with open(LIVE_CSV_PATH, 'w', newline='') as f: + w = csv.writer(f) + w.writerow(["Artist", "Title", "Album", "Year"]) + for entry_type, entry_data in self._history: + if entry_type == 'song': + fp, md = entry_data + artist = md.get('artist') or 'Unknown Artist' + title = md.get('title') or (os.path.splitext(os.path.basename(fp))[0] if fp else 'Unknown') + album = md.get('album') or '' + year = md.get('year') or '' + w.writerow([artist, title, album, year]) + + def _reset_live_csv(self): + try: + if os.path.exists(LIVE_CSV_PATH): + os.remove(LIVE_CSV_PATH) + except Exception: + pass def _export_txt(self): path, _ = QFileDialog.getSaveFileName( - self, "Export History (TXT)", - os.path.expanduser("~/play_history.txt"), + self, "Export Show Log", + os.path.expanduser("~/show_log.txt"), "Text Files (*.txt)" ) if not path: return + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") with open(path, 'w') as f: - for idx, artist, title, album, year in self._iter_entries(): - f.write(f"{idx}. {artist} — {title}") + f.write(f"=== GTI Radio Studio — Show Log ===\n") + f.write(f"Exported: {now}\n\n") + for tag, text, album, year in self._iter_entries(): + f.write(f"[{tag}] {text}") if album or year: - parts = [p for p in [album, - year[:4] if year and len(year) >= 4 else year] - if p] + parts = [p for p in [album, year[:4] if year and len(year) >= 4 else year] if p] if parts: f.write(f"\n {' · '.join(parts)}") f.write("\n") def _export_csv(self): path, _ = QFileDialog.getSaveFileName( - self, "Export History (CSV)", - os.path.expanduser("~/play_history.csv"), + self, "Export Show Log (CSV)", + os.path.expanduser("~/show_log.csv"), "CSV Files (*.csv)" ) if not path: return with open(path, 'w', newline='') as f: w = csv.writer(f) - w.writerow(["#", "Artist", "Title", "Album", "Year"]) - for idx, artist, title, album, year in self._iter_entries(): - w.writerow([idx, artist, title, album, year]) + w.writerow(["#", "Type", "Text", "Album", "Year"]) + for i, (tag, text, album, year) in enumerate(self._iter_entries()): + w.writerow([i + 1, tag, text, album, year]) \ No newline at end of file diff --git a/main.py b/main.py index 6ee68c8..3d9905a 100644 --- a/main.py +++ b/main.py @@ -16,12 +16,13 @@ from PySide6.QtGui import QFont, QColor, QPalette from audio_engine import AudioEngine from deck_widget import DeckWidget -from cart_widget import CartBankWidget +from cart_widget import CartPlayerWidget from playlist_widget import PlaylistWidget from midi_engine import MidiEngine from top_bar_widget import TopBarWidget, THEMES -from history_widget import HistoryWidget -from mixer_widget import MixerWidget +from history_widget import ShowHistoryWidget +from mixer_widget import MixerWidget, MicTimerWidget, LUFSExpandedSection +from event_logger import SessionLogger logger = logging.getLogger(__name__) @@ -31,6 +32,8 @@ class RadioPanelWindow(QMainWindow): super().__init__() self.engine = AudioEngine() + self.session_logger = SessionLogger() + self.engine.logger = self.session_logger self.setWindowTitle(f"Getting To It v{__version__}") self.setMinimumSize(1400, 860) @@ -50,17 +53,19 @@ class RadioPanelWindow(QMainWindow): self.top_bar.ratio_changed.connect(self._apply_ratio) self.top_bar.save_session.connect(self._save_session) self.top_bar.load_session.connect(self._load_session) + self.top_bar.recording_toggled.connect(self._on_recording_toggled) outer.addWidget(self.top_bar) # ── Build widgets ───────────────────────────────────────────────── self.deck1 = DeckWidget("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(150) + self.cart_player = CartPlayerWidget(self.engine) + self.cart_player.setFixedHeight(160) + + self.mic_timer = MicTimerWidget(self.engine) + self.lufs_section = LUFSExpandedSection(self.engine) self.playlist_widget = PlaylistWidget( engine=self.engine, @@ -68,10 +73,11 @@ class RadioPanelWindow(QMainWindow): "deck1": self.deck1, "deck2": self.deck2, }, - cart_slots=self.cart_bank.slots + cart_player=self.cart_player ) - self.history_widget = HistoryWidget() + self.history_widget = ShowHistoryWidget() + self.history_widget.set_engine(self.engine) self.mixer = MixerWidget(self.engine) @@ -79,7 +85,7 @@ class RadioPanelWindow(QMainWindow): centre_col = QVBoxLayout() centre_col.setSpacing(6) - # Top: 4 decks in 2x2 grid + carts + # Top: 2 decks + cart deck_area = QWidget() deck_area.setStyleSheet("background: transparent; border: none;") deck_layout = QVBoxLayout(deck_area) @@ -92,13 +98,15 @@ class RadioPanelWindow(QMainWindow): deck_row1.addWidget(self.deck2) deck_layout.addLayout(deck_row1) - deck_row2 = QHBoxLayout() - deck_row2.setSpacing(4) - deck_row2.addWidget(self.deck3) - deck_row2.addWidget(self.deck4) - deck_layout.addLayout(deck_row2) - - deck_layout.addWidget(self.cart_bank) + cart_row = QHBoxLayout() + cart_row.setSpacing(4) + cart_row.addWidget(self.cart_player) + mic_lufs_row = QHBoxLayout() + mic_lufs_row.setSpacing(4) + mic_lufs_row.addWidget(self.mic_timer, stretch=1) + mic_lufs_row.addWidget(self.lufs_section, stretch=1) + cart_row.addLayout(mic_lufs_row) + deck_layout.addLayout(cart_row) # Mixer fills remaining space below decks centre_col.addWidget(deck_area) @@ -121,10 +129,9 @@ class RadioPanelWindow(QMainWindow): outer.addWidget(self.splitter) # ── Wire signals ───────────────────────────────────────────────── - self.deck1.track_started.connect(self.history_widget.add_track) - self.deck2.track_started.connect(self.history_widget.add_track) - self.deck3.track_started.connect(self.history_widget.add_track) - self.deck4.track_started.connect(self.history_widget.add_track) + self.deck1.track_started.connect(self.history_widget.add_song) + self.deck2.track_started.connect(self.history_widget.add_song) + self.cart_player.cart_triggered.connect(self.history_widget.add_cart) # ── MIDI ────────────────────────────────────────────────────────── self._setup_midi() @@ -148,11 +155,11 @@ class RadioPanelWindow(QMainWindow): self._style_splitter(theme) self.deck1.apply_theme(theme) self.deck2.apply_theme(theme) - self.deck3.apply_theme(theme) - self.deck4.apply_theme(theme) - self.cart_bank.apply_theme(theme) + self.cart_player.apply_theme(theme) self.history_widget.apply_theme(theme) self.mixer.apply_theme(theme) + self.mic_timer.apply_theme(theme) + self.lufs_section.apply_theme(theme) def _apply_ratio(self, w, h): self.resize(w, h) @@ -265,10 +272,6 @@ class RadioPanelWindow(QMainWindow): self.deck1.toggle_play() elif deck == 'deck2': self.deck2.toggle_play() - elif deck == 'deck3': - self.deck3.toggle_play() - elif deck == 'deck4': - self.deck4.toggle_play() def _on_midi_cue(self, deck: str): if deck == 'deck1': @@ -283,7 +286,7 @@ class RadioPanelWindow(QMainWindow): self.deck2.scrub(delta) def _on_midi_cart(self, slot: int): - self.cart_bank.slots[slot].toggle() + self.cart_player.toggle() def _on_midi_mixer_volume(self, channel: int, value: float): self.engine.set_channel_volume(channel, value) @@ -320,6 +323,12 @@ class RadioPanelWindow(QMainWindow): self.engine.shutdown() event.accept() + def _on_recording_toggled(self, checked): + if checked: + self.engine.start_recording() + else: + self.engine.stop_recording() + def main(): app = QApplication(sys.argv) diff --git a/midi_engine.py b/midi_engine.py index 3544703..83eb7eb 100644 --- a/midi_engine.py +++ b/midi_engine.py @@ -354,7 +354,7 @@ class MidiEngine(QObject): def _handle_note_on(self, note: int): # Hardcoded Numark DJ2Go mapping (legacy) - NUMARK_CART = {0x44: 0, 0x43: 1, 0x46: 2, 0x45: 3} + NUMARK_CART = {0x44: 0} if note == 0x3B: self.deck_play_pause.emit('deck1') diff --git a/mixer_widget.py b/mixer_widget.py index 50ba2a3..7389428 100644 --- a/mixer_widget.py +++ b/mixer_widget.py @@ -410,8 +410,8 @@ class LUFSExpandedSection(QFrame): self.engine = engine self._master = engine.mixer['master'] - self.setFixedWidth(200) - self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) + self.setMinimumWidth(160) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.setStyleSheet(""" LUFSExpandedSection { background-color: #181a1e; @@ -484,6 +484,70 @@ class LUFSExpandedSection(QFrame): pass +class MicTimerWidget(QFrame): + def __init__(self, engine, parent=None): + super().__init__(parent) + self.engine = engine + + self.setMinimumWidth(120) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self.setStyleSheet(""" + MicTimerWidget { + background-color: #181a1e; + border: 1px solid #282a30; + border-radius: 6px; + } + """) + + root = QVBoxLayout(self) + root.setContentsMargins(8, 12, 8, 12) + root.setSpacing(4) + + header = QLabel("MIC ON TIME") + header.setFont(QFont("Segoe UI", 9, QFont.Bold)) + header.setAlignment(Qt.AlignCenter) + header.setStyleSheet("color: #556670; background: transparent; border: none;") + root.addWidget(header) + + self.duration_label = QLabel("00:00") + self.duration_label.setFont(QFont("Courier New", 28, QFont.Bold)) + self.duration_label.setAlignment(Qt.AlignCenter) + self.duration_label.setStyleSheet("color: #00cc66; background: transparent; border: none;") + root.addWidget(self.duration_label, stretch=1) + + self.status_label = QLabel("MIC OFF") + self.status_label.setFont(QFont("Segoe UI", 10, QFont.Bold)) + self.status_label.setAlignment(Qt.AlignCenter) + self.status_label.setStyleSheet("color: #444; background: transparent; border: none;") + root.addWidget(self.status_label) + + self.timer = QTimer(self) + self.timer.setInterval(100) + self.timer.timeout.connect(self._refresh) + self.timer.start() + + def _refresh(self): + dur = self.engine.get_mic_duration() + channels = self.engine.mixer['channels'] + any_on = any(ch.mic_on for ch in channels[:2]) + + if any_on: + mins = int(dur // 60) + secs = int(dur % 60) + self.duration_label.setText(f"{mins:02d}:{secs:02d}") + self.duration_label.setStyleSheet("color: #00ff41; background: transparent; border: none;") + self.status_label.setText("MIC LIVE") + self.status_label.setStyleSheet("color: #ff4444; font-weight: bold; background: transparent; border: none;") + else: + self.duration_label.setText("00:00") + self.duration_label.setStyleSheet("color: #333; background: transparent; border: none;") + self.status_label.setText("MIC OFF") + self.status_label.setStyleSheet("color: #444; background: transparent; border: none;") + + def apply_theme(self, theme: dict): + pass + + class MixerWidget(QFrame): def __init__(self, engine, parent=None): super().__init__(parent) @@ -548,9 +612,6 @@ class MixerWidget(QFrame): self.master_pre = MasterPreSection(self.engine) channels_layout.addWidget(self.master_pre) - self.lufs_section = LUFSExpandedSection(self.engine) - channels_layout.addWidget(self.lufs_section) - scroll.setWidget(scroll_content) root.addWidget(scroll) @@ -558,7 +619,6 @@ class MixerWidget(QFrame): for strip in self._strips: strip.apply_theme(theme) self.master_pre.apply_theme(theme) - self.lufs_section.apply_theme(theme) def refresh_strips(self): for strip in self._strips: diff --git a/mixerlayout.jpg b/mixerlayout.jpg deleted file mode 100644 index 0fc77ad..0000000 Binary files a/mixerlayout.jpg and /dev/null differ diff --git a/panel_issues.jpg b/panel_issues.jpg deleted file mode 100644 index 31d7fcd..0000000 Binary files a/panel_issues.jpg and /dev/null differ diff --git a/playlist_widget.py b/playlist_widget.py index d26edbc..59f883a 100644 --- a/playlist_widget.py +++ b/playlist_widget.py @@ -1,23 +1,21 @@ # playlist_widget.py +import json +import os +from datetime import datetime, timedelta + from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QFileDialog, QFrame, QSizePolicy, QListWidget, QListWidgetItem, QAbstractItemView, - QMenu, QSplitter + QMenu, QSplitter, QTextEdit, QLineEdit ) -from PySide6.QtCore import Qt, QTimer, QSize +from PySide6.QtCore import Qt, QTimer, QSize, Signal from PySide6.QtGui import QFont, QColor, QAction -import json -import os from icons import icon_save, icon_load -# ───────────────────────────────────────── -# PLAYLIST ITEM -# ───────────────────────────────────────── - class PlaylistItem(QListWidgetItem): def __init__(self, filepath: str): self.filepath = filepath @@ -29,10 +27,6 @@ class PlaylistItem(QListWidgetItem): self.setToolTip(filepath) -# ───────────────────────────────────────── -# HOUR DISPLAY + CLOCK -# ───────────────────────────────────────── - class HourDisplay(QFrame): def __init__(self, parent=None): super().__init__(parent) @@ -78,10 +72,6 @@ class HourDisplay(QFrame): self.hour_label.setText(f"HOUR {now.strftime('%H:00')}") -# ───────────────────────────────────────── -# DECK SELECTOR -# ───────────────────────────────────────── - class DeckSelector(QFrame): def __init__(self, parent=None): super().__init__(parent) @@ -152,6 +142,11 @@ class DeckSelector(QFrame): return self._selected +# ───────────────────────────────────────── +# SHOW LOG PANEL +# ───────────────────────────────────────── + + # ───────────────────────────────────────── # BASE PLAYLIST PANEL # ───────────────────────────────────────── @@ -275,7 +270,131 @@ class BasePlaylistPanel(QWidget): paths.append(item.filepath) return paths - + +# ───────────────────────────────────────── +# PREVIEW BAR +# ───────────────────────────────────────── + +class PreviewBar(QFrame): + def __init__(self, engine, parent=None): + super().__init__(parent) + self.engine = engine + self.setFixedHeight(40) + self.setStyleSheet(""" + QFrame { + background-color: #0a0a12; + border: 1px solid #222; + border-radius: 3px; + } + """) + + layout = QHBoxLayout(self) + layout.setContentsMargins(6, 2, 6, 2) + layout.setSpacing(6) + + self.track_label = QLabel("NO TRACK") + self.track_label.setFont(QFont("Courier New", 9, QFont.Bold)) + self.track_label.setStyleSheet("color: #4488ff; background: transparent; border: none;") + self.track_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + layout.addWidget(self.track_label, stretch=1) + + self.time_label = QLabel("--:--") + self.time_label.setFont(QFont("Courier New", 12, QFont.Bold)) + self.time_label.setStyleSheet("color: #4488ff; background: transparent; border: none;") + layout.addWidget(self.time_label) + + self.btn_play = QPushButton("\u25B6") + self.btn_play.setFixedSize(30, 26) + self.btn_play.setFont(QFont("Arial", 10, QFont.Bold)) + self.btn_play.setStyleSheet(""" + QPushButton { + background-color: #1a3a5a; color: #4488ff; + border: 1px solid #4488ff; border-radius: 3px; + } + QPushButton:hover { background-color: #2a4a6a; } + QPushButton:checked { background-color: #1a5a3a; color: #00ff41; border-color: #00ff41; } + """) + self.btn_play.setCheckable(True) + layout.addWidget(self.btn_play) + + self.btn_forward = QPushButton("+10s") + self.btn_forward.setFixedSize(40, 26) + self.btn_forward.setFont(QFont("Arial", 8, QFont.Bold)) + self.btn_forward.setStyleSheet(""" + QPushButton { + background-color: #2a1a0a; color: #aa6622; + border: 1px solid #553311; border-radius: 3px; + } + QPushButton:hover { background-color: #3a2a1a; } + """) + layout.addWidget(self.btn_forward) + + self.btn_play.clicked.connect(self._toggle_preview) + self.btn_forward.clicked.connect(self._skip_forward) + + self.timer = QTimer(self) + self.timer.setInterval(100) + self.timer.timeout.connect(self._refresh) + self._playing = False + self._pending_play = False + + def set_playlist(self, playlist): + self._playlist = playlist + + def _selected_item(self): + item = self._playlist.currentItem() + if isinstance(item, PlaylistItem): + return item + return None + + def _toggle_preview(self): + item = self._selected_item() + if item is None: + self.btn_play.setChecked(False) + return + + deck = self.engine.decks['preview'] + if self._playing: + deck.stop() + self._playing = False + self._pending_play = False + self.btn_play.setChecked(False) + self.track_label.setText("NO TRACK") + self.time_label.setText("--:--") + else: + self.engine.load_track('preview', item.filepath) + self.track_label.setText(item.text()[:30]) + self.time_label.setText("00:00") + self._pending_play = True + + def _skip_forward(self): + if not self._playing: + return + self.engine.decks['preview'].skip_seconds(10) + + def _refresh(self): + deck = self.engine.decks['preview'] + with deck.lock: + playing = deck.playing + track = deck.track + pos = deck.position + + if self._pending_play and track is not None: + self._pending_play = False + deck.play() + self._playing = True + + if playing and track: + s = pos / track.sr + self.time_label.setText(f"{int(s//60):02d}:{int(s%60):02d}") + if pos >= track.num_samples - 1: + self._playing = False + self.btn_play.setChecked(False) + self.track_label.setText("NO TRACK") + self.time_label.setText("--:--") + elif not playing and self._playing: + self._playing = False + self.btn_play.setChecked(False) # ───────────────────────────────────────── @@ -310,15 +429,35 @@ class TrackPlaylistPanel(BasePlaylistPanel): header.setAlignment(Qt.AlignCenter) root.addWidget(header) + self.preview_bar = PreviewBar(self.engine) + root.addWidget(self.preview_bar) + self.hour_display = HourDisplay() root.addWidget(self.hour_display) self.deck_selector = DeckSelector() root.addWidget(self.deck_selector) + self.search_box = QLineEdit() + self.search_box.setPlaceholderText("search...") + self.search_box.setFont(QFont("Courier New", 9)) + self.search_box.setStyleSheet(""" + QLineEdit { + background-color: #0d0d0d; + color: #00ff41; + border: 1px solid #333; + border-radius: 3px; + padding: 3px 6px; + } + QLineEdit:focus { border-color: #00ff41; } + """) + self.search_box.textChanged.connect(self._filter_list) + root.addWidget(self.search_box) + self.playlist = self._make_list() self.playlist.itemDoubleClicked.connect(self._send_to_deck) self.playlist.customContextMenuRequested.connect(self._context_menu) + self.preview_bar.set_playlist(self.playlist) root.addWidget(self.playlist) row1 = QHBoxLayout() @@ -364,6 +503,13 @@ class TrackPlaylistPanel(BasePlaylistPanel): self.btn_remove.clicked.connect(self._remove_selected) self.btn_clear.clicked.connect(self._clear_all) + def _filter_list(self): + query = self.search_box.text().lower() + for i in range(self.playlist.count()): + item = self.playlist.item(i) + if isinstance(item, PlaylistItem): + item.setHidden(bool(query and query not in item.filename.lower())) + def _send_to_deck(self, item=None): if item is None: item = self.playlist.currentItem() @@ -456,10 +602,10 @@ class TrackPlaylistPanel(BasePlaylistPanel): # ───────────────────────────────────────── class CartPlaylistPanel(BasePlaylistPanel): - def __init__(self, engine, cart_slots: list, parent=None): + def __init__(self, engine, cart_player, parent=None): super().__init__(parent) - self.engine = engine - self.cart_slots = cart_slots + self.engine = engine + self.cart_player = cart_player self._build_ui() def _build_ui(self): @@ -483,8 +629,24 @@ class CartPlaylistPanel(BasePlaylistPanel): header.setAlignment(Qt.AlignCenter) root.addWidget(header) + self.search_box = QLineEdit() + self.search_box.setPlaceholderText("search...") + self.search_box.setFont(QFont("Courier New", 9)) + self.search_box.setStyleSheet(""" + QLineEdit { + background-color: #0d0d0d; + color: #ffaa00; + border: 1px solid #333; + border-radius: 3px; + padding: 3px 6px; + } + QLineEdit:focus { border-color: #ffaa00; } + """) + self.search_box.textChanged.connect(self._filter_list) + root.addWidget(self.search_box) + self.playlist = self._make_list() - self.playlist.itemDoubleClicked.connect(self._send_to_next_free_cart) + self.playlist.itemDoubleClicked.connect(self._send_to_cart) self.playlist.customContextMenuRequested.connect(self._context_menu) root.addWidget(self.playlist) @@ -498,11 +660,19 @@ class CartPlaylistPanel(BasePlaylistPanel): row2 = QHBoxLayout() row2.setSpacing(4) + self.btn_send_cart = self._make_btn("→ LOAD CART", "#3a5a1a") + self.btn_send_queue = self._make_btn("→ QUEUE", "#4a3a00") + row2.addWidget(self.btn_send_cart) + row2.addWidget(self.btn_send_queue) + root.addLayout(row2) + + row3 = QHBoxLayout() + row3.setSpacing(4) self.btn_remove = self._make_btn("REMOVE", "#5a1a1a") self.btn_clear = self._make_btn("CLEAR ALL", "#3a1a1a") - row2.addWidget(self.btn_remove) - row2.addWidget(self.btn_clear) - root.addLayout(row2) + row3.addWidget(self.btn_remove) + row3.addWidget(self.btn_clear) + root.addLayout(row3) self.count_label = QLabel("0 tracks") self.count_label.setFont(QFont("Courier New", 9)) @@ -518,27 +688,33 @@ class CartPlaylistPanel(BasePlaylistPanel): self.btn_add_folder.clicked.connect( lambda: self._add_folder_to(self.playlist) ) + self.btn_send_cart.clicked.connect(self._send_to_cart) + self.btn_send_queue.clicked.connect(self._send_to_cart_queue) self.btn_remove.clicked.connect(self._remove_selected) self.btn_clear.clicked.connect(self._clear_all) - def _send_to_cart(self, item: PlaylistItem, cart_index: int): - if not isinstance(item, PlaylistItem): - return - slot = self.cart_slots[cart_index] - slot.load_track(item.filepath) - self._highlight(item, active=True) + def _filter_list(self): + query = self.search_box.text().lower() + for i in range(self.playlist.count()): + item = self.playlist.item(i) + if isinstance(item, PlaylistItem): + item.setHidden(bool(query and query not in item.filename.lower())) - def _send_to_next_free_cart(self, item=None): + def _send_to_cart(self, item=None): if item is None: item = self.playlist.currentItem() if not isinstance(item, PlaylistItem): return - for i in range(4): - cart_key = f"cart{i + 1}" - if self.engine.decks[cart_key].track is None: - self._send_to_cart(item, i) - return - self._send_to_cart(item, 0) + self.cart_player.load_track(item.filepath) + self._highlight(item, active=True) + + def _send_to_cart_queue(self, item=None): + if item is None: + item = self.playlist.currentItem() + if not isinstance(item, PlaylistItem): + return + self.engine.add_to_cart_queue(item.filepath) + self._highlight(item, queued=True) def _remove_selected(self): row = self.playlist.currentRow() @@ -565,12 +741,13 @@ class CartPlaylistPanel(BasePlaylistPanel): QMenu::item:selected { background-color: #1a3a5a; } """) - for i in range(4): - action = QAction(f"Load → Cart {i + 1}", self) - action.triggered.connect( - lambda checked, idx=i: self._send_to_cart(item, idx) - ) - menu.addAction(action) + a1 = QAction("Load → Cart", self) + a1.triggered.connect(lambda: self._send_to_cart(item)) + menu.addAction(a1) + + a2 = QAction("Queue → Cart", self) + a2.triggered.connect(lambda: self._send_to_cart_queue(item)) + menu.addAction(a2) menu.addSeparator() ar = QAction("Remove", self) @@ -590,7 +767,7 @@ class PlaylistWidget(QWidget): PLAYLIST_JSON_FILTER = "JSON Files (*.json)" def __init__(self, engine, deck_widgets: dict, - cart_slots: list, parent=None): + cart_player, parent=None): super().__init__(parent) self.engine = engine @@ -601,7 +778,6 @@ class PlaylistWidget(QWidget): root.setContentsMargins(0, 0, 0, 0) root.setSpacing(4) - # Toolbar bar = QHBoxLayout() bar.setSpacing(4) @@ -653,11 +829,11 @@ class PlaylistWidget(QWidget): """) self.track_panel = TrackPlaylistPanel(engine, deck_widgets) - self.cart_panel = CartPlaylistPanel(engine, cart_slots) + self.cart_panel = CartPlaylistPanel(engine, cart_player) splitter.addWidget(self.track_panel) splitter.addWidget(self.cart_panel) - splitter.setSizes([600, 400]) + splitter.setSizes([400, 400]) root.addWidget(splitter) diff --git a/top_bar_widget.py b/top_bar_widget.py index 4a59a46..f47d02e 100644 --- a/top_bar_widget.py +++ b/top_bar_widget.py @@ -47,6 +47,7 @@ class TopBarWidget(QFrame): ratio_changed = Signal(int, int) save_session = Signal() load_session = Signal() + recording_toggled = Signal(bool) def __init__(self, parent=None): super().__init__(parent) @@ -98,6 +99,32 @@ class TopBarWidget(QFrame): self.station_label.setStyleSheet("color: #555555; background: transparent; border: none;") layout.addWidget(self.station_label) + layout.addStretch() + + self.record_btn = QPushButton("● REC") + self.record_btn.setCheckable(True) + self.record_btn.setFixedSize(70, 30) + self.record_btn.setFont(QFont("Arial", 8, QFont.Bold)) + self.record_btn.setToolTip("Record master output to WAV") + self.record_btn.setStyleSheet(""" + QPushButton { + background-color: #2a1a1a; + color: #664444; + border: 1px solid #442222; + border-radius: 3px; + padding: 0 6px; + } + QPushButton:checked { + background-color: #4a1a1a; + color: #ff4444; + border: 1px solid #ff4444; + } + QPushButton:hover { color: #ff8888; } + QPushButton:checked:hover { background-color: #5a2020; } + """) + self.record_btn.toggled.connect(self._on_record_toggled) + layout.addWidget(self.record_btn) + self.settings_btn = QPushButton() self.settings_btn.setFixedSize(24, 24) self.settings_btn.setIconSize(QSize(14, 14)) @@ -132,6 +159,9 @@ class TopBarWidget(QFrame): # ── Settings popup ───────────────────────────────────────────── + def _on_record_toggled(self, checked): + self.recording_toggled.emit(checked) + def _toggle_settings_popup(self): self._popup = SettingsPopup(self._midi_engine, self) self._popup.save_session.connect(self.save_session.emit) @@ -346,12 +376,7 @@ class SettingsPopup(QFrame): ("Deck 1 Cue", "deck_cue", "deck1"), ("Deck 2 Play/Pause", "deck_play_pause", "deck2"), ("Deck 2 Cue", "deck_cue", "deck2"), - ("Deck 3 Play/Pause", "deck_play_pause", "deck3"), - ("Deck 4 Play/Pause", "deck_play_pause", "deck4"), - ("Cart 1", "cart_trigger", 0), - ("Cart 2", "cart_trigger", 1), - ("Cart 3", "cart_trigger", 2), - ("Cart 4", "cart_trigger", 3), + ("Cart Trigger", "cart_trigger", 0), ("Ch 1 Volume", "mixer_volume", 0), ("Ch 2 Volume", "mixer_volume", 1), ("Ch 3 Volume", "mixer_volume", 2),