MixMusic Star on GitHub

Architecture

MixMusic is one Go program. MyGo draws its windows in Go, with its ui package: immediate-mode views, which the GPU draws, without a webview. The windows show the catalogs, the accounts and the library of the services of internal/app, and the player in Go plays the queue through MixMusic's own audio engine, or on Spotify's devices.

┌────────────────── the windows (the root package) ───────────────────┐
│ pages, shell, player bar, queue, lyrics, mini player, themes,       │
│ plugins' pages and widgets                                          │
└──────┬──────────────────────────┬────────────────────────┬──────────┘
       │ App.Services             │ the queue              │ plugins
┌──────┴──────────────────┐ ┌─────┴──────────────┐ ┌───────┴──────────┐
│ internal/app: Catalog,  │ │ internal/player    │ │ internal/plugins │
│ Collection, Accounts,   │ │ ├ internal/audio   │ │ (sobek, a JS     │
│ Preferences, Addons,    │ │ └ Spotify devices  │ │ engine in Go)    │
│ System; the desktop     │ └────────────────────┘ └──────────────────┘
├─────────┬─────────┬────────┬───────┬────────┬─────────┬─────────────┤
│ youtube │ spotify │ stream │ match │ lyrics │ library │ addons  ... │
└─────────┴─────────┴────────┴───────┴────────┴─────────┴─────────────┘

The Go side

  • internal/media defines what every service shares: tracks, albums, artists, playlists, shelves. IDs carry their service: yt:<video id>, sp:<track id>, mx:<mix id>.
  • internal/youtube reads YouTube Music through the API its website uses: search, suggestions, the home and explore pages, charts, moods and genres, albums, artists, playlists, related tracks, radios and plain lyrics. Signed in with the cookies of the user's session, which it signs its requests with as the website does, it reads and changes their library: liked songs, playlists, albums, subscriptions, likes and saves, and it deletes the playlists the user made, which it tells from those they saved by the menus YouTube sends with them. Its parsers are tested against recorded responses in testdata/, and against the live service with MIXMUSIC_LIVE=1 go test ./internal/youtube -run Live (MIXMUSIC_YT_COOKIES adds a signed-in account, and MIXMUSIC_YT_WRITE=1 lets it make and delete a playlist there).
  • internal/spotify signs in with PKCE on a loopback callback, refreshes tokens, reads the Web API (search, albums, artists, playlists, the library), and controls Spotify Connect devices.
  • internal/stream finds the audio of a YouTube video through YouTube's player API, asked signed out as YouTube's visionOS app (Direct), else with yt-dlp: its Opus stream, else, of a video YouTube has only in AAC, its AAC-LC one (itag 140), never HE-AAC (itag 139). It keeps the stream until its URL expires, and opens it for the audio engine (Proxy.Open): chunked fetches from YouTube in the background, a new URL when YouTube refuses the old one, a new connection, from the byte it reached, when a download gets no bytes for 15 seconds or its connection drops, and an on-disk cache of tracks played to their end. A live video's audio is an HLS media playlist (RFC 8216) of AAC-LC in packed audio, format 234, or 233 with data saver, which Proxy.OpenLive follows from near its live edge: it loads the playlist again about once per target duration, fetches each new segment once, and finds the stream again before its URL expires, never caching it. The player API also tells how loud YouTube measured each video (Stream.Loudness, Direct.Loudness). Its answers are tested against recorded ones in testdata/, and against YouTube with MIXMUSIC_LIVE=1 go test ./internal/stream -run Live.
  • webm reads the audio of WebM files, as YouTube serves it: the packets of the first audio track with their times, and seeking by the Cues, reading a file played from its start straight through.
  • opus decodes Opus: a pure Go port of libopus 1.6.1's decoder (celt, silk and the range decoder rangedec, in opus/internal), which decodes the test vectors of RFC 8251 as libopus does, bit for bit.
  • mp4 reads the audio of MP4 files, as YouTube serves its AAC: the packets of the first audio track with their times, and the codec delay of the edit list, from progressive files and fragmented ones, seeking by the sample table, the sidx or the moof boxes.
  • aac decodes AAC-LC, in mono or stereo, with all of its tools, and reads the headers of ADTS frames: a pure Go decoder derived from SharpJaad, the C# port of JAAD, under SharpJaad's MIT license, which decodes YouTube's AAC within about 136 dB of ffmpeg's decoder, but for the few frames whose channels share their noise, as the standard has it and ffmpeg's decoder doesn't. resample converts it from its rate, 44.1 kHz for YouTube, to the engine's 48 kHz, with a polyphase filter of Kaiser-windowed sincs.
  • ac3 decodes AC-3, the 5.1 surround of YouTube's format 380: a pure Go decoder written from ATSC A/52, of every audio coding mode and tool, into six channels, with the stereo downmix the stream's own mix levels ask for. It decodes streams of ffmpeg's encoder as ffmpeg's decoder does to about 110 dB, and its tests edit those streams to use the tools the encoder doesn't: block switching, delta bit allocation, phase flags and dynamic range gains.
  • internal/audio is MixMusic's audio engine: two decks, each a goroutine that reads a track, Opus in WebM with webm and opus, AAC in MP4 with mp4, aac and resample, or the AAC of a live stream's packed audio, its ID3 tags passed over, which has no length and can't seek, as its first bytes tell, and decodes it into a ring the audio thread plays from, at 48 kHz, each turning a track louder than -14 LKFS down to it, from the loudness its Measured source tells (Even loudness), and the preamp, equalizer, levelling compressor, volume and analyser of the web player MixMusic had, ported from Chromium's Web Audio, which they match sample for sample. MIXMUSIC_LIVE=1 go test ./internal/audio -run Live plays songs of both codecs, and a 24/7 live stream, as the app finds them on YouTube, against ffmpeg's decode. internal/audio/output plays it on the system's output, without cgo: through WASAPI on Windows, an Audio Queue on macOS, and PulseAudio, which PipeWire serves too, or ALSA on Linux. On Windows it also hears what a process and its children play, through WASAPI's process loopback: internal/app's listener hears Spotify there, in MixMusic's webview or the Spotify app, into a Tap, the analyser of the engine for audio outside it, which the visualizer and the plugins' analyser read while Spotify plays.
  • internal/nowplaying shows what the engine plays in the system's media controls, and passes on what they ask: Now Playing on macOS, through MediaPlayer, and an MPRIS player on Linux, served by GIO's GDBus, both called with purego, and the SystemMediaTransportControls of a hidden window on Windows, reached through WinRT without cgo.
  • internal/portal asks the desktop's Background portal, in a Flatpak, to let MixMusic run without a window and to start it at login: the sandbox's own autostart folder is one the desktop never reads. internal/app asks it for Start at login, and for close to the tray. packaging/flatpak holds the Flatpak itself.
  • internal/taskbar puts previous, play or pause, and next on the thumbnail of the main window's taskbar button on Windows, through the shell's ITaskbarList3, and takes their clicks in a window procedure in front of MyGo's.
  • internal/player plays the queue: one goroutine owns the queue and what plays, and the network calls run beside it. It plays YouTube Music, and Spotify tracks matched to their recording, through the engine, and Spotify tracks on a Spotify device: a Spotify Connect device, or MixMusic itself, as below. It shuffles, repeats, crossfades, starts radios when the queue runs out, counts plays and keeps up to 500 tracks of the queue, around the one playing, in queue.json, as it changes and as MixMusic quits. A track that can't play for want of the network waits for it where it was, and plays on once it answers; the player pauses as the computer sleeps.
  • internal/online tells the player whether the network answers: a check of YouTube's and Spotify's addresses over HTTPS, shared by its callers, which audio coming from YouTube answers too, and a wait for the network, with checks further apart the longer it lasts. The system's word on the network, through MyGo, only has it check at once.
  • internal/tools finds yt-dlp and a JavaScript runtime, and installs yt-dlp from its releases, checked against their published checksums.
  • internal/match finds a Spotify track's recording on YouTube Music, by title, artists and duration, for playback without a Spotify device. It also finds the song of a music video, by its title and artists, which the video's radio starts from: YouTube Music makes the radio of a video out of videos.
  • lyrics finds synced lyrics on Unison and LRCLIB, and reads TTML and LRC.
  • internal/library keeps liked songs, mixes, the history and stats in library.json.
  • internal/lastfm signs in to Last.fm with the user's API account and scrobbles by Last.fm's rules, keeping what fails offline for later.
  • internal/discord shows what plays on the user's Discord profile, over Discord's local pipe or socket.
  • internal/diag writes the log, mixmusic.log in the logs folder, rotated at a megabyte and without the cookies, hashes and tokens a line may hold, and has Go write a crash to crash.log there, which the next start keeps as crash-<time>.log and offers to report.
  • internal/addons validates theme and plugin manifests, lists the built-in and installed add-ons, and installs add-ons from the community registry.
  • internal/colorfile reads the colours of pywal's colors.json, matugen's JSON, MixMusic's matugen template and a theme.json, telling them apart by what they hold; internal/app/colorfile.go follows the file the settings name with MyGo's file watch, and keeps the colours it read last while the file is half written.
  • internal/plugins runs the plugins the user enabled, each in a JavaScript runtime of its own, sobek (a fork of goja with ES modules), on a goroutine of its own: their modules, console, timers and fetch, and the API of plugins.md, built in api.js over Go natives. What they add to the interface the windows show: pages and widgets as views of elements, which the windows draw in their own styles, and canvases, whose drawing calls the plugin records and the window paints.
  • internal/store writes JSON files atomically. A file the user may edit while MixMusic runs, settings.json, it reads again when asked (File.Reload), and before each write, so that it never saves over an edit it hasn't read: it merges the edit in, by top-level keys. While the file doesn't decode, it leaves it as it is and holds its writes. internal/settings defines the settings, keeps them in range, and reads settings.json (Decode), at start and on each edit alike.
  • internal/app gives the windows the services (App.Services), tells them when the settings, the accounts, the add-ons or the updates change (App.Watch), makes the player, and puts MixMusic on the desktop: the tray, media keys, global shortcuts, deep links, notifications, updates.

The windows

The root package draws the main window and the mini player. Pages fetch what they show beside the main thread (fetch), and a window's state belongs to its main thread: what finishes elsewhere comes back through update.

  • Refresh (F5, ⇧⌘R on macOS, refresh.go) asks again for the keys the page shown and the side panel fetched in the frame before, as fetch collects them, and renders the plugins' views again (View.Remount). The page keeps its answers until the new ones come, and keeps them when a refresh fails, which one toast tells. Home's own tile and shelves read the library as it is, and need no refresh.

  • Artwork downloads only for what shows in the window, scaled to the size it shows at, and the images shown most recently are kept, up to 48 MB of pixels (artwork.go). An image that couldn't load tries again after 1 to 2 seconds, then less often, up to every 2 minutes, and at once when another of its host loads; one whose address gives no image waits half an hour. Both windows, the accent and Terminal's tint read the images through artwork.Store (internal/artwork), which downloads each once, keeps it on disk as it came, up to 256 MB, and logs a host's failures at most once a minute, without the addresses.

  • Themes give their tokens: colours, fonts, radius (theme.go), and the accent follows the artwork of what plays. A colour file's colours replace the theme's, kept readable (colorfile.go). The windows draw the effects of the built-in themes, as their options say (effects.go): Mix's textures, Aurora's drifting lights and frosted dialogs, Sakura's petals, Terminal's scanlines, glow and phosphor artwork.

  • Deep links and second launches open their pages in the main window (App.OnNavigate).

Webviews

Two windows still show a web page, and only while they're needed:

  • Spotify in MixMusic itself (internal/app/spotifyhere.go): Spotify's Web Playback SDK, which decrypts Spotify's protected audio, in the hidden window of public/spotify.html, the only page of the app, which it embeds. It opens at the first Spotify track that plays in MixMusic, and makes MixMusic a Spotify device of its own, which the player plays tracks on through the Web API and follows by asking the page (Page.Eval); the page gets its token from SpotifyPage.Token. Only WebView2, on Windows, decrypts Spotify's audio.
  • Google's sign-in to YouTube Music, whose cookies MixMusic keeps.

Data

Everything lives in the user's data folder (%AppData%\MixMusic on Windows, ~/Library/Application Support/MixMusic on macOS, ~/.config/MixMusic on Linux): settings.json, library.json, queue.json, addons/, bin/ (yt-dlp), the YouTube session (youtube-session.bin, encrypted with a key of the system's credential store, which also keeps the Spotify token and the Last.fm connection), the scrobbles still to send, and updater.json, the update preferences. On Windows, a secret the Credential Manager can't keep, as in a logon session without credentials, is sealed for the user with DPAPI in spotify-token.dpapi, lastfm.dpapi or youtube-session-key.dpapi; on Linux without a Secret Service, the secrets and the session are in plain files only the user can read (spotify-token.json, lastfm.json, youtube-session.json). secretStore (internal/app/secrets.go) moves a secret found in a file into the credential store once it can keep it. settings.json may be edited while MixMusic runs: App.watchSettings watches it (mygo.WatchFiles, or a look every two seconds where it can't), and App.ReloadSettings applies an edit as Settings applies a change, then tells the windows (App.Watch). A file with a mistake is told of after a second, and kept as settings.json.broken if MixMusic quits before it is fixed; a settings.json that is a link stays one. The cache folder holds audio, the covers shown (artwork/), matches, how loud YouTube measured the tracks played (loudness.json), and yt-dlp's cache, and the logs folder the log and the crash reports. MIXMUSIC_DATA_DIR moves all three into one folder, for a portable copy or a test run. MIXMUSIC_YTDLP_ONLY=1 has yt-dlp find every stream, as before MixMusic asked YouTube's player API, to time one against the other or tell which fails: the log tells which streams took over a second to find, and which tracks over two to start.

Capture mode

MIXMUSIC_CAPTURE=<dir> screenshots pages of the main window, and MIXMUSIC_CAPTURE_QUIT=1 quits once they are saved:

MIXMUSIC_CAPTURE=shots MIXMUSIC_CAPTURE_QUIT=1 MIXMUSIC_CAPTURE_ROUTES="/|/search?q=jazz|/settings" MIXMUSIC_CAPTURE_SIZE=1440x900 ./MixMusic

MIXMUSIC_CAPTURE_WAIT sets how long each step gets (5s). Steps that aren't pages act: act:shot:<name> saves the window as it is, act:mute mutes the player, act:play:<search> plays what a search finds, act:resume plays the queue kept in the data folder, act:wait:<duration> waits, act:palette opens the command palette, act:refresh refreshes the page, as F5 does, act:panel:<queue|lyrics|none> shows a side panel, act:scroll:<y|end> scrolls the page, act:theme:<id> wears a theme, act:colors:<off|pywal|matugen|path> takes the colours from a file, as Settings › Appearance does, act:mini opens the mini player, which saves mini.png, act:spotify logs whether MixMusic's own Spotify device could play, act:title logs the window's title, which the screenshots leave out, and act:memory and act:heap log the Go heap and save a profile of it. Nothing plays unless the player is muted, or MIXMUSIC_SILENT=1 has the audio engine play into no device, which keeps the visualizer and the lyrics moving without a sound. MIXMUSIC_CAPTURE_COMMUNITY=<dir> has the marketplace read the community registry from a copy of community/, for add-ons not on GitHub yet. The site's screenshots are made this way, from a throwaway profile, at 1536x900 (MIXMUSIC_CAPTURE_SIZE=1552x909: the size includes the window's invisible borders). go test . -run Render with MIXMUSIC_RENDER=<dir> draws the pages and themes without a window.