- BRIDGE-SIGNALS: yeni "Transitions & Discord" slot bolumu (setCrossfade/setGapless/ setDiscordRpc/setDiscordClientId), getState semasina crossfade/gapless/discord*/ discordAvail; YANLIS "setLanguage yok" notu duzeltildi (v1.1.0'da eklendi) - ARCHITECTURE: 6b bolumu — siradaki parcayi onden coz, callback'te es-guclu harman/ gapless terfi, reload'suz UI guncelleme, Discord RPC tasarimi
18 KiB
Just Music Premium — Architecture
A Windows desktop music player. The UI is a web page (HTML/CSS/JS) rendered by QtWebEngine; all real logic — audio, downloads, library, karaoke — is native Python. There is no HTTP server and no TCP port: everything runs in one process, and the web layer talks to Python over a QWebChannel bridge.
This document is a fast context rebuild for a future developer or AI. Every claim
is cited to a file (and line where useful). Paths are relative to the repo root
C:\Users\bkese\Desktop\jasb31.
1. Big picture
+-----------------------------------------------------------+
| QMainWindow (main.py) |
| +-----------------------------------------------------+ |
| | QWebEngineView -> justmusic/web/index.html | |
| | HTML / app.css / app.js (visual layer ONLY) | |
| +--------------------------^--------------------------+ |
| QWebChannel | (JS <-> Python, in-process)|
| +--------------------------v--------------------------+ |
| | Bridge (justmusic/bridge.py) — the controller | |
| | owns: DspEngine, Library, worker QThreads | |
| +-----------------------------------------------------+ |
| app:// URL scheme (justmusic/scheme.py) serves web |
| assets + cached cover images. No server, no port. |
+-----------------------------------------------------------+
- Entry point:
main.py. It registers the customapp://URL scheme beforeQApplicationis created — this ordering is mandatory for QtWebEngine (main.py:13-26,_register_scheme()then module-level call at line 26;QApplication(sys.argv)only atmain.py:64). It then builds the window, installs the scheme handler, creates theBridge, wires aQWebChannelwith the bridge registered as the object"bridge"(main.py:88-92), and loadsapp://app/index.html(main.py:98). - The bridge (
justmusic/bridge.py, classBridge(QObject)at line 256) is the application controller. UI actions call its@pyqtSlotmethods; Python pushes state back viapyqtSignals (declaredbridge.py:257-278). The web side connects to it inapp.js:1256-1257(new QWebChannel(qt.webChannelTransport, …); bridge = channel.objects.bridge;). - The
app://scheme (justmusic/scheme.py) is aQWebEngineUrlSchemeHandlerthat reads files off disk and replies in-process:app://app/<path>->justmusic/web/<path>, andapp://app/cover/<hash>.jpg->~/Music/JustMusic/covers/<hash>.jpg(scheme.py:52-74). It is registered as a secure scheme (not a LocalScheme) so the page counts as a secure origin and can load remote cover thumbnails from ytimg (main.py:17-22). - State sync: a 60 ms
QTimer(POLL_MS = 60,bridge.py:24) polls the engine inBridge._on_poll(bridge.py:1382) and emitsplayingChanged,spectrumSignal, andpositionChanged(pos, dur)(bridge.py:1385-1391). The engine itself is Qt-independent; the UI polls it.
2. Why there is no server (non-obvious)
An earlier version of this app used pywebview, which served the web UI from a
local HTTP server on port 8998. That produced a port-collision / infinite-lock
bug. The current design serves every asset in-process through the app:// scheme,
so that entire class of bug is structurally impossible — there is no socket to
collide on and nothing to deadlock. This is documented in the project README
(README.md:38-40, "Sunucu / port YOK … Eski pywebview sürümündeki port
çakışması / sonsuz kilitlenme burada imkânsız") and scheme.py:1-6. Residual
traces of the old pywebview data model are still migrated on load
(library.py:50-59, old playCount / raw_path fields).
3. Audio is native, not the browser
QtWebEngine is used for pixels only. All sound goes through the native DSP
engine in justmusic/engine.py (class DspEngine, line 247).
- Decode:
decode_audio()shells out to the embedded ffmpeg (config.FFMPEG) to turn any input format into rawf32lestereo PCM, then reshapes it to a NumPy(N, 2)float32 buffer at 44.1 kHz (engine.py:67-83). ffmpeg is run withCREATE_NO_WINDOWso no console flashes in the windowed exe (engine.py:27,74-77). - Playback: a
sounddevice(PortAudio)OutputStreamwith a callback (engine.py:298-312, callback at504). Decoding happens on a background thread (_decode_worker,engine.py:337); a generation counter_gendiscards stale decodes when the user skips quickly (engine.py:325,344-345). - Real-time chain (per audio block, in
_callback,engine.py:504-569): variable-speed resampling by linear interpolation over a fractional sample index (engine.py:520-526) -> preamp -> 10-band EQ (RBJ biquads viascipy.signal.sosfilt,engine.py:543-544) -> bass low-shelf (engine.py:546-548) -> karaoke center-channel cancel (engine.py:550-554) -> echo -> reverb (multi-tap) -> spatial 8D panning -> volume -> optional soundscape mix -> clip to [-1, 1] (engine.py:558-569). Effect implementations:_apply_echo/_apply_reverb/_apply_spatial(engine.py:591-630). - Presets: 30 EQ presets + an auto-EQ analyzer. Presets live in
eqpresets.py:11-42(PRESETS, 30 entries); "Oto" is computed from the track's average spectrum inengine.auto_eq_gains(engine.py:86-116) and applied viaBridge.autoEq(bridge.py:815).
This native pipeline is the whole reason the feature set exists: QMediaPlayer
could not provide a custom per-block DSP chain, so 30 EQ presets plus
bass/echo/reverb/8D/karaoke are only possible because audio is handled in NumPy,
not the browser. The engine also derives extras from the same buffer: waveform
peaks, mood, BPM, loudness normalization, and a small spectrogram
(engine.py:119-244).
4. Clip (video) mode — the single-clock design (very non-obvious)
The most subtle part of the app. Read this before touching clip code.
The constraint. QtWebEngine cannot decode H.264/AAC, and YouTube no longer
serves a muxed WebM stream. So a <video> element cannot both show the picture
and carry synced audio. (VideoStreamThread docstring, bridge.py:127-133;
format string forces WebM VP9 video + WebM/Opus audio, bridge.py:150-153.)
The solution: one clock, one audio source. The clip's picture is the VP9
video played muted; the only audio is the DSP engine's already-loaded mp3.
The muted <video> is slaved to the engine:
Bridge.playVideo(bridge.py:1010-1032) does not stop the engine. It records the engine's current position as the video start offset (_video_handoff,bridge.py:1026) and kicks offVideoStreamThreadto resolve stream URLs with yt-dlp (no download).- When URLs arrive,
_on_video_readyforcesdata["muted"] = Trueand passes the start second (bridge.py:1034-1041). Audio always comes from the engine. - In the web layer,
buildClipMedia(app.js:1085-1108) creates a muted<video>(v.muted=true; v.volume=0), seeks it to the handoff second, mirrors the engine's play state, and matchesv.playbackRateto the DSP speed (app.js:1092-1106). - The engine is the single clock. On every
positionChangedtick the video is re-synced and lyrics advance:bridge.positionChanged.connect((pos,dur)=>{ … if(clip){syncClipVideo(pos); clipSyncLyrics(pos);} })(app.js:714).syncClipVideonudgescurrentTimeonly when drift exceeds 0.35 s (app.js:1110-1114);clipMirrorPlaymirrors play/pause (app.js:1116-1119).
Result: no double playback, and every DSP effect (EQ, speed, karaoke, reverb…) applies to the clip too because the clip's sound is the engine.
The crash guard. stopClip(resume) (app.js:1120-1131) must strip the
src and remove the <video> from the DOM (v.pause(); v.removeAttribute('src'); v.load(); then c.host.remove()). A merely-paused <video> left live in the DOM
keeps network traffic and crashes WebEngine on window close — this is called
out in the code comment at app.js:1124-1126.
Persistent mini-player. The video/lyrics live inside a single persistent
clip.host element that is moved, never destroyed, when the user navigates
away — so the stream is not torn down and yt-dlp is not re-invoked. goMiniClip
(app.js:1138-1151) reparents clip.host into a bottom-right mini box;
renderClipView (app.js:1152-1182) moves it back onto the big stage. Leaving
the clip view calls goMiniClip() rather than stopping (app.js:218, 236,
249). The clip only truly ends on "Sese geç" (exit), closing the mini box, or a
track change (design comment app.js:1075-1078). resumeMusic
(bridge.py:1046-1051) just ensures the engine is playing; it never stopped.
VideoStreamThread still resolves an audio_url too, but the new architecture
ignores it — audio is always the engine (bridge.py:169-185, and
data["muted"]=True at 1040).
5. Downloads and search
- yt-dlp is used as a Python library, not the exe.
downloader.py(DownloadThread) importsyt_dlpand drives it directly for real progress hooks, an exact post-conversion file path, and readable errors (module docstringdownloader.py:1-6;import yt_dlpat52; opts at60-75; mp3 postprocessor 192 kbps). Cover search also uses the library (covers.py:85-92). Clip streaming uses it too (bridge.py:144). The bundledbin/yt-dlp.exeexists but the running code path is the in-process library. - Embedded binaries in
bin/:ffmpeg.exe,yt-dlp.exe,deno.exe(confirmed present inbin/). Resolved inconfig.py:60-71. - Deno is REQUIRED. Modern yt-dlp needs a JavaScript runtime to solve
YouTube's
nsigsignature challenge. Without Deno, search still works but downloads mostly 403 / fail.config.py:71pointsDENOatbin/deno.exe, and — critically —config.py:78-79prependsbin/to the processPATHso the in-processyt_dlplibrary discovers Deno and ffmpeg.subprocess_env()(config.py:82-86) does the same for any child processes. - yt-dlp goes stale fast (YouTube keeps changing), so the pinned floor is
bumped often:
requirements.txt:7isyt-dlp>=2026.8.19with a note that2026.07.04caused HTTP 403 on download until the upgrade fixed it. The CI build always fetches the newestyt-dlp.exe(.github/workflows/build.yml:60). Keep it bumped.
6. Karaoke / vocal separation
- Engine:
justmusic/separation.pyruns Demucshtdemucson CPU only (Separator(model="htdemucs", device="cpu", …),separation.py:106). It splits a track into stems and builds the instrumental asdrums + bass + other(separation.py:113), savingno_vocals.wav(karaoke) andvocals.wav(acapella). - Cache: stems are cached under
~/Music/JustMusic/stems/<hash>/, where the hash is md5 ofabspath|filesize(separation.py:35-45). A cached track switches instantly and is never re-separated (cached_stems,separation.py:48-54;SeparationThread.runshort-circuits on cache,separation.py:73-76). CPU separation is slow (minutes/song), so it runs on aQThreadwith progress signals (separation.py:57-125). - Seamless switch via
swap_source. Selecting a stem callsDspEngine.swap_source(path)(engine.py:354-383), which decodes the stem on a worker thread and swaps the audio buffer while preserving the current position and play state (_swap_worker,engine.py:367-383). This is the key difference fromload(), which resets position to 0 (engine.py:324-335). Because position is preserved, karaoke / acapella toggles mid-song without a jump and all DSP effects keep applying. Wiring:Bridge.setKaraoke(bridge.py:834-885) and_apply_stem(bridge.py:887-892). Modes areoff,quick(instant mid-side cancel, no engine needed),instrumental, andvocals. If torch/demucs are unavailable,demucs_available()(separation.py:25-32) is false and the UI falls back toquickmid-side karaoke (bridge.py:863-868). - Bundling:
torchanddemucs(plusjulius,einops,lameenc,safetensors,sphn,huggingface_hub) are collected into the exe (build.py:33-42;requirements.txt:10-12, CPU torch wheel, no CUDA).
6b. Transitions: crossfade / gapless + Discord (v1.3.0)
- Pre-decode the next track.
DspEngine.preload_next(path)decodes the upcoming track on a worker thread into_next_audio(gen-guarded like_gen).Bridge._maybe_preload_next(in_on_poll) triggers this whendur - pos ≤ crossfade + 8s, but only for predictable sequential playback — it bails whenshuffle, a non-emptyuser_queue, orrepeat_mode == 2(repeat-one) would make "next" unpredictable.load()/swap_source()/clear()all callclear_next()so a manual skip drops a stale preload. - The blend happens in the one audio callback. In
_callback, once the next buffer exists and no A-B loop is active: withcrossfade_sec > 0it equal-power crossfades (gout=cos(t·π/2),gin=sin(t·π/2),tfrom per-sample seconds-left) by mixing_interp_block(current)with_interp_block(next)before the shared DSP chain, so EQ/effects apply once to the blended stream. Withgapless(andcrossfade_sec == 0) it promotes at the boundary with no overlap. - Promotion → gapless UI update.
_promote_next()swapsaudio/current_path← next under the lock, carriesposfrom_next_pos, and sets_advanced. The poll callsengine.consume_advanced(); a returned path routes toBridge._on_crossfade_advanced, which advancesplay_index/active_song_idand emitstrackChangedwithout reloading the engine — no gap, no re-decode. Default off (crossfade=0,gapless=False) = byte-for-byte the old behavior (the engine sets_at_end, the poll'sconsume_end()→_on_media_ended). - Discord Rich Presence lives in
justmusic/richpresence.py(DiscordPresence), an optional, thread-safe, fault-tolerant wrapper overpypresence(imported lazily;pypresence_available()gates the UI). It needs Discord running and a user-supplied Application Client ID (settings.discord_client_id). Updates are throttled (~15s, immediate on track/play-state change) and pushed from_on_poll(_tick % 80), ontrackChanged, and on play/pause. Connection failures degrade silently (Discord closed / bad ID → no-op). Slots:setCrossfade,setGapless,setDiscordRpc,setDiscordClientId.
7. Packaging and distribution
- PyInstaller onedir.
build.pybuilds a windowed one-dir bundle (default;--onefileoptional). It--collect-alls WebEngine core, sounddevice (PortAudio DLL), yt_dlp, certifi, torch, demucs and friends, adds thebin/binaries andjustmusic/web/+ logo as data (build.py:25-57). A checked-inJustMusic.specalso exists. - CI:
.github/workflows/build.yml. On av*tag push (or manual dispatch) it stampsconfig.APP_VERSIONfrom the tag (build.yml:38-44), downloads fresh ffmpeg / yt-dlp / deno intobin/(build.yml:51-65), runsbuild.py, then packages a.msiwith WiX 3.14 via heat/candle/light (build.yml:79-98) and uploads it to a GitHub Release (build.yml:100-105). The MSI is perUser (no UAC / admin), perinstaller/Product.wxsand the release notes (build.yml:109). - In-app auto-update:
justmusic/updater.py.UpdateCheckThread(updater.py:58) queries the GitHub "latest release" API 3.5 s after launch (bridge.py:339,_start_update_checkat1054). In a frozen build it silently downloads the new.msito%TEMP%\JustMusic-update\in the background (updater.py:123-140); the path is staged inBridge._staged_update(bridge.py:1071-1075). On close,bridge.shutdowncalls_perform_update(bridge.py:1478-1480), which writes a detached.cmdthat waits for the app to exit, runsmsiexec /i … /qnsilently (perUser -> no UAC), and relaunches the exe (updater.install_and_relaunch,updater.py:143-173). In dev (non-frozen) it only compares versions, never downloads (updater.py:96-98). - Landing page: a static GitHub Pages site in
site/(site/index.html,site/img/).
8. Module map (one line each)
justmusic/config.py— constants and paths; resolves embeddedbin/binaries and prependsbin/toPATHso in-process yt-dlp finds Deno/ffmpeg.justmusic/engine.py—DspEngine: ffmpeg decode to NumPy(N,2), sounddevice output, real-time speed/EQ/effects/karaoke chain,swap_source, analysis.justmusic/bridge.py— QWebChannel controller:@pyqtSlotAPI for the UI, signals back, owns the engine/library and all workerQThreads.justmusic/library.py— data model + JSON persistence (library.json), playlists,~/Musicscan, favorites, legacy migration.justmusic/downloader.py—DownloadThread: yt-dlp Python library search + download, ffmpeg extract to mp3, builds a song dict.justmusic/covers.py— background cover fetcher (queue + thread): downloads a thumbnail URL, or searches one via yt-dlp; caches tocovers/.justmusic/naming.py— messy filename -> clean "Artist - Title" for display and cover search.justmusic/separation.py— Demucshtdemucs(CPU) stem separation with on-disk cache understems/<hash>/.justmusic/updater.py— GitHub-Releases auto-update: check, background.msidownload, detached silent install + relaunch on close.justmusic/scheme.py— theapp://URL scheme handler serving web assets and cover images in-process (no server).justmusic/eqpresets.py— 10 band center frequencies, 30 EQ presets, default effect slider values.justmusic/web/app.js— the whole UI: rendering, event wiring, QWebChannel hookup, clip mode (buildClipMedia / stopClip / goMiniClip / sync), lyrics.justmusic/web/app.css— all styling (themes, layout, responsive, player bar, clip stage + mini player).justmusic/web/index.html— DOM skeleton (topbar, sidebar, main view, player footer); loadsqwebchannel.js,app.css,app.js.
Quick pointers for common tasks
- Change an EQ preset or add one:
justmusic/eqpresets.py. - Add a UI action: add a
@pyqtSlotonBridgeand callbridge.<method>()fromapp.js; push results back with apyqtSignal. - Touch clip playback: re-read section 4 first, and never leave a live
<video>in the DOM. - After editing code,
graphify update .keepsgraphify-out/current (per the project CLAUDE.md).