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.
214 lines
11 KiB
Markdown
214 lines
11 KiB
Markdown
# Spec: Windows native audio backend
|
||
|
||
**Status:** Proposed — not started. Windows still runs on
|
||
`WebviewAudioBackend`. Blocked on [libmpv2-migration.md](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](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`:
|
||
|
||
```rust
|
||
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:
|
||
|
||
```rust
|
||
// 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.def` or MSVC `mpv.lib` — only a
|
||
MinGW `libmpv.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` → synthesize `mpv.def` →
|
||
`llvm-dlltool -m i386:x86-64 -d mpv.def -l mpv.lib`. `llvm-lib /def:` produces a
|
||
byte-identical result.
|
||
- A real `lld-link` link against that import lib **succeeds**, and the resulting
|
||
import table resolves `mpv_client_api_version` from `libmpv-2.dll`. `lld-link`
|
||
is the linker `cargo-xwin` uses, so this is the load-bearing step.
|
||
- Linking directly against the shipped MinGW `libmpv.dll.a` **also** succeeds, so
|
||
def-generation may be skippable — but that relies on lld's GNU-archive
|
||
tolerance rather than a documented contract. Keep `llvm-dlltool` as 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 `WebviewAudioBackend` for macOS.
|
||
- MPRIS/SMTC media-key integration — worth a follow-up, not this spec.
|
||
|
||
## Acceptance criteria
|
||
|
||
- [ ] Windows build produces a `MpvBackend`-backed player; `backend-init-failed` is 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.dll` ships 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:boundary` pass.
|
||
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
|
||
- [ ] 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 `MpvBackend` construction in `create_player_backend` → `// TRACES: UR-003 | IR-030`
|
||
- `detect_audio_system` Windows branch → `IR-030`
|
||
- Existing `set_audio_settings` gains 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](libmpv2-migration.md) — porting the
|
||
current dead `libmpv` git pin to a second platform would double the migration
|
||
work.
|
||
- `libmpv2` has broken its API in every major release (4.0 removed command
|
||
helpers, 5.0 removed `mpv_node`, 6.0 changed `RenderContext` ownership). Pin an
|
||
exact version.
|
||
- Only the `render`-feature parts of `libmpv2` concern video; audio-only use does
|
||
not need it, and disabling the default `render` feature may shrink the build.
|
||
- A parallel Claude session may be active — `git diff` first.
|