A spec was a promise; sixteen of them had become descriptions of code that already shipped, sitting beside four that describe work still outstanding, with nothing in the file telling the two apart. Half the statuses were also wrong — audio-equalizer read "Accepted" with the EQ live on both platforms, the native video spec said the flag stays off after the default was flipped on. The shipped designs move into docs/architecture, which is the maintained description of the build, and the spec files go. Git history keeps the originals; what a future change still needs is carried across: - 01-rust-backend: favourites rewritten (the old section named a file that no longer exists and called shipped buttons "planned"), domain vocabulary owned by Rust (SearchScope, exclusions, the bitrate ladder), background workers - 02-svelte-frontend: app shell and chrome, library mosaic, series/episode navigation, downloaded browse, safe-area insets, native-video store, logging - 03-data-flow: locally-indexed search - 05-platform-backends: audio settings on ExoPlayer, the equalizer's band vocabulary, native video compositing, the background-audio handoff - 06-downloads-and-offline: one storage model, offline catalog visibility - 09-security: path confinement and input binding docs/specs/README.md now says what the directory is for and where each shipped design went. Deferred work the specs recorded is kept beside the code it concerns rather than lost: season-bounded autoplay, the two dead search commands, why indexing is a full crawl. requirements.md had fourteen stale statuses — Android audio parity still read "Linux only", DR-150 still said the native-video default was off, DR-190 was Proposed after DR-196 implemented it, and five tooling requirements were Proposed after landing. Three unbuilt specs suggested requirement ids that have since been allocated to other work; each now carries a warning.
11 KiB
Spec: Windows native audio backend
Status: Proposed — not started. Windows still runs on
WebviewAudioBackend. Blocked on libmpv2-migration.md,
whose crate swap has not landed either.
Requirements: UR-003, UR-027, UR-032, UR-033 → DR-030, DR-035, DR-036;
⚠️ the suggested id IR-030 has since been allocated to the scheduled catalog
crawl — allocate a fresh id (IR-033 or later) on implementation
UX spec: n/a — Settings › Audio already renders the controls
Supersedes / revises: acts on the "audio can unify, video cannot" conclusion in playback-backend-unification.md
Summary
Give Windows a real native audio backend instead of the current webview
<audio> shim. Windows is the only platform where audio playback has no decoder
of its own: WebviewAudioBackend hands a URL to a frontend <audio> element and
relays transport commands. It cannot set volume, cannot apply any audio setting,
and reports state only via DOM events.
Audio needs no rendering surface, so none of the webview-compositing problems that block unified video apply here. This is the cleanest available win.
Motivation
WebviewAudioBackend was a deliberate stopgap ("audio-only playback for
platforms without a native audio backend"), and it works — but it has a hard
functional gap. From webview_audio_backend.rs:
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
// ...stores locally only; there is no ControlCommand action for volume
}
So volume changes never reach the element; the frontend has to observe the player
store and apply volume itself. set_audio_settings likewise stores values that
nothing consumes — EQ, normalization, and gapless are all inert on Windows.
Meanwhile the backend-unification investigation established that a native audio
engine is unproblematic on Windows specifically: tauri-plugin-libmpv lists
Windows as its fully tested platform (in contrast to Linux, where embedding
is broken — but that is a video surface problem, which audio does not have).
Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|---|---|---|
| Decoding and playing the audio stream | Rust | Playback is domain logic; every other platform already decodes in Rust or a native player. The webview shim is the anomaly. |
Applying AudioSettings (EQ/normalize/gapless) |
Rust | Same AudioSettings contract as MPV/ExoPlayer; band layout and presets stay canonical in settings.rs. |
| Position/state reporting | Rust | Restores the project's core principle — the player is the authoritative source of state. Today Windows inverts this: the DOM element is authoritative and Rust mirrors it. |
| Volume | Rust | Currently broken precisely because it is split across the boundary. |
| Rendering the player UI | Frontend | Unchanged. |
The strongest argument for this change is the third row. CLAUDE.md states
playback state is one-directional with the player authoritative; on Windows that
is currently false, and the player_report_* round-trip exists to paper over it.
Design
Engine choice
Two viable options; libmpv is recommended for consistency with the Linux audio backend.
| libmpv | GStreamer | |
|---|---|---|
| Windows status | ✅ tauri-plugin-libmpv reports fully tested |
✅ works, but… |
| Rust bindings | libmpv2 6.0.0, active |
gstreamer-rs 0.25.x, excellent |
| Cross-MSVC from Linux | ⚠️ needs prebuilt DLL + import lib | ❌ gstreamer-sys uses pkg-config, fights cargo-xwin |
| Code reuse | ✅ MpvBackend logic is directly reusable |
❌ a second engine to learn |
| Crossfade capable | ❌ single-stream chain | ✅ audiomixer |
libmpv wins on reuse: MpvBackend's set_audio_settings — the af lavfi graph
built by build_af_filter, eq_filter_entries, normalize_filter_entry — is
platform-independent and would apply unchanged.
The one reason to prefer GStreamer is crossfade (UR-031), which mpv structurally cannot do. If crossfade becomes a priority, revisit; it would then argue for GStreamer on both Linux and Windows, which is a much larger change.
Structure
Rename the cfg gate so MpvBackend is no longer Linux-only:
// src-tauri/src/player/mod.rs
#[cfg(any(target_os = "linux", target_os = "windows"))]
pub mod mpv_backend;
MpvBackend::new needs one platform-specific branch: detect_audio_system()
currently probes pactl/pw-cli//proc/asound/cards to pick an ao. On
Windows the equivalent is wasapi (mpv's default), so the detection is a
#[cfg] returning "wasapi" — no probing needed.
Everything else — the event loop, the 250ms position thread, the seek-suppression
window, the af filter graph — is unchanged.
WebviewAudioBackend stays for other targets (macOS and anything else hitting
the not(any(...)) arm) and as the fallback if libmpv fails to initialize. The
existing emit_backend_init_failed path already handles that gracefully.
Build
libmpv2-sys is well-suited to cross-compilation: no pkg-config, vendored
headers, pregenerated bindings (no libclang). It emits cargo:rustc-link-lib=mpv
unconditionally, so the build must supply a linkable import library for
x86_64-pc-windows-msvc.
Keep the build_libmpv feature off — its Unix path shells out to mpv-build
and explicitly rejects cross-compilation.
🔴 Per CLAUDE.md, the prebuilt libmpv must be added to the builder image
(Dockerfile.builder → rebuild + push via scripts/build-builder-image.sh), not
installed at CI job time. libmpv-2.dll must also be bundled into the NSIS
installer via tauri.conf.json's resources.
Verified build mechanics
The cross-compile path was tested hands-on from Linux (July 2026), not inferred:
- Neither shinchiro nor zhongfly ships an
mpv.defor MSVCmpv.lib— only a MinGWlibmpv.dll.a. (Several online sources claim otherwise; they are wrong.) - An MSVC-style import lib can be generated locally with LLVM tools only:
llvm-readobj --coff-exports libmpv-2.dll→ synthesizempv.def→llvm-dlltool -m i386:x86-64 -d mpv.def -l mpv.lib.llvm-lib /def:produces a byte-identical result. - A real
lld-linklink against that import lib succeeds, and the resulting import table resolvesmpv_client_api_versionfromlibmpv-2.dll.lld-linkis the linkercargo-xwinuses, so this is the load-bearing step. - Linking directly against the shipped MinGW
libmpv.dll.aalso succeeds, so def-generation may be skippable — but that relies on lld's GNU-archive tolerance rather than a documented contract. Keepllvm-dlltoolas the fallback. - MinGW origin is not an ABI problem: libmpv exports a pure C ABI, and the x86-64 Windows calling convention is platform-defined. The upstream note that MSVC cannot build mpv is frequently misread as "MSVC cannot link libmpv" — that is not what it says.
- 🔴 Never free/realloc across the DLL boundary — use
mpv_free.
Build wiring is ordinary: cargo:rustc-link-lib=dylib=mpv plus
cargo:rustc-link-search. Nothing about libmpv conflicts with cargo-xwin.
Size and shipping
Measured uncompressed: 93 MiB (zhongfly mpv-dev-lgpl-x86_64) vs 112 MiB
(shinchiro, full GPL build); ~26–30 MB compressed in the .7z.
Ship the zhongfly LGPL build — smaller, and there is no reason to pull the GPL variant in for an audio-only use.
Import-table inspection confirms no companion DLLs are needed: every
dependency is a system DLL (KERNEL32, USER32, d2d1, DWrite, OPENGL32,
vulkan-1, UCRT api-ms-win-*). One file to bundle.
93 MiB is still substantial against a Tauri app's usual few MB. Since we use mpv audio-only, investigate whether a pruned build (no video decoders, no libplacebo) is worth producing for the builder image — but treat that as an optimization, not a blocker.
Out of scope
- Windows video. Stays in WebView2 + hls.js — it works and has ABR.
- Crossfade (UR-031/DR-034) — not implemented anywhere; needs its own spec.
- Replacing
WebviewAudioBackendfor macOS. - MPRIS/SMTC media-key integration — worth a follow-up, not this spec.
Acceptance criteria
- Windows build produces a
MpvBackend-backed player;backend-init-failedis emitted (not a crash) if libmpv is unavailable. - Volume control works from the UI — the current hard gap.
- EQ, normalization, and gapless audibly take effect on Windows.
- Position/state originate in Rust; the
<audio>element is no longer in the audio path. - Seek, next/previous, and queue advance work; sleep timer stops playback.
libmpv-2.dllships in the NSIS installer and the app runs on a clean Windows VM with no mpv installed.- Builder image carries the Windows libmpv artefacts; no toolchain install added to any CI step.
bun run check,bun run test,bun run check:boundarypass.cargo fmtclean,cargo clippyclean,bun run test:rustpasses.- New requirement-implementing code carries
// TRACES:comments.
Testing
Rust: the existing mpv_backend_test.rs and the build_af_filter /
normalize_filter_entry / eq_filter_entries unit tests already cover the
filter-graph logic and are platform-independent — they should pass unchanged
under a Windows cargo check/test. Add a test asserting detect_audio_system()
returns wasapi under cfg(windows).
Manual, on Windows: volume, EQ preset change, normalization toggle, gapless between two tracks, seek, queue advance, sleep timer. Then the packaging test — install the NSIS output on a clean VM and confirm it launches and plays.
Per CLAUDE.md, the volume gap is a bug fix: write a failing test for
"set_volume reaches the backend" before implementing.
TRACES
- Windows
MpvBackendconstruction increate_player_backend→// TRACES: UR-003 | IR-030 detect_audio_systemWindows branch →IR-030- Existing
set_audio_settingsgains Windows coverage →UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036 - Allocate IR-030 in
requirements.md("libmpv integration for Windows audio playback").
Notes for the implementer
- Do this after libmpv2-migration.md — porting the
current dead
libmpvgit pin to a second platform would double the migration work. libmpv2has broken its API in every major release (4.0 removed command helpers, 5.0 removedmpv_node, 6.0 changedRenderContextownership). Pin an exact version.- Only the
render-feature parts oflibmpv2concern video; audio-only use does not need it, and disabling the defaultrenderfeature may shrink the build. - A parallel Claude session may be active —
git difffirst.