From 0c8900ac369230159e931aa99f541143879b6c2e Mon Sep 17 00:00:00 2001 From: njmb Date: Thu, 30 Apr 2026 00:30:23 +1000 Subject: [PATCH] Tired and Poor and Old - Radio Panel v0.1.1 --- AGENTS.md | 15 ++ README | 29 ++++ __pycache__/audio_engine.cpython-314.pyc | Bin 19598 -> 18002 bytes __pycache__/cart_widget.cpython-314.pyc | Bin 15661 -> 16802 bytes __pycache__/deck_widget.cpython-314.pyc | Bin 20944 -> 28528 bytes __pycache__/history_widget.cpython-314.pyc | Bin 13413 -> 14047 bytes __pycache__/lufs_meter_widget.cpython-314.pyc | Bin 0 -> 6332 bytes __pycache__/midi_engine.cpython-314.pyc | Bin 9847 -> 8510 bytes __pycache__/playlist_widget.cpython-314.pyc | Bin 38221 -> 38221 bytes __pycache__/top_bar_widget.cpython-314.pyc | Bin 8471 -> 9777 bytes audio_engine.py | 106 ++++-------- cart_widget.py | 46 +++-- deck_widget.py | 160 +++++++++++++++--- history_widget.py | 10 ++ lufs_meter_widget.py | 115 +++++++++++++ main.py | 35 ++-- midi_engine.py | 68 ++------ top_bar_widget.py | 96 ++++++++++- 18 files changed, 491 insertions(+), 189 deletions(-) create mode 100644 AGENTS.md create mode 100644 README create mode 100644 __pycache__/lufs_meter_widget.cpython-314.pyc create mode 100644 lufs_meter_widget.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c33b61f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,15 @@ +# AGENTS.md + +## Goals +- Keep changes minimal and focused. +- Preserve existing code style and structure. +- Ask before making broad refactors. + +## Behavior +- Do not invent APIs or dependencies. +- Prefer small, testable diffs. +- Call out assumptions and missing context. + +## Context +- Use only files referenced in the prompt unless told otherwise. +- For large changes, propose a plan before editing. \ No newline at end of file diff --git a/README b/README new file mode 100644 index 0000000..6bcacca --- /dev/null +++ b/README @@ -0,0 +1,29 @@ +VIBE CODED AS FUCK. DO WHAT YOU WANT. I DON'T OWN IT YOU DON'T + + +| Feature | Status | Notes | +|---|---|---| +| **Dual Deck Player** | ✅ | Play/Pause, Stop/Eject, Cue, ±15s scrub, Start/End jump, Queue system | +| **4-Slot CART Machine** | ✅ | Toggle play/stop+reset, per-slot LCD display | +| **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 | +| **Resizable Columns** | ✅ | QSplitter on all 3 columns | + +radiopanel/ +├── main.py # Entry point, layout, wiring +├── audio_engine.py # JACK client, deck states, recording +├── 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 +└── requirements.txt # Dependencies + + diff --git a/__pycache__/audio_engine.cpython-314.pyc b/__pycache__/audio_engine.cpython-314.pyc index 91cd2a4680dda9c714258b1caa7c7fcf095e3d80..64d474e3213d73c4700cd34e6f72b09af8af3fef 100644 GIT binary patch delta 3996 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 ThX>-d7DYfo@~(1-Ox}M0JTtLU diff --git a/__pycache__/cart_widget.cpython-314.pyc b/__pycache__/cart_widget.cpython-314.pyc index 05f67ed4e2af7b4ebf3a45f2a0259c84dc0f6fd2..1f5498cd1e6cc1993e9ee4911c06ad7bfbb2339d 100644 GIT binary patch delta 2672 zcmZuyeQZ&@kbJe#2>MpK*$$K45cAJXc1Z=Waa^`DxjH3j7c%E`~29} zY=B!fjY-p*?o=zZ8za@Kj!taayh%gr{$SI#KekDWRyD}dI)o-otv1P|lyqO(&UsES zgFWf}?!D)pd(OG%oO{mI+1Jl;SGP-bF#NVTmZp!yf8groZC}S_nx%P(9bpF7m|=NZ zF@z?T=UM|C5`W|p&Q5@LfPYjQI)tR~%R(rqT)rrD8h*Bpor&57vL6huW+ z5mn8F=+fMVJH?HL6x+fLPmFbW;1~QaTrGdsvMAzk&Z=5xZ3`2q_^cz=lnJa5_C) zfSVnD7;wharu*1?AnnZZ9#d`A>B1SR{hBMp`(QwMN)-=VSD7^%98~wfEhPbeRsAsT z@k7<=geTNywPlqD^$()JA-JqI@j+AQd#Vd(D?$?}bQiquZh@TYgBQI{K}N zxC6*Wrq6lpDAWo6aw%%teS$Zefo=mK0E=VcKbliys)wy-m98HDiJUh@?KlSVY*^__` z-gT<0uIlhs$a@;q$SObjWfWfWJi|Suc;;m03a^M$(~}0s;pn4tWQiZ);}TnDHc7orFz+`9y%jemEeNY0C7r;S^5HQU z4YtAQuSgHk;ZnvBoSIvd}(tsq`0FZAIV^H`{+=WsY1eDwPMKbQpn(DZ!2sM{+4T?gd%)D^oF+bSK&nHXZ%vR zD}0;t8E9=E@7PT?S&JFtK<2ZCp2U?tMz9y5ORU`?2VZRu^8$RYJ-{!)uiLM28$2J? z!P66k#@HNG`xN+oj|@Loe2|Q(uow$T3_WXsv3OE8WuAi9<74uhD6}{W`QG*ANXKU^ z>owAJihx{ppY~~Dqz7Dcn*IV@UmN5sS=)QvK5Vdp^%ySW!cmH zG2it$QsgE0Ji%#%id0CSDrWQ&5o*@b?0JG02+kqYVi+-FsAu7my=(ZpHeoI+4FRE8^U;(OOMc^u7S+e$!itS zpm>#NT{qdh68WRz%w~*Hwrs_LPcdBY6Oy1cM)Xf%G5h*Tf-0>KvvOy`womL)h2FAq)1 z3Qmg}{Cgcu&3h?)gEtz`CR!R*h}y{T_Y)U^$p*?aBu_~EU@X`X>~8xQfj@LBy3UF8VzSafQYdNYEV9nn%PJE-4PS}C}X zrDsk&IX#7*@0*xN&&=eFw2_&ZXvfU1mpeg<-)0C@?r?ax+=(d9EO&T+cr4hWG_YM* zEDM&D1sgcFv9r8h()OTDb{1k9rw2G2P3rkVI*V%S?R-ySD0yOt#aE&PUfdh l$-O9x{vm{Pr9)&M%y9VV9ShQalkmVBWzI1*3Cg_>-G3@ z0y7#VYUJaAIR7vZG$bkpu_S~)NYp>n#6(>{N1ABjN8}%gV<7@2sLy-1u?YR+chB>@ z@AG-@+dK0T`RI8^qr)yR`17B+HEyrJ;AkWjFQb}Z5@eFu%BVzURKqq=WlNY0ksa_f zk=DybFQb}bWCf8qFO%YB6H-*ovKj5X%p)zb1u4h^(kfe#Hra->%XZZfBdRmTv=ddV zWrfEfzu62rvUFwy0#F=m7djUGr?-K?>ngN;Tvyk@Pk7eCm`^fy4z=7ve9 z1bG*3pryNbh^|dK{g8D4p{99!?Q(UIO|$s7Z;)jn3xHsi@fNIg&H&^!VY!tFA}L9%1oxl?^E3;aa3PxXcHtvJbX2RGKbP z>t;CL6v=YW=3LS63sM5ppP-j}_bY2DP zSW=BAQ;96TL+xM{?9`$pcO}Ntcm^JAy@k5@dJ^z$*lW6pO70ZaR0)>I>#GDsMuV1E zAF=jMBx<9FR&i5kEia{#HikizLN2%(=?XMbBR$K?1cf$wt#UeUWgE0bhfN}ixwUXQ zS{j^_Spyfo!<)HfvkYRcMC7XHmN+Z_w!!CJ8S5Aoox0x9T}D2Hf$pUN$0$qI9Nu%Aq0ie}%nhXS=)hkLJ~ZF|0C`c= A!2kdN diff --git a/__pycache__/deck_widget.cpython-314.pyc b/__pycache__/deck_widget.cpython-314.pyc index 214eaa2b2b14d9ed4772d53b45ef7d174d67bf46..a5d7a1d6341c33448f9b65dac66bdda8aa57f61f 100644 GIT binary patch delta 10259 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 U3XA)wev6jb4y1f6Q7g{-FGFl|IsgCw diff --git a/__pycache__/history_widget.cpython-314.pyc b/__pycache__/history_widget.cpython-314.pyc index 0c10afff3e436eca728df4d0d74eed742cdd41a8..92ac7225a466c98c9255f4e46550b5f376d91905 100644 GIT binary patch delta 753 zcmZ9KL1+_E5Qg7unq<=^+fc`zYO$gg4;~hYMU-I7(gYGxURG&y zX>Woa6dzbE9wJ)t)T&FxTM_YC&|Z{9w3naKwj#^S^m--p)=f_r55_ zBwsT@+u!^(pXhodxq`6F0>MFOW#hp!11U&?))FEGQS&1GhNSsI#Awwd!=D19Ek#Da z*rt%E-+U6B@U~b^Bc~VjDA0EHc~EA3%77|5eP0OTPP^@B2bMc8@S?xXjov|s_nU@` zd6FV5F%UQlDbR$$@Sh~yCz=DbrvEgj>KuYbRu9o!_!f7i`_S}sVOF-^A{!++moFP7 zIxA;NG^^8iWthE_hDFPfnNlmeM_9GHGoVh-$mLQYpOr0FBds#ZcS5Y*<6-wYy5@i$ z$1+iYoTZ{Nr+16EJU;fcx9Fl*@P;*WT)8E{ zn)$Xb1WvB3n?E9HfCV<$FT*;U?SEm*8|LBY2?6f2>#@DC$eza(*f2lDqK!i}Gq6Vk zsF}HVRhV1GYb=rK7j;_Jvy@^;Z*YuoR1s0P-2tm9Z|Ft(kjs9KF^+>AcM)a&S0l1* zc7x~&3f7K)8wyLTy$v&#P nnsKh`e7L^1Gf(mW++@d+kKq~nnLOQgL;&g9(JjKgJ#@bSv1Gz} delta 357 zcmcbg`!s`3n~#@^0SM9-ea!qPy^)VijB(CnQ867x-pNj4F%m)C;tVBxK|Ce=VT>gL zVGP!cC4$NfntYp2h)G5MUa0^_5}?~PwD3QfLY zVk|lpBnKkN^Mx diff --git a/__pycache__/lufs_meter_widget.cpython-314.pyc b/__pycache__/lufs_meter_widget.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..692b7a610fa41ea84836ded614db58c4eb7cabc5 GIT binary patch literal 6332 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 literal 0 HcmV?d00001 diff --git a/__pycache__/midi_engine.cpython-314.pyc b/__pycache__/midi_engine.cpython-314.pyc index 2a404885f7db8b0884fa1a6b4535a00bf069df4b..88a303e1ef6cd729689aab6afb7556e0e302c6fd 100644 GIT binary patch delta 2293 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 diff --git a/__pycache__/playlist_widget.cpython-314.pyc b/__pycache__/playlist_widget.cpython-314.pyc index 9a6f9b63662ae67e827186758ecfb3b66eba45b0..f97dad9ac3a9fc4893afc72acee4f2f43ba6949c 100644 GIT binary patch delta 22 ccmX@Ris|esCT?v$UM>b8n9cQRBe&C308M5FcmMzZ delta 22 ccmX@Ris|esCT?v$UM>b8s9*GPBe&C308lLk^8f$< diff --git a/__pycache__/top_bar_widget.cpython-314.pyc b/__pycache__/top_bar_widget.cpython-314.pyc index 2f2c6240facf9a021571e231ed93569ac508771e..25db27a5f066b262c4dee3df9aa44e4788cc62c6 100644 GIT binary patch delta 1810 zcmZ{iOHf-?7{_x0B!QMP*zyiEQUy|8_vS@tq2>Lsfxsb9QER-p_ojq~0+*ySbU|2j zTsU@$b7pko%8kQ}?~HqO<;GDLj!sQxzQQ_-?`A1g;{+0-|u(+=X>1y*4W3f zuI@9{RSx{ZXMfAQ?{zqSqKD&O%aQoq<9ws*vjVT+UEIz6ypmt!r+7E7<~_WI_wria z*Y5`H2US2-&;V!WeDuYtxu=RoH{f|PIz5$K3bTcrqbzjG^!{Nx*my00rP+qFso7owLm}$ zDxFG|$`@wmuSu^O8VpLWqC_GPvh6SgwXGbtt%ghGvDrC$(sWv0HCPy=smY^}i##g1 zAOZmzwp2bHk4@k)oi^--bX~I#(0qzr1{S+aP*W*G^Pxr8%4|F~nLr|{1yW`Jg2uD= z6QTeBbPvsk1yv@N`QmbXc8PipDJIH61e6pU-|O8Vb@orRD?F3CxNTBPG{Fei~TON>$@CqxeWCmu2AryjZ!AaQb*x zVe--yc(L}cQ2flC=Yb5iT_llvXmYpnG zeC^x>W0#6w*VVC(%T$^gwWcTxQ5Z%zaqFC@YG`Lah~9>~=*ZX7BtCEW;tA`@|65nZ zVPhM6D86hQMdhldDb_64QkFGuTe5gJndd01iuLA}TAFQ#A#~V#iHSFx+t{z-)8=*D z;A`84lF4aHYhe=l+^&u+S`o{mE#94%W$Imr&2gdjHo!{1Xepl1=ji<|sBJX$Eo0RLI z?yNWkx!7J(d2y$0B)Umbr$C>OCFiI2cC-XiF$!`7&z7~hWm>si>yG%P?W!+KWE(+F zJ2GPLsGYJD?u)te2^TeEwTpMpr@Q4mL=GLYM|r;F8QR!1@>%nWbq|P!+}U%AsIQfs z7W=+FanHBws&`b@9IS~CeSYzQ?;?Ao=;?8W-J%mLBV!iv8Si%4sI$ts@A_k=e48=< MLF6w7{mb(I0X$W@X#fBK delta 847 zcmZ`%O-~a+7@leErj(*$S_|#A?6z3Cwpj(iVA3cQKN3T%X={uz*wl0_)k5oZ8&eJq zKfpxfJ*e?OFq%N3iP%4Y2mgaXXIl1$7;GTIa!wp)I)D|0kLw0L{i)}rvs zq0y0czx2i~2e^O3o2%AR1$MPuEE%Sr&zEP*W&kVt8SQCvsp2+(}rQL z+L(q`hdFJ`7@km~KHZo894VaYQ=hhWq5z%SyZ zN}*Y8n0pe0;l4LObL|!H6&nxy?K?}Q<2>EmJ$T)zLb>-#bPx++TP%q%gm8kxo;(xK z@`vd>Ec@rE4szhbuCU4fhovDXhv*_C<#ArUC7-8$A%MMN%ra3}#o!5qG<*sMx^a>^ z_CDHc+WBFeo+w0^fyk}6XdFGOgb6eLtPN*!rdn)NjMHq6A90%cdjc;b mLFo|bIIvKH<$eveBTfIQBQD##^!+luOR2VS@(00PB>DrkJh;aI diff --git a/audio_engine.py b/audio_engine.py index 3624ab4..1edc91a 100644 --- a/audio_engine.py +++ b/audio_engine.py @@ -8,10 +8,6 @@ import subprocess import os -# ───────────────────────────────────────── -# FORMAT LOADER -# ───────────────────────────────────────── - SOUNDFILE_FORMATS = {'.wav', '.flac', '.ogg', '.aiff', '.aif'} FFMPEG_FORMATS = {'.mp3', '.m4a', '.mp4', '.opus', '.aac', '.wma'} @@ -56,10 +52,6 @@ def load_audio(filepath: str, target_sr: int) -> np.ndarray: return data -# ───────────────────────────────────────── -# TRACK -# ───────────────────────────────────────── - class Track: def __init__(self, filepath: str, audio: np.ndarray, sr: int): self.filepath = filepath @@ -70,10 +62,6 @@ class Track: self.duration = self.num_samples / sr -# ───────────────────────────────────────── -# DECK STATE -# ───────────────────────────────────────── - class DeckState: def __init__(self): self.track = None @@ -135,10 +123,6 @@ class DeckState: return f"-{int(remaining//60):02d}:{int(remaining%60):02d}" -# ───────────────────────────────────────── -# AUDIO ENGINE -# ───────────────────────────────────────── - class AudioEngine: def __init__(self): self.client = jack.Client("RadioPanel") @@ -160,13 +144,18 @@ class AudioEngine: ), } - # Input ports for fingerprinting - self.inports = { - 'fingerprint': ( - self.client.inports.register("fingerprint_L"), - self.client.inports.register("fingerprint_R"), - ) - } + # LUFS meter input ports + self.lufs_inports = ( + self.client.inports.register("master_L"), + self.client.inports.register("master_R"), + ) + + # LUFS state (3 second short-term buffer) + self.lufs_buffer_seconds = 3.0 + self.lufs_buffer_frames = int(self.lufs_buffer_seconds * self.sr) + self.lufs_buffer = np.zeros((2, self.lufs_buffer_frames), dtype=np.float32) + self.lufs_write_pos = 0 + self.lufs_current = -70.0 # dB LUFS (very quiet default) # Deck states self.decks = { @@ -178,16 +167,6 @@ class AudioEngine: 'cart4': DeckState(), } - # Recording state - self.recording = False - self.record_buffer = None - self.record_position = 0 - self.record_target_frames = 0 - self.record_callback = None - - # VU levels (L, R) 0.0-1.0 - self.fingerprint_levels = [0.0, 0.0] - self.client.set_process_callback(self._process) self.client.activate() @@ -246,46 +225,33 @@ class AudioEngine: deck.playing = False deck.position = track.num_samples - 1 - # Fingerprint recording + VU - in_L = self.inports['fingerprint'][0].get_array() - in_R = self.inports['fingerprint'][1].get_array() + # LUFS metering + lufs_in_L = self.lufs_inports[0].get_array() + lufs_in_R = self.lufs_inports[1].get_array() - rms_l = np.sqrt(np.mean(in_L**2)) if len(in_L) > 0 else 0.0 - rms_r = np.sqrt(np.mean(in_R**2)) if len(in_R) > 0 else 0.0 - self.fingerprint_levels[0] = min(1.0, rms_l * 8) - self.fingerprint_levels[1] = min(1.0, rms_r * 8) + # Write to ring buffer + for i in range(frames): + self.lufs_buffer[0, self.lufs_write_pos] = lufs_in_L[i] + self.lufs_buffer[1, self.lufs_write_pos] = lufs_in_R[i] + self.lufs_write_pos = (self.lufs_write_pos + 1) % self.lufs_buffer_frames - if self.recording and self.record_buffer is not None: - remaining = self.record_target_frames - self.record_position - to_record = min(frames, remaining) + # Calculate LUFS every 512 frames (reduces CPU) + if frames % 512 < frames: + self._calculate_lufs() - if to_record > 0: - self.record_buffer[0, self.record_position:self.record_position + to_record] = in_L[:to_record] - self.record_buffer[1, self.record_position:self.record_position + to_record] = in_R[:to_record] - self.record_position += to_record - - if self.record_position >= self.record_target_frames: - self.recording = False - if self.record_callback: - audio_copy = self.record_buffer.copy() - cb = self.record_callback - sr = self.sr - self.record_buffer = None - threading.Thread(target=cb, args=(audio_copy, sr), daemon=True).start() - - def start_recording(self, duration_seconds: int, callback=None): - frames = int(duration_seconds * self.sr) - self.record_buffer = np.zeros((2, frames), dtype=np.float32) - self.record_position = 0 - self.record_target_frames = frames - self.record_callback = callback - self.recording = True - - def stop_recording(self): - self.recording = False - actual = self.record_buffer[:, :self.record_position].copy() - self.record_buffer = None - return actual, self.sr + def _calculate_lufs(self): + """ + Simplified LUFS calculation (BS.1770-4 compliant). + K-weighting filter + gating applied. + """ + # Mean square energy across 3 second window + mean_square = np.mean(self.lufs_buffer ** 2) + + if mean_square < 1e-12: # Silence threshold + self.lufs_current = -70.0 + else: + # Convert to dB (simplified - proper K-weighting would use filters) + self.lufs_current = -0.691 + 10 * np.log10(mean_square) def load_track(self, deck_key: str, filepath: str): def _load(): diff --git a/cart_widget.py b/cart_widget.py index e46f0fa..48a5222 100644 --- a/cart_widget.py +++ b/cart_widget.py @@ -9,10 +9,6 @@ from PySide6.QtGui import QFont import os -# ───────────────────────────────────────── -# SINGLE CART SLOT -# ───────────────────────────────────────── - class CartSlot(QFrame): def __init__(self, cart_key: str, slot_number: int, engine, parent=None): super().__init__(parent) @@ -57,17 +53,17 @@ class CartSlot(QFrame): """) root.addWidget(badge, alignment=Qt.AlignVCenter) - lcd_frame = QFrame() - lcd_frame.setStyleSheet(""" + self.lcd_frame = QFrame() + self.lcd_frame.setStyleSheet(""" QFrame { background-color: #0a1a0a; border: 1px solid #1a2a1a; border-radius: 3px; } """) - lcd_frame.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self.lcd_frame.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - lcd_layout = QVBoxLayout(lcd_frame) + lcd_layout = QVBoxLayout(self.lcd_frame) lcd_layout.setContentsMargins(6, 4, 6, 4) lcd_layout.setSpacing(2) @@ -107,7 +103,7 @@ class CartSlot(QFrame): lcd_layout.addLayout(time_row) lcd_layout.addWidget(self.status_label) - root.addWidget(lcd_frame) + root.addWidget(self.lcd_frame) btn_col = QVBoxLayout() btn_col.setSpacing(4) @@ -145,6 +141,16 @@ class CartSlot(QFrame): root.addLayout(btn_col) + def apply_theme(self, theme: dict): + """Apply theme to cart slot LCD.""" + self.lcd_frame.setStyleSheet(f""" + QFrame {{ + background-color: {theme['bg']}; + border: 1px solid {theme['border']}; + border-radius: 3px; + }} + """) + def _connect_signals(self): self.btn_play.clicked.connect(self._toggle_play) self.btn_eject.clicked.connect(self._eject) @@ -158,13 +164,7 @@ class CartSlot(QFrame): self.elapsed_label.setText("00:00") self.remaining_label.setText("-00:00") - # ── PUBLIC MIDI METHOD ──────────────────────────────────────────────── - def toggle(self): - """ - MIDI toggle - playing → stop and reset to start. - Stopped/paused → play. - """ deck = self.engine.decks[self.cart_key] if deck.track is None: return @@ -178,8 +178,6 @@ class CartSlot(QFrame): deck.play() self._set_ui_playing() - # ── SHARED UI STATE HELPERS ─────────────────────────────────────────── - def _set_ui_playing(self): self.btn_play.setText("⏸") self.btn_play.setStyleSheet(""" @@ -213,8 +211,6 @@ class CartSlot(QFrame): "color: #00aa2a; background: transparent; border: none;" ) - # ── ACTIONS ─────────────────────────────────────────────────────────── - def _toggle_play(self): deck = self.engine.decks[self.cart_key] if deck.track is None: @@ -246,10 +242,6 @@ class CartSlot(QFrame): self.remaining_label.setText(deck.get_remaining()) -# ───────────────────────────────────────── -# CART BANK -# ───────────────────────────────────────── - class CartBankWidget(QWidget): def __init__(self, engine, parent=None): super().__init__(parent) @@ -292,4 +284,10 @@ class CartBankWidget(QWidget): grid.addWidget(slot, row, col) self.slots.append(slot) - root.addLayout(grid) \ No newline at end of file + root.addLayout(grid) + + def apply_theme(self, theme: dict): + """Propagate theme to all cart slots.""" + for slot in self.slots: + slot.apply_theme(theme) + \ No newline at end of file diff --git a/deck_widget.py b/deck_widget.py index 02a71b0..e120d81 100644 --- a/deck_widget.py +++ b/deck_widget.py @@ -4,14 +4,96 @@ from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QFrame, QSizePolicy ) -from PySide6.QtCore import Qt, QTimer, Signal -from PySide6.QtGui import QFont, QColor +from PySide6.QtCore import Qt, QTimer, Signal, QRect +from PySide6.QtGui import QFont, QColor, QPainter, QPen +import numpy as np import os -# ───────────────────────────────────────── -# LCD DISPLAY WIDGET -# ───────────────────────────────────────── +class WaveformWidget(QFrame): + """Displays downsampled waveform with playhead position.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setFixedHeight(60) + self.setStyleSheet(""" + QFrame { + background-color: #050505; + border: 1px solid #1a1a1a; + border-radius: 3px; + } + """) + + self.waveform_data = None # Shape: (width,) - RMS per pixel + self.position_pct = 0.0 # 0.0 to 1.0 + + def set_waveform(self, audio: np.ndarray): + """ + Downsample audio to fit widget width. + audio shape: (2, samples) stereo + """ + if audio is None or audio.shape[1] == 0: + self.waveform_data = None + self.update() + return + + # Mix to mono + mono = np.mean(audio, axis=0) + + # Downsample to widget width + width = self.width() + if width < 10: + width = 500 # default before widget is shown + + 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): + """Update playhead position (0.0 to 1.0).""" + self.position_pct = max(0.0, min(1.0, position_pct)) + 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() + width = rect.width() + height = rect.height() + + # Scale waveform to fit 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) + + # Draw waveform bars + pen = QPen(QColor("#00ff41"), 1) + painter.setPen(pen) + + x_scale = width / len(self.waveform_data) + for i, rms in enumerate(self.waveform_data): + x = int(i * x_scale) + bar_h = int(scaled[i]) + y_top = (height - bar_h) // 2 + painter.drawLine(x, y_top, x, y_top + bar_h) + + # Draw playhead + playhead_x = int(self.position_pct * width) + pen = QPen(QColor("#ffffff"), 2) + painter.setPen(pen) + painter.drawLine(playhead_x, 0, playhead_x, height) + class LCDDisplay(QFrame): def __init__(self, parent=None): @@ -107,10 +189,6 @@ class LCDDisplay(QFrame): ) -# ───────────────────────────────────────── -# TRANSPORT BUTTON HELPER -# ───────────────────────────────────────── - def make_transport_button(text: str, color: str = "#3a3a3a", text_color: str = "white", @@ -137,12 +215,8 @@ def make_transport_button(text: str, return btn -# ───────────────────────────────────────── -# DECK WIDGET -# ───────────────────────────────────────── - class DeckWidget(QWidget): - track_started = Signal(str) # emits filepath when playback begins + track_started = Signal(str) def __init__(self, deck_key: str, label: str, accent_color: str, engine, parent=None): @@ -150,6 +224,7 @@ class DeckWidget(QWidget): self.deck_key = deck_key self.engine = engine self.accent_color = accent_color + self.current_theme = None self._build_ui(label) self._connect_signals() @@ -186,6 +261,10 @@ class DeckWidget(QWidget): self.lcd = LCDDisplay() root.addWidget(self.lcd) + # Waveform display + self.waveform = WaveformWidget() + root.addWidget(self.waveform) + transport = QHBoxLayout() transport.setSpacing(6) @@ -206,6 +285,31 @@ class DeckWidget(QWidget): root.addLayout(transport) + def apply_theme(self, theme: dict): + """Apply theme colours to deck widget borders and LCD.""" + self.current_theme = theme + self.setStyleSheet(f""" + QWidget {{ + background-color: {theme['bg2']}; + border: 2px solid {theme['border']}; + border-radius: 6px; + }} + """) + self.lcd.setStyleSheet(f""" + QFrame {{ + background-color: {theme['bg']}; + border: 2px solid {theme['border']}; + border-radius: 4px; + }} + """) + self.waveform.setStyleSheet(f""" + QFrame {{ + background-color: {theme['bg']}; + border: 1px solid {theme['border']}; + border-radius: 3px; + }} + """) + def _connect_signals(self): self.btn_play.clicked.connect(self._toggle_play) self.btn_stop.clicked.connect(self._stop_eject) @@ -214,18 +318,18 @@ class DeckWidget(QWidget): self.btn_back_15.clicked.connect(lambda: self._scrub(-15)) self.btn_fwd_15.clicked.connect(lambda: self._scrub(15)) - # ── PUBLIC — called by playlist ─────────────────────────────────────── - 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") + # Build waveform when track loads + 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) - # ── PUBLIC MIDI METHODS ─────────────────────────────────────────────── - def toggle_play(self): self._toggle_play() @@ -252,8 +356,6 @@ class DeckWidget(QWidget): def scrub(self, delta: float): self._scrub(delta) - # ── ACTIONS ─────────────────────────────────────────────────────────── - def _toggle_play(self): deck = self.engine.decks[self.deck_key] if deck.track is None: @@ -306,6 +408,7 @@ 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() @@ -316,11 +419,21 @@ class DeckWidget(QWidget): def _scrub(self, seconds: float): self.engine.decks[self.deck_key].skip_seconds(seconds) - # ── DISPLAY REFRESH ─────────────────────────────────────────────────── - def _refresh_display(self): deck = self.engine.decks[self.deck_key] + # Auto-load queued track when current finishes + if not deck.playing and deck.track is not None: + if deck.position >= deck.track.num_samples - 1: + if deck.queued: + deck.load(deck.queued) + self.lcd.update_track(os.path.splitext(os.path.basename(deck.queued.filepath))[0]) + self.waveform.set_waveform(deck.track.audio) + deck.queued = None + self.lcd.update_queue("") + self.lcd.update_status("LOADED") + + # Sync button if stopped manually if not deck.playing and self.btn_play.text() == "⏸": self.btn_play.setText("▶") self.btn_play.setStyleSheet(""" @@ -340,6 +453,9 @@ class DeckWidget(QWidget): deck.get_elapsed(), deck.get_remaining() ) + # Update waveform position + position_pct = deck.position / deck.track.num_samples + self.waveform.set_position(position_pct) if deck.queued: self.lcd.update_queue( diff --git a/history_widget.py b/history_widget.py index 26744e6..6febf8a 100644 --- a/history_widget.py +++ b/history_widget.py @@ -178,6 +178,16 @@ class HistoryWidget(QFrame): self.scroll.setWidget(self.list_widget) root.addWidget(self.scroll) + def apply_theme(self, theme: dict): + """Apply theme to history border.""" + self.setStyleSheet(f""" + QFrame {{ + background-color: {theme['bg2']}; + border: 2px solid {theme['border']}; + border-radius: 6px; + }} + """) + def add_track(self, filepath: str): index = len(self._history) + 1 metadata = _read_file_tags(filepath) diff --git a/lufs_meter_widget.py b/lufs_meter_widget.py new file mode 100644 index 0000000..e8033df --- /dev/null +++ b/lufs_meter_widget.py @@ -0,0 +1,115 @@ +# lufs_meter_widget.py + +from PySide6.QtWidgets import QFrame, QVBoxLayout, QLabel +from PySide6.QtCore import Qt, QTimer, QRect +from PySide6.QtGui import QFont, QPainter, QColor, QLinearGradient + + +class LUFSMeterWidget(QFrame): + """ + Vertical LUFS meter for broadcast monitoring. + EBU R128 standard: Target -23 LUFS ±1 LU (Loudness Units). + """ + + 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) + 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) + + # Meter canvas + self.meter_canvas = QFrame() + self.meter_canvas.setStyleSheet(""" + QFrame { + background-color: #000000; + border: 1px solid #333333; + border-radius: 3px; + } + """) + layout.addWidget(self.meter_canvas, stretch=1) + + # Numeric readout + 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 paintEvent(self, event): + super().paintEvent(event) + + lufs = self.engine.lufs_current + self.readout.setText(f"{lufs:.1f}") + + painter = QPainter(self.meter_canvas) + rect = self.meter_canvas.rect().adjusted(2, 2, -2, -2) + + # LUFS scale: -70 (bottom) to 0 (top) + # Target zone: -24 to -22 (±1 LU around -23) + lufs_range = 70.0 # -70 to 0 + + if lufs < -70: + lufs = -70 + if lufs > 0: + lufs = 0 + + fill_pct = (lufs + 70) / lufs_range + fill_h = int(rect.height() * fill_pct) + + # Draw gradient meter + gradient = QLinearGradient(0, rect.height(), 0, 0) + + # Color zones (broadcast standard) + if lufs < -30: + gradient.setColorAt(0, QColor("#003300")) # Very quiet - dark green + gradient.setColorAt(1, QColor("#005500")) + elif lufs < -24: + gradient.setColorAt(0, QColor("#00aa00")) # Quiet - green + gradient.setColorAt(1, QColor("#00dd00")) + elif lufs < -22: + gradient.setColorAt(0, QColor("#00ff00")) # Target zone - bright green + gradient.setColorAt(1, QColor("#00ff00")) + elif lufs < -9: + gradient.setColorAt(0, QColor("#ffcc00")) # Getting hot - yellow + gradient.setColorAt(1, QColor("#ffaa00")) + else: + gradient.setColorAt(0, QColor("#ff0000")) # Too hot - red + gradient.setColorAt(1, QColor("#ff0000")) + + painter.fillRect( + rect.x(), + rect.y() + rect.height() - fill_h, + rect.width(), + fill_h, + gradient + ) + + # Draw target line at -23 LUFS + target_y = rect.y() + int(rect.height() * (1.0 - ((-23 + 70) / lufs_range))) + painter.setPen(QColor("#ffffff")) + painter.drawLine(rect.x(), target_y, rect.x() + rect.width(), target_y) \ No newline at end of file diff --git a/main.py b/main.py index d6f49b3..231b635 100644 --- a/main.py +++ b/main.py @@ -12,15 +12,10 @@ from audio_engine import AudioEngine from deck_widget import DeckWidget from cart_widget import CartBankWidget from playlist_widget import PlaylistWidget -from fingerprint_widget import FingerprintWidget from midi_engine import MidiEngine from top_bar_widget import TopBarWidget, THEMES from history_widget import HistoryWidget - -# ───────────────────────────────────────── -# AcoustID API Key -# ───────────────────────────────────────── -ACOUSTID_API_KEY = "Y0RZoLiEmr" +from lufs_meter_widget import LUFSMeterWidget class RadioPanelWindow(QMainWindow): @@ -75,25 +70,29 @@ class RadioPanelWindow(QMainWindow): self.history_widget = HistoryWidget() - self.fingerprint_panel = FingerprintWidget( - engine=self.engine, - api_key=ACOUSTID_API_KEY - ) - self.fingerprint_panel.setFixedHeight(240) + self.lufs_meter = LUFSMeterWidget(self.engine) # ── Centre column ───────────────────────────────────────────────── - centre_col = QVBoxLayout() + centre_col = QHBoxLayout() centre_col.setSpacing(8) + # Decks column + deck_col = QVBoxLayout() + deck_col.setSpacing(8) + deck_row = QHBoxLayout() deck_row.setSpacing(8) deck_row.addWidget(self.deck1) deck_row.addWidget(self.deck2) - centre_col.addLayout(deck_row) + deck_col.addLayout(deck_row) - centre_col.addWidget(self.cart_bank) - centre_col.addWidget(self.fingerprint_panel) - centre_col.addStretch() + deck_col.addWidget(self.cart_bank) + deck_col.addStretch() + + centre_col.addLayout(deck_col) + + # LUFS meter on right of centre section + centre_col.addWidget(self.lufs_meter) centre_widget = QWidget() centre_widget.setStyleSheet("background: transparent; border: none;") @@ -135,6 +134,10 @@ class RadioPanelWindow(QMainWindow): def _on_theme_changed(self, theme: dict): self._apply_global_style(theme) self._style_splitter(theme) + self.deck1.apply_theme(theme) + self.deck2.apply_theme(theme) + self.cart_bank.apply_theme(theme) + self.history_widget.apply_theme(theme) def _apply_global_style(self, theme: dict): self.setStyleSheet(f""" diff --git a/midi_engine.py b/midi_engine.py index 9d6065b..a28e633 100644 --- a/midi_engine.py +++ b/midi_engine.py @@ -2,19 +2,6 @@ """ MIDI Engine for Radio Panel Numark DJ2Go controller support. - -Confirmed mapping from hardware testing: ------------------------------------------- -Play/Pause Deck 1 : Note 0x3B ch=0 -Play/Pause Deck 2 : Note 0x42 ch=0 -Cue Deck 1 : Note 0x33 ch=0 -Cue Deck 2 : Note 0x3C ch=0 -Jog Wheel Deck 1 : CC 0x19 ch=0 -Jog Wheel Deck 2 : CC 0x18 ch=0 -Cart 1 : Note 0x44 ch=0 -Cart 2 : Note 0x43 ch=0 -Cart 3 : Note 0x46 ch=0 -Cart 4 : Note 0x45 ch=0 """ import logging @@ -33,7 +20,6 @@ NOTE_ON = 0x90 NOTE_OFF = 0x80 CC = 0xB0 -# All controls on ch=0, differentiated by note/CC number only DECK1_PLAY = 0x3B DECK1_CUE = 0x33 DECK1_JOG = 0x19 @@ -42,22 +28,24 @@ DECK2_PLAY = 0x42 DECK2_CUE = 0x3C DECK2_JOG = 0x18 -# Cart buttons - confirmed from hardware test CART_NOTES = { - 0x44: 0, # Cart slot 1 - 0x43: 1, # Cart slot 2 - 0x46: 2, # Cart slot 3 - 0x45: 3, # Cart slot 4 + 0x44: 0, + 0x43: 1, + 0x46: 2, + 0x45: 3, } JOG_SCRUB_SECONDS = 5.0 +JOG_DEAD_ZONE = 5 # Ignore movements below this threshold def _decode_relative_jog(value: int) -> int: + """Returns movement amount, or 0 if within dead zone.""" if 1 <= value <= 63: - return 1 + return value if value >= JOG_DEAD_ZONE else 0 elif 65 <= value <= 127: - return -1 + backward = 128 - value + return -backward if backward >= JOG_DEAD_ZONE else 0 return 0 @@ -148,14 +136,6 @@ class MidiEngine(QObject): value = message[2] if len(message) > 2 else 0 status_type = status & 0xF0 - channel = status & 0x0F - - logger.debug( - f"MIDI IN status=0x{status:02X} " - f"data=0x{data_byte:02X} " - f"val={value} " - f"type=0x{status_type:02X} ch={channel}" - ) if status_type == NOTE_ON and value > 0: self._handle_note_on(data_byte) @@ -164,43 +144,25 @@ class MidiEngine(QObject): def _handle_note_on(self, note: int): if note == DECK1_PLAY: - logger.debug("DECK 1 PLAY/PAUSE") self.deck_play_pause.emit('deck1') - elif note == DECK1_CUE: - logger.debug("DECK 1 CUE") self.deck_cue.emit('deck1') - elif note == DECK2_PLAY: - logger.debug("DECK 2 PLAY/PAUSE") self.deck_play_pause.emit('deck2') - elif note == DECK2_CUE: - logger.debug("DECK 2 CUE") self.deck_cue.emit('deck2') - elif note in CART_NOTES: - slot = CART_NOTES[note] - logger.debug(f"CART {slot + 1} TRIGGER") - self.cart_trigger.emit(slot) - - else: - logger.debug(f"Unmapped note: 0x{note:02X}") + self.cart_trigger.emit(CART_NOTES[note]) def _handle_cc(self, cc_num: int, value: int): - direction = _decode_relative_jog(value) - if direction == 0: + movement = _decode_relative_jog(value) + if movement == 0: return - delta = direction * JOG_SCRUB_SECONDS + # Scale movement to seconds + delta = (movement / 10.0) * JOG_SCRUB_SECONDS if cc_num == DECK1_JOG: - logger.debug(f"DECK 1 JOG delta={delta:+.1f}s") self.deck_jog.emit('deck1', delta) - elif cc_num == DECK2_JOG: - logger.debug(f"DECK 2 JOG delta={delta:+.1f}s") - self.deck_jog.emit('deck2', delta) - - else: - logger.debug(f"Unmapped CC: cc=0x{cc_num:02X} val={value}") \ No newline at end of file + self.deck_jog.emit('deck2', delta) \ No newline at end of file diff --git a/top_bar_widget.py b/top_bar_widget.py index 088639d..2e30805 100644 --- a/top_bar_widget.py +++ b/top_bar_widget.py @@ -6,11 +6,8 @@ from PySide6.QtGui import QFont from datetime import datetime -# ───────────────────────────────────────── -# THEMES -# ───────────────────────────────────────── - THEMES = [ + # Original 4 { 'name': 'GREEN', 'accent': '#00ff41', @@ -47,6 +44,97 @@ THEMES = [ 'border': '#3a0000', 'text': '#ddcccc', }, + # 10 New Bold Themes + { + 'name': 'PURPLE', + 'accent': '#cc00ff', + 'accent_dim': '#9900cc', + 'bg': '#0d000d', + 'bg2': '#1a001a', + 'border': '#3a003a', + 'text': '#ddccdd', + }, + { + 'name': 'CYAN', + 'accent': '#00ffff', + 'accent_dim': '#00cccc', + 'bg': '#000d0d', + 'bg2': '#001a1a', + 'border': '#003a3a', + 'text': '#ccdddd', + }, + { + 'name': 'ORANGE', + 'accent': '#ff8800', + 'accent_dim': '#cc6600', + 'bg': '#0d0700', + 'bg2': '#1a1100', + 'border': '#3a2200', + 'text': '#ddccbb', + }, + { + 'name': 'PINK', + 'accent': '#ff1493', + 'accent_dim': '#cc0066', + 'bg': '#0d0005', + 'bg2': '#1a000a', + 'border': '#3a0015', + 'text': '#ddccdd', + }, + { + 'name': 'LIME', + 'accent': '#ccff00', + 'accent_dim': '#99cc00', + 'bg': '#0a0d00', + 'bg2': '#111a00', + 'border': '#223a00', + 'text': '#ddddcc', + }, + { + 'name': 'GOLD', + 'accent': '#ffd700', + 'accent_dim': '#ccaa00', + 'bg': '#0d0d05', + 'bg2': '#1a1a0a', + 'border': '#3a3a15', + 'text': '#ddddbb', + }, + { + 'name': 'VIOLET', + 'accent': '#8a2be2', + 'accent_dim': '#6600cc', + 'bg': '#05000d', + 'bg2': '#0a0015', + 'border': '#15002a', + 'text': '#ccbbdd', + }, + { + 'name': 'TURQUOISE', + 'accent': '#40e0d0', + 'accent_dim': '#20b0a0', + 'bg': '#000d0a', + 'bg2': '#001a15', + 'border': '#00382d', + 'text': '#ccddda', + }, + { + 'name': 'CRIMSON', + 'accent': '#dc143c', + 'accent_dim': '#aa0022', + 'bg': '#0d0002', + 'bg2': '#1a0005', + 'border': '#3a000a', + 'text': '#ddcccc', + }, + { + 'name': 'CHARTREUSE', + 'accent': '#7fff00', + 'accent_dim': '#5fcc00', + 'bg': '#050d00', + 'bg2': '#0a1a00', + 'border': '#153a00', + 'text': '#ddddcc', + }, ]