diff --git a/CLAUDE.md b/CLAUDE.md index b96ab2e7..1a130d4f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,7 @@ React 19 + TypeScript. Entry: `src/main.tsx` → `src/App.tsx`. Inside `crates/app/src/`: - **`commands/`** — one module per domain (`library`, `playlist`, `smart_playlists`, `track`, `browse`, `player`, `scan`, `edit`, `profile`, `analysis`, `deezer`, `similar`, `lyrics`, `stats`, `wrapped`, `maintenance`, `radio`, `duplicates`, `preferences`, `plugins`, `canvas`, …), all registered in `lib.rs::generate_handler![]`. CRUD delegates to `waveflow_core::repository::sqlite::*`; IPC + state + filesystem + emit glue stays in the command. -- **`audio/`** — 3-thread lock-free engine: `decoder.rs` (symphonia + rubato), `output.rs` (cpal callback on its own thread, SPSC `rtrb` ring), `state.rs` (`SharedPlayback` atomics), `analytics.rs`, `crossfade.rs`, `eq.rs`, `spectrum.rs`, `wasapi_exclusive.rs`. Topology: [`docs/architecture/audio.md`](docs/architecture/audio.md). +- **`audio/`** — 3-thread lock-free engine: `decoder.rs` (symphonia + rubato), `output.rs` (cpal callback on its own thread, SPSC `rtrb` ring), `state.rs` (`SharedPlayback` atomics), `analytics.rs`, `crossfade.rs`, `eq.rs`, `spectrum.rs`, the three exclusive backends (`wasapi_exclusive.rs`, `alsa_exclusive.rs`, `coreaudio_exclusive.rs`). Topology: [`docs/architecture/audio.md`](docs/architecture/audio.md). - **`dlna/`** (axum + SSDP, opt-in) · **`mpd/`** (TCP MPD protocol, opt-in) · **`media_controls.rs`** (souvlaki → SMTC / MPRIS / MediaRemote) · **`discord_presence.rs`** · **`queue.rs`** · **`player_actions.rs`** (shared control sequence) · **`remote/`** (remote source + sync v2, feature `sync_v2`, now in the default feature set; `remote/mirror.rs` walks the server's catalogue into the projection so both sources can be browsed from one library; `remote/download.rs` keeps a track's bytes in a managed folder the scanner never sees while `remote/import.rs` copies them into a scanned one, where they become a local track linked to the server's; `remote/upload.rs` is the fourth direction — offering the server what it lacks, deduplicated offline against the mirror and hashed once via `remote/hashing.rs`; `mod sync` is now a permanent no-op stub — v1 was removed in the RFC-005 cutover) · **`backup.rs`** · **`db/`** (pool wiring + `migration_heal`). - **Scanner** — the orchestrator `scan_folder_inner` stays app-side (it emits `scan:progress`); every pure helper lives in `waveflow_core::scanner::{extract, upserts}`. - **Database** — per-profile SQLite via sqlx + a global `app.db` for the profile list and app-wide settings. Migrations at `src-tauri/migrations/{app,profile}/`, compiled in via `sqlx::migrate!`. Layout: [`docs/architecture/storage.md`](docs/architecture/storage.md). @@ -92,7 +92,7 @@ Names in `commands/`, `audio/` and `src/components/` are predictable — read th | Area | Doc | Covers | | ------------ | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Playback | [`playback.md`](docs/features/playback.md) | decoder + DSD pipeline, native DSD via DoP, crossfade (static / smart / dynamic), gapless, ReplayGain, EQ, speed, network pre-load, WASAPI exclusive, spectrum visualizer, A-B repeat, queue | +| Playback | [`playback.md`](docs/features/playback.md) | decoder + DSD pipeline, native DSD via DoP, crossfade (static / smart / dynamic), gapless, ReplayGain, EQ, speed, network pre-load, exclusive output (WASAPI / ALSA / CoreAudio), spectrum visualizer, A-B repeat, queue | | Library | [`library.md`](docs/features/library.md) | scanner + watcher, folder covers, local artist images, search + filters, tag editor, ratings, duplicates, import, history, multi-artist split | | Playlists | [`playlists.md`](docs/features/playlists.md) · [`smart-playlists.md`](docs/features/smart-playlists.md) | CRUD, sorting, auto-covers, M3U, Daily Mix + On Repeat generators, rule tree | | Integrations | [`integrations.md`](docs/features/integrations.md) | Deezer, Last.fm, TheAudioDB, lyrics providers + editor, artist overrides, Discord RPC, OS notifications, scrobbling | diff --git a/README.md b/README.md index 6a4978e4..3b0cba20 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ WaveFlow is a desktop music player for the audio files you already own. It scans | Area | Highlights | Deep dive | | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -| **Playback** | Symphonia + cpal, lock-free 3-thread engine, real dual-decoder crossfade, gapless, ReplayGain (file tags or BS.1770 analysis, with pre-amp and clipping prevention), 6-band EQ (20 presets), **WASAPI Exclusive** bit-perfect output (Windows, opt-in, transparent cpal fallback), DSD → PCM, variable playback speed (0.5×–2×), spectrum visualizer, sleep timer, seed + mood radio, A-B repeat, output-device picker, OS media controls (SMTC / MPRIS / MediaRemote), persistent queue with shuffle / repeat / auto-advance | [docs](docs/features/playback.md) | +| **Playback** | Symphonia + cpal, lock-free 3-thread engine, real dual-decoder crossfade, gapless, ReplayGain (file tags or BS.1770 analysis, with pre-amp and clipping prevention), 6-band EQ (20 presets), **exclusive output** (opt-in, transparent cpal fallback — WASAPI Exclusive on Windows, a raw ALSA `hw:` device on Linux, CoreAudio hog mode on macOS), DSD → PCM, variable playback speed (0.5×–2×), spectrum visualizer, sleep timer, seed + mood radio, A-B repeat, output-device picker, OS media controls (SMTC / MPRIS / MediaRemote), persistent queue with shuffle / repeat / auto-advance | [docs](docs/features/playback.md) | | **Library** | Folder scanning + filesystem watcher, on-demand audio analysis (peak, BS.1770 loudness, ReplayGain, BPM), Hi-Res badges, multi-artist split, POPM 5-star ratings, A-Z navigator, multi-select action bar | [docs](docs/features/library.md) | | **Playlists** | Drag-and-drop reorder (virtualised), bulk add from any source, M3U import / export with basename-fallback matching, likes, recently-played | [docs](docs/features/playlists.md) | | **Smart playlists** | Auto-generated **Daily Mix** family bucketed by tempo, with composite artist-photo covers rendered from your Deezer cache | [docs](docs/features/smart-playlists.md) | diff --git a/docs/architecture/audio.md b/docs/architecture/audio.md index 50d576fc..c22602cc 100644 --- a/docs/architecture/audio.md +++ b/docs/architecture/audio.md @@ -19,9 +19,9 @@ | **`waveflow-audio-decoder`** | `audio::decoder::spawn_decoder_thread` | Owns the `rtrb::Producer` and the active `ActiveStream` (symphonia + rubato). Polls commands between packets so pause / stop / seek feel responsive. | | **`waveflow-audio-output`** | `audio::output::spawn_output_thread` | Owns the `cpal::Stream` (which is `!Send` on Windows because WASAPI / COM handles can't cross threads). Parks on a shutdown channel for the engine's lifetime. | | **cpal callback** | cpal-managed (WASAPI / ALSA / CoreAudio worker) | Pops samples from `rtrb::Consumer`, applies volume / normalization / mono downmix, writes to the device buffer. | -| **`waveflow-wasapi-exclusive`** ¹ | `audio::wasapi_exclusive::spawn_exclusive_output_thread` | Windows-only alternate output backend (opt-in). Owns the WASAPI `IAudioClient` + event handle. Blocks on the OS event between buffer periods — zero CPU when idle. Drives the same `rtrb::Consumer` as the cpal thread does in shared mode. | +| **`waveflow-{wasapi,alsa,coreaudio}-exclusive`** ¹ | `audio::{wasapi,alsa,coreaudio}_exclusive::spawn_*_output_thread` | The per-OS alternate output backend (opt-in). Owns the device outright — the WASAPI `IAudioClient` + event handle, the raw ALSA `hw:` PCM, or the hogged CoreAudio `AudioUnit`. Drives the same `rtrb::Consumer` as the cpal thread does in shared mode. | -¹ Mutually exclusive with the cpal output thread — only one of the two is running at a time, picked by `output::spawn_output_with_mode` based on the persisted `audio.wasapi_exclusive` setting. +¹ Mutually exclusive with the cpal output thread **and with each other** — exactly one output thread runs at a time, picked by `output::spawn_output_with_mode` from the persisted `audio.exclusive_output` setting (plus whether the track needs DoP). ## Shared state @@ -40,15 +40,23 @@ `playback_speed_bits` is read on every position computation (UI 4 Hz + analytics) — see [`current_position_ms`](../../src-tauri/crates/app/src/audio/state.rs) and [playback / Playback speed](../features/playback.md#playback-speed-05--2). `speed_dirty` is a one-shot flag the decoder consumes once per `'pkt` loop iteration to trigger a resampler rebuild. -## WASAPI Exclusive Mode (Windows opt-in) +## Exclusive output (opt-in) -[`audio/wasapi_exclusive.rs`](../../src-tauri/crates/app/src/audio/wasapi_exclusive.rs) is a parallel output backend to the cpal shared-mode default. Engaged via the `audio.wasapi_exclusive` profile setting (toggle in Settings → Audio). When on: +Each OS has a parallel output backend to the cpal shared-mode default, engaged by the **one** `audio.exclusive_output` profile setting (toggle in Settings → Audio): -1. `output::spawn_output_with_mode` tries the exclusive backend first via the [`wasapi` crate](https://crates.io/crates/wasapi). -2. The backend opens the device in **event-driven exclusive mode**, negotiating over two axes (see below). -3. If every candidate fails (device busy with another exclusive app, no supported format, COM apartment conflict), the engine logs a warning and falls back transparently to the cpal shared backend so the user keeps hearing audio. +| OS | Backend | How the device is taken | +| ------- | ---------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| Windows | [`wasapi_exclusive.rs`](../../src-tauri/crates/app/src/audio/wasapi_exclusive.rs) | WASAPI event-driven exclusive mode via the [`wasapi` crate](https://crates.io/crates/wasapi) | +| Linux | [`alsa_exclusive.rs`](../../src-tauri/crates/app/src/audio/alsa_exclusive.rs) | a raw `hw:` PCM, after asking the sound server for the card | +| macOS | [`coreaudio_exclusive.rs`](../../src-tauri/crates/app/src/audio/coreaudio_exclusive.rs) | CoreAudio **hog mode** | -### Format negotiation (two axes) +The shape is the same everywhere: `output::spawn_output_with_mode` tries the exclusive backend first, and **every** failure (device busy, no format we can write, no driver support, COM apartment conflict) logs a warning and falls back transparently to the cpal shared backend, so the user keeps hearing audio. The one exception is DoP, which is a demand rather than a preference — see [playback / Decoding & output](../features/playback.md#decoding--output). + +The setting is stored under `audio.exclusive_output`; the boot read in [`lib.rs`](../../src-tauri/crates/app/src/lib.rs) also accepts the legacy `audio.wasapi_exclusive` row (the name from when only Windows had a backend), with the current key winning when both exist. + +**What it is and isn't.** Exclusive output means the system mixer is out of the path — nothing else is mixed in, resampled or DSP'd on top of us. It does **not** mean the source rate is honoured end to end: except on the DoP path, all three backends open at a rate the *device* offers and the decoder's rubato resampler meets it. Making the rate follow the source needs the device re-opened per track, which is a separate phase — which is why the UI copy no longer says "bit-perfect" here (the audio-pipeline pill still can, but only when it has also checked that source rate == output rate; see [ui / Bit-perfect conditions](../features/ui.md#bit-perfect-conditions)). + +### Windows: format negotiation (two axes) `open_exclusive_session` walks **layout × bit depth** and takes the first pair the driver accepts. @@ -73,15 +81,40 @@ Init then uses the shape the probe accepted, falling back to the requested shape Each rejection logs the full `(rate, channels, format, layout-origin)` at `debug`, the success logs the same at `info`, and a total failure logs **every** attempt in one `warn` line — release builds log at `info`, so without that summary a user report only ever surfaced the last attempt of eight. -Trade-offs: +Dependency footprint: the `wasapi` crate + a slim slice of `windows-rs` features (`Win32_Foundation`, `Win32_System_Com`, `Win32_System_Threading`) target-gated to `cfg(target_os = "windows")`. Adds ~5-10 MB to the NSIS / MSI Windows binary; the Linux + macOS bundles are untouched. -- **Bit-perfect to the DAC at the chosen rate.** No Windows mixer between us and the hardware — no automatic resampling, no system-sound mixing, no per-app volume DSP. -- **One app at a time.** While exclusive is engaged, system sounds (notifications, Discord, browser audio) are silenced. By design. -- **Mode survives device hot-swaps.** `engine::set_output_device` reuses the same `spawn_output_with_mode` dispatch so picking a new output keeps the chosen mode. -- **No per-track rate switching yet.** The decoder's rubato resampler still converts every source to the negotiated rate. True bit-perfect at the source rate is a future phase (would require reinitialising the WASAPI client on every rate change). -- **The negotiated layout is authoritative downstream.** `open_exclusive_session` stores it into `SharedPlayback.{sample_rate,channels}` before init is reported to the caller, so the decoder thread — spawned only after that — resamples and downmixes to what the device actually accepted, not to what the mix format claimed. +### Linux: a raw `hw:` device, asked for rather than grabbed -Dependency footprint: the `wasapi` crate + a slim slice of `windows-rs` features (`Win32_Foundation`, `Win32_System_Com`, `Win32_System_Threading`) target-gated to `cfg(target_os = "windows")`. Adds ~5-10 MB to the NSIS / MSI Windows binary; the Linux + macOS bundles are untouched. +There is no plug layer under a `hw:` device — that is the whole point — so **every** conversion from the ring's `f32` is ours. `FORMAT_FALLBACK_CHAIN` walks the wire formats the hardware itself has to accept, best first: + +`FLOAT_LE` → `S32_LE` → `S24_3LE` → `S24_LE` → `S16_LE` + +with the channel count tried at the engine's current value first and stereo as the fallback (a zero count — nothing has opened an output yet — reads as stereo, or the first launch with exclusive already on would ask a card for mono and get it). + +**`S24_LE` is placed late on purpose.** ALSA puts the 24 valid bits in the **low** three bytes of the 32-bit word; WASAPI's `Pcm24Padded` puts them in the **high** three. Copying the other backend's `<< 8` multiplies every sample by 256 into permanent clipping, and the mirror mistake costs 48 dB. A unit test pins the layout, and the enum variant's doc comment says why it exists. + +Two things the DoP path had worked out first are now shared rather than duplicated: + +- **The reservation protocol** ([`device_reservation.rs`](../../src-tauri/crates/app/src/audio/device_reservation.rs)) — PipeWire and PulseAudio hold every card from login, so a bare `hw:` open returns `EBUSY` on any desktop. Taking the `org.freedesktop.ReserveDevice1.Audio` bus name is the protocol both servers watch in order to release a device; the open is then retried for up to a second while the server finishes letting go. The reservation is bound to the stream and released with it. +- **Partial writes** — re-offering only the frames the device declined. + +**Period and buffer must both be asked for.** `HwParams::any` leaves them at whatever the driver offers, and `snd_pcm_hw_params` then takes its **maximum** for both. Measured on a `snd-dummy` card: a 16 384-frame period — ~370 ms at 44.1 kHz — and a buffer deep enough that starting a track, seeking and changing track each took about ten seconds. That wait was **the buffer draining**, not the period. + +The period has its own, quieter effect: one period is drained from the ring in a single pass, and whatever the ring can't supply is written as silence. A period that size was two thirds of [`RING_CAPACITY`](#ring-buffer-sizing) on the card measured, which makes an underrun the normal case rather than the exception. `set_period_and_buffer` asks for 1024 frames (~23 ms at 44.1 kHz) and a buffer of 4 periods. + +### macOS: hog mode, and nothing else + +`open_and_run_pcm` takes hog mode and leaves the device's **physical format exactly as it found it**, reading the rate and channel count the device already runs at and publishing them for the decoder to meet. The DoP path does pin the format — a marker cadence that gets resampled is noise — but re-clocking a device the whole machine shares is a price only that cadence justifies. + +**Hog mode is registered against a PID**, so it does not evict a stream from our own process. The engine's spawn-before-release order — which works on Windows (the seized endpoint kicks the outgoing shared client off) and on Linux (the reservation makes the server hand the card back) — produced an `AudioUnit` here that rendered nothing at all: no sound, position counter frozen. The release-first rule in [playback / Output-stream lifecycle](../features/playback.md#output-stream-lifecycle--recovery) now covers this case too. + +### Shared by all three + +- **One app at a time.** While exclusive is engaged, system sounds (notifications, Discord, browser audio) are silenced. By design. +- **The mode survives device hot-swaps.** `engine::set_output_device` reuses the same `spawn_output_with_mode` dispatch, so picking a new output keeps the chosen mode. +- **The negotiated layout is authoritative downstream.** Each backend stores what the device actually accepted into `SharedPlayback.{sample_rate,channels}` before init is reported to the caller, so the decoder thread — spawned only after that — resamples and downmixes to that, not to what any mix format claimed. +- **The period fill is one function.** [`output::fill_pcm_period`](../../src-tauri/crates/app/src/audio/output.rs) pops the ring and applies volume / normalization / mono downmix, and it lives in `output.rs` rather than in each backend — the three have no business disagreeing about whether an underrun counts toward the play clock. Its tests run on every platform instead of only where one backend compiles. +- **Failure is fail-soft but not silent.** A backend that can't open logs a `warn`; the engine reports what actually engaged through `PlayerStateSnapshot.exclusive_active`, which is what the Settings card and the pipeline pill read (the toggle can be on while the mode is off). ## Ring buffer sizing diff --git a/docs/architecture/crates.md b/docs/architecture/crates.md index bedc540c..5f108af3 100644 --- a/docs/architecture/crates.md +++ b/docs/architecture/crates.md @@ -37,7 +37,7 @@ src-tauri/ ├── tauri.conf.json ├── capabilities/ icons/ build.rs └── src/ - ├── audio/ (real-time cpal + rtrb pipeline, EQ, WASAPI exclusive) + ├── audio/ (real-time cpal + rtrb pipeline, EQ, exclusive backends) ├── commands/ (#[tauri::command] handlers, thin over core) ├── db/ (per-profile pool wiring + migration_heal) ├── dlna/ (MediaServer worker thread) @@ -79,7 +79,7 @@ Lyrics providers that are query-based rather than exact metadata clients. The cr Anything tied to the Tauri runtime, the real-time audio engine, or the desktop OS: - **Every `#[tauri::command]`** — even when the body is a thin call into a core function. The IPC bridge contract is desktop-specific. -- **Real-time audio engine** — `audio/{decoder,output,engine,crossfade,eq,resampler,spectrum,state,wasapi_exclusive,analytics}.rs`. The `cpal` callback and the WASAPI exclusive thread must not allocate / log / lock; the surrounding decoder + state machinery only makes sense alongside them. +- **Real-time audio engine** — `audio/{decoder,output,engine,crossfade,eq,resampler,spectrum,state,analytics}.rs` plus the per-OS exclusive backends `audio/{wasapi,alsa,coreaudio}_exclusive.rs`. The `cpal` callback and the exclusive output threads must not allocate / log / lock; the surrounding decoder + state machinery only makes sense alongside them. - **OS media controls** — souvlaki (`media_controls.rs`), Discord Rich Presence named-pipe client (`discord_presence.rs`), system notification plugin bridge (`notifications.rs`). - **DLNA / UPnP MediaServer** — `dlna/` is integrated as a worker thread driven by the Tauri runtime. - **Filesystem watcher** — `watcher.rs` wires `notify` events into `library:rescanned` Tauri events. diff --git a/docs/features/playback.md b/docs/features/playback.md index 48a854a8..6c4b1f64 100644 --- a/docs/features/playback.md +++ b/docs/features/playback.md @@ -80,29 +80,34 @@ On Linux, enumeration uses ALSA's hint database (`snd_device_name_hint("pcm")`) ## Output-stream lifecycle & recovery -Three paths replace the output stream, and they must all end in the same place: `wasapi_exclusive_active` updated and a `player:audio-mode-changed` event emitted, because that event is the only thing that keeps Settings' Exclusive-mode toggle honest ([`ExclusiveModeCard`](../../src/components/views/settings/ExclusiveModeCard.tsx) re-reads on it). +Three paths replace the output stream, and they must all end in the same place: `exclusive_output_active` updated and a `player:audio-mode-changed` event emitted, because that event is the only thing that keeps Settings' Exclusive-output toggle honest ([`ExclusiveModeCard`](../../src/components/views/settings/ExclusiveModeCard.tsx) re-reads on it). | Path | Trigger | Order | | ---------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `set_output_device` | user picks another endpoint | spawn first, then release — the two streams target different devices, so a failed spawn can roll back to the working one | -| `set_wasapi_exclusive` | user toggles the mode | **release first when the old stream is exclusive**, then spawn | -| `force_rebuild_output` | automatic recovery after a device error | **release first when the old stream is exclusive**, then spawn | +| `set_exclusive_output` | user toggles the mode | order from `must_release_before_reopening` — see below | +| `force_rebuild_output` | automatic recovery after a device error | the same rule | -The release-first rule is the #322 / #405 lesson: a WASAPI exclusive client owns its endpoint outright, so no other client — shared _or_ exclusive — can open it until that client is released. Re-opening the **same** endpoint while an exclusive stream still holds it always fails, and when it failed inside `set_wasapi_exclusive` the command returned `Err` before persisting anything, leaving the toggle latched on the mode the user was trying to leave (#405). +[`must_release_before_reopening`](../../src-tauri/crates/app/src/audio/engine.rs) owns the order, and answers "release first" in **two** cases: + +- **The old stream is exclusive**, on any platform. It owns the device outright, so nothing — shared _or_ exclusive — can open that device until it lets go. Re-opening the **same** endpoint while an exclusive stream still holds it always fails, and when that failure landed inside `set_exclusive_output` the command returned `Err` before persisting anything, leaving the toggle latched on the mode the user was trying to leave (#405). This is the #322 / #405 lesson. +- **We are entering exclusive on macOS**, even from a *shared* stream. CoreAudio registers hog mode against a **PID**, not a stream, so the client it would have to evict is our own cpal stream in this very process — and it evicts nothing. The new `AudioUnit` then comes up on a device the old one is still driving and renders nothing: no sound, position counter frozen. Windows kicks the shared client off when the endpoint is seized and Linux's reservation protocol makes the sound server hand the card over, so neither needs the widening — which is exactly why the macOS case stayed hidden until PCM hog mode existed. + +Spawn-first is the order we want everywhere else: a failed open then costs nothing, because the stream the user is listening to is still installed and still playing. Releasing first costs less than it looks in the macOS case, because `spawn_output_with_mode` falls back to shared mode on its own — so a refused exclusive open still leaves the caller holding a stream. It is only when the shared fallback *also* fails that there is no output thread at all, and that path is the one described at the end of this section. Device loss reaches the recovery path from two independent places, since the two backends have separate failure surfaces: - **cpal shared** — the stream's `err_fn` callback fires on an arbitrary thread. -- **WASAPI exclusive** — [`wasapi_exclusive::run_event_loop`](../../src-tauri/crates/app/src/audio/wasapi_exclusive.rs) returns an `ExitReason`; `DeviceLost` covers a failed `wait_for_event` / `write_to_device`. Each is re-checked against the shutdown channel first so a deliberate teardown isn't mistaken for a failure. +- **Exclusive backends** — each output loop returns an `ExitReason` whose `DeviceLost` variant is re-checked against the shutdown channel first, so a deliberate teardown isn't mistaken for a failure: [`wasapi_exclusive`](../../src-tauri/crates/app/src/audio/wasapi_exclusive.rs) (a failed `wait_for_event` / `write_to_device`), [`alsa_exclusive`](../../src-tauri/crates/app/src/audio/alsa_exclusive.rs) (a write that fails past recovery), [`coreaudio_exclusive`](../../src-tauri/crates/app/src/audio/coreaudio_exclusive.rs) (the `IsAlive` property listener, with a periodic `get_hogging_pid` query as fallback). Both then call the shared [`output::notify_device_lost`](../../src-tauri/crates/app/src/audio/output.rs) (park the player, emit `player:state` + `player:error`, sync the OS media controls) and [`output::schedule_device_rebuild`](../../src-tauri/crates/app/src/audio/output.rs) (300 ms backoff, then a same-device rebuild). Two gates keep the recovery from thrashing: - **`RebuildGate`** (`REBUILD_SETTLE_WINDOW`, 2 s) — one rebuild per burst of device errors. `begin_deliberate_output_change()` opens the same window around a mode toggle, because seizing the endpoint exclusively kicks the outgoing shared client off it and that self-inflicted `DeviceNotAvailable` would otherwise schedule a rebuild that undoes the switch. -- **`FlapWindow`** (`EXCLUSIVE_FLAP_THRESHOLD` / `EXCLUSIVE_FLAP_WINDOW`) — a device that resets on every exclusive grab gives up on exclusive for the rest of the session. Cleared by an explicit toggle or device switch. +- **`FlapWindow`** (`EXCLUSIVE_FLAP_THRESHOLD` / `EXCLUSIVE_FLAP_WINDOW`) — a device that resets on every exclusive grab gives up on exclusive for the rest of the session (session-only: the persisted preference is untouched, so the next launch tries again). Cleared by an explicit toggle or device switch. -Every failure path that ends with no output thread at all publishes `wasapi_exclusive_active = false` + the event before returning the error — a toggle describing a stream that no longer exists is the exact shape of #405. +Every failure path that ends with no output thread at all publishes `exclusive_output_active = false` + the event before returning the error — a toggle describing a stream that no longer exists is the exact shape of #405. ## OS media controls diff --git a/docs/features/ui.md b/docs/features/ui.md index 39f1188a..85d3b984 100644 --- a/docs/features/ui.md +++ b/docs/features/ui.md @@ -266,7 +266,7 @@ Hovering (or keyboard-focusing) the footer opens [`AudioPipelinePopover`](../../ Two things have to hold, and the pill used to check only the first: 1. **Nothing in our pipeline touches the samples** — no processing chip is active and the source rate matches the output rate. Any single chip lit (including `EQ` and `Speed`) suppresses it. -2. **Nothing downstream touches them either** — the stream owns the device (`PlayerStateSnapshot.exclusive_active`, WASAPI Exclusive today; native DoP implies an exclusive backend and qualifies on its own). +2. **Nothing downstream touches them either** — the stream owns the device (`PlayerStateSnapshot.exclusive_active` — WASAPI Exclusive, a raw ALSA `hw:` device or CoreAudio hog mode, whichever the platform has; native DoP implies an exclusive backend and qualifies on its own). The second condition is what makes the claim true. A shared-mode stream at the same nominal rate still passes through the system mixer, which re-clocks it and mixes in every other sound on the machine — and that was being badged `Bit-perfect`. When the pipeline is clean but the device is shared, the pill reads `Sortie partagée (mixeur système)` instead, so the reason the green one is absent is on screen rather than left to guess. @@ -371,7 +371,7 @@ Opt-in scheduled mirror of the manual export so the user's playlists / likes / r | Tab | Houses | | -------------- | ------------------------------------------------------------------------------------------------------- | | `library` | Library folders, scan-on-start, file watcher | -| `playback` | EQ, crossfade, ReplayGain, normalisation, WASAPI exclusive, mono | +| `playback` | EQ, crossfade, ReplayGain, normalisation, exclusive output, mono | | `integrations` | Last.fm, Discord RPC, Deezer enrichment, DLNA media server | | `appearance` | Theme picker (14 presets) + player-bar layout | | `data` | Profile export / import, auto-backup, statistics export, offline | diff --git a/src-tauri/crates/app/src/audio/alsa_exclusive.rs b/src-tauri/crates/app/src/audio/alsa_exclusive.rs index 5fcf186f..16cf55ad 100644 --- a/src-tauri/crates/app/src/audio/alsa_exclusive.rs +++ b/src-tauri/crates/app/src/audio/alsa_exclusive.rs @@ -1,17 +1,34 @@ -//! Linux-only ALSA hardware-exclusive DoP output backend (#495). +//! Linux-only ALSA hardware-exclusive output backend (#495 for DoP, then +//! ordinary PCM). //! //! The Linux equivalent of [`super::wasapi_exclusive`]: it opens the DAC //! as a **raw `hw:` device** (never `default` / `plughw:` / a Pulse or //! PipeWire alias), which bypasses the system mixer + resampler and gives -//! us exclusive, bit-perfect access — the only way a DoP marker cadence -//! survives to the DAC. The stream is opened at the exact DoP rate -//! (`dsd_rate / 16`) in **`S32_LE`**, and each 24-bit DoP word is placed -//! MSB-justified in the 32-bit sample (marker in the top byte) via -//! [`super::dop_pack::fill_dop_period_i32`]. +//! us exclusive access to the hardware. //! -//! If the DAC won't accept `S32_LE` at the DoP rate the open fails and -//! the engine falls back to the ordinary DSD → PCM path (through cpal -//! shared). +//! It carries two kinds of stream, and the difference between them is +//! the rate: +//! +//! - **DoP** (`Some(DopFormat)`) — opened at the exact DoP rate +//! (`dsd_rate / 16`) in **`S32_LE`**, each 24-bit DoP word placed +//! MSB-justified in the 32-bit sample (marker in the top byte) via +//! [`super::dop_pack::fill_dop_period_i32`]. The rate is a *demand*: +//! the marker cadence only survives if nothing resamples it, so a +//! device that can't do this exact rate fails the open and the engine +//! falls back to ordinary DSD → PCM through cpal shared. +//! - **PCM** (`None`) — the audiophile path for every other track. Here +//! the rate is a *preference*: the decoder's rubato stage converts to +//! whatever the device lands on, exactly as it does for cpal shared, +//! so we take the device's answer and publish it. What exclusive buys +//! is the mixer's absence, not the source rate — see the note on +//! source-rate negotiation below. +//! +//! **Scope, same as WASAPI's:** this is "bypass the system mixer", +//! not yet "honor the source rate exactly". `hw:` guarantees no +//! resampling *below* us; the resampling that remains is our own, and +//! moving it out means re-opening the device per track. That's a +//! separate phase, and it is the one that would make the word +//! bit-perfect true end to end. //! //! A device held by *another client* is a different story, and used to //! end the same way: on a desktop the holder is PipeWire or PulseAudio, @@ -29,7 +46,7 @@ use std::sync::Arc; use std::thread::JoinHandle; use std::time::{Duration, Instant}; -use alsa::pcm::{Access, Format, HwParams, State, PCM}; +use alsa::pcm::{Access, Format, Frames, HwParams, State, IO, PCM}; use alsa::{Direction, ValueOr}; use crossbeam_channel::{bounded, Receiver, Sender}; use rtrb::{Consumer, Producer, RingBuffer}; @@ -39,16 +56,17 @@ use super::output::{DopFormat, OutputHandle, RING_CAPACITY}; use super::state::SharedPlayback; use crate::error::{AppError, AppResult}; -/// Spawn the ALSA DoP output thread. Mirrors +/// Spawn the ALSA exclusive output thread. Mirrors /// [`super::wasapi_exclusive::spawn_exclusive_output_thread`]'s contract: /// returns the decoder-side `Producer` and an [`OutputHandle`], or an /// error (device busy / format unsupported / no such device) surfaced -/// synchronously so the caller can fall back to DSD → PCM. -pub fn spawn_alsa_dop_output_thread( +/// synchronously so the caller can fall back — to DSD → PCM for a DoP +/// request, to cpal shared mode for a PCM one. +pub fn spawn_alsa_exclusive_output_thread( shared: Arc, app: AppHandle, device_name: Option, - dop: DopFormat, + dop: Option, ) -> AppResult<(Producer, OutputHandle)> { let (producer, consumer) = RingBuffer::::new(RING_CAPACITY); let (shutdown_tx, shutdown_rx) = bounded::<()>(1); @@ -58,9 +76,12 @@ pub fn spawn_alsa_dop_output_thread( let thread_app = app.clone(); let thread_device = device_name.clone(); let join: JoinHandle<()> = std::thread::Builder::new() - .name("waveflow-alsa-dop".into()) - .spawn(move || { - output_thread_main( + .name(match dop { + Some(_) => "waveflow-alsa-dop".into(), + None => "waveflow-alsa-exclusive".to_string(), + }) + .spawn(move || match dop { + Some(dop) => output_thread_main( thread_shared, consumer, shutdown_rx, @@ -68,9 +89,17 @@ pub fn spawn_alsa_dop_output_thread( thread_app, thread_device, dop, - ) + ), + None => pcm_output_thread_main( + thread_shared, + consumer, + shutdown_rx, + init_tx, + thread_app, + thread_device, + ), }) - .map_err(|e| AppError::Audio(format!("spawn alsa dop thread: {e}")))?; + .map_err(|e| AppError::Audio(format!("spawn alsa exclusive thread: {e}")))?; match init_rx.recv() { Ok(Ok(())) => Ok(( @@ -79,8 +108,14 @@ pub fn spawn_alsa_dop_output_thread( shutdown_tx, join, device_name, - wasapi_exclusive: false, - dop: Some(dop), + // We hold the card through a raw `hw:` handle: nothing + // else can mix into it while this thread lives. That is + // true of the DoP stream too — it used to report `false` + // here, which made the pipeline panel deny an exclusive + // grab that had in fact happened (WASAPI has always + // reported `true` for both). + exclusive: true, + dop, }, )), Ok(Err(err)) => { @@ -88,7 +123,7 @@ pub fn spawn_alsa_dop_output_thread( Err(err) } Err(_) => Err(AppError::Audio( - "alsa dop thread died before reporting init result".into(), + "alsa exclusive thread died before reporting init result".into(), )), } } @@ -122,41 +157,19 @@ fn output_thread_main( // The reservation is bound alongside the PCM and dropped with it: // holding a card we are no longer playing on would keep the sound // server locked out of it. - let (_reservation, pcm, period_frames) = match open_pcm(&dev, dop) { - Ok((pcm, period_frames)) => (None, pcm, period_frames), - Err(failure) if failure.busy => { - // Someone else owns the card. On any desktop that is the - // sound server, and the protocol below is how you ask it to - // step aside; before this, the answer was always to give up. - let reservation = - hw_card_index(&dev).and_then(super::device_reservation::Reservation::acquire); - let Some(reservation) = reservation else { + let (_reservation, (pcm, period_frames)) = + match open_reserving_the_card(&dev, "dop", || open_dop_pcm(&dev, dop)) { + Ok(opened) => opened, + Err(err) => { tracing::warn!( + %err, device = %dev, - "alsa dop: the card is busy and could not be reserved; falling back to DSD -> PCM" + "alsa dop init failed; falling back to DSD -> PCM" ); - let _ = init_tx.send(Err(failure.err)); + let _ = init_tx.send(Err(err)); return; - }; - match open_after_release(&dev, dop) { - Ok((pcm, period_frames)) => (Some(reservation), pcm, period_frames), - Err(failure) => { - tracing::warn!( - err = %failure.err, - device = %dev, - "alsa dop: the card stayed busy after the reservation" - ); - let _ = init_tx.send(Err(failure.err)); - return; - } } - } - Err(failure) => { - tracing::warn!(err = %failure.err, device = %dev, "alsa dop init failed"); - let _ = init_tx.send(Err(failure.err)); - return; - } - }; + }; // `io_i32` borrows the PCM, so it lives in this frame alongside it. let io = match pcm.io_i32() { @@ -167,6 +180,16 @@ fn output_thread_main( } }; + // What the output was running at before DoP took over. The PCM + // half reads `sample_rate` / `channels` as its *preference* for the + // next open, and a DoP rate is not one: a DAC that does DSD128 + // usually also accepts 352.8 kHz as PCM, so the track after the DSD + // one would open there and have rubato upsample every 44.1 kHz + // source eightfold for nothing. Put back what we found on the way + // out — see the restore at the end of this function. + let pre_dop_rate = shared.sample_rate.load(Ordering::Acquire); + let pre_dop_channels = shared.channels.load(Ordering::Acquire); + shared.sample_rate.store(dop.sample_rate, Ordering::Release); shared.channels.store(dop.channels, Ordering::Release); let _ = init_tx.send(Ok(())); @@ -215,38 +238,14 @@ fn output_thread_main( } } - // Blocking write (the PCM was opened blocking), but `writei` is - // still allowed to accept fewer frames than we offered — on a - // signal, or after a recovery that swallowed part of the period. - // The tail has to be re-offered rather than dropped: a hole in - // the stream shifts every following frame against the DoP marker - // cadence the DAC is locked onto. Only the *unwritten* remainder - // is re-sent, never the frames the device already took. - let mut frames_done = 0usize; - while frames_done < period_frames { - match io.writei(&buf[frames_done * channels..]) { - Ok(0) => { - // A blocking device that reports no progress and no - // error has nothing left to recover from. - break 'run ExitReason::DeviceLost( - "alsa accepted 0 frames on a blocking write".into(), - ); - } - Ok(n) => frames_done += n, - Err(err) => { - if shutdown_rx.try_recv().is_ok() { - break 'run ExitReason::Shutdown; - } - if let Err(rec) = pcm.try_recover(err, true) { - tracing::warn!(?rec, "alsa dop write failed and recovery failed"); - break 'run ExitReason::DeviceLost(format!("alsa write failed: {rec}")); - } - // Recovered — re-prepare if needed, then retry the tail. - if pcm.state() == State::Setup { - let _ = pcm.prepare(); - } - } - } + // One `i32` per channel per frame, so the item stride of a frame + // is the channel count. Re-offering a partial write matters most + // here: a hole shifts every following frame against the DoP + // marker cadence the DAC is locked onto. + match write_full_period(&pcm, &io, &buf, channels, period_frames, &shutdown_rx) { + PeriodOutcome::Written => {} + PeriodOutcome::Shutdown => break 'run ExitReason::Shutdown, + PeriodOutcome::DeviceLost(reason) => break 'run ExitReason::DeviceLost(reason), } if shutdown_rx.try_recv().is_ok() { @@ -259,6 +258,16 @@ fn output_thread_main( // exclusive `hw:` handle for the next opener. drop(io); + // Hand the DoP rate back before anyone can mistake it for a PCM + // preference. `OutputHandle::stop` joins this thread, so every + // deliberate teardown has published this before the replacement + // output opens; on a device loss the rebuild is scheduled from + // below, after the same store. Whatever opens next overwrites both + // values with what it actually negotiated — this only decides what + // that open *asks* for. + shared.sample_rate.store(pre_dop_rate, Ordering::Release); + shared.channels.store(pre_dop_channels, Ordering::Release); + match exit { ExitReason::Shutdown => { tracing::debug!("alsa dop output thread exiting"); @@ -348,19 +357,71 @@ impl From for PcmOpenError { } } +/// Take the card, asking the sound server to step aside if it holds it. +/// +/// Shared by both streams because the obstacle is the same one: on any +/// desktop the holder of a `hw:` device is PipeWire or PulseAudio, which +/// grabbed the card at login. Before the reservation protocol existed the +/// answer to that was always to give up. +/// +/// The returned reservation must be kept alive alongside the PCM and +/// dropped with it — holding a card we no longer play on would keep the +/// sound server locked out of it. `None` means the card was free and +/// nothing had to be asked. +/// +/// `what` only labels the log lines; the caller still reports the +/// failure in its own terms, because what happens next differs (a DoP +/// stream falls back to DSD → PCM, a PCM stream to cpal shared). +fn open_reserving_the_card( + dev: &str, + what: &str, + mut attempt: impl FnMut() -> Result, +) -> Result<(Option, T), AppError> { + match attempt() { + Ok(opened) => Ok((None, opened)), + Err(failure) if failure.busy => { + let Some(reservation) = + hw_card_index(dev).and_then(super::device_reservation::Reservation::acquire) + else { + tracing::warn!( + device = %dev, + %what, + "alsa: the card is busy and could not be reserved" + ); + return Err(failure.err); + }; + match open_after_release(attempt) { + Ok(opened) => Ok((Some(reservation), opened)), + Err(failure) => { + tracing::warn!( + err = %failure.err, + device = %dev, + %what, + "alsa: the card stayed busy after the reservation" + ); + Err(failure.err) + } + } + } + Err(failure) => Err(failure.err), + } +} + /// Retry the open while the sound server finishes letting go. /// /// Releasing is asynchronous on its side — it sees `NameLost`, plays /// out what it has buffered and only then closes the device — so the /// first open after the reservation lands still returns `EBUSY`. -fn open_after_release(dev: &str, dop: DopFormat) -> Result<(PCM, usize), PcmOpenError> { +fn open_after_release( + mut attempt: impl FnMut() -> Result, +) -> Result { const STEP: Duration = Duration::from_millis(50); let deadline = Instant::now() + super::device_reservation::RELEASE_GRACE; loop { // Try before waiting: a server that let go promptly costs - // nothing, and a device that simply cannot do this DoP rate + // nothing, and a device that simply cannot do what we're asking // says so on the first attempt. - match open_pcm(dev, dop) { + match attempt() { Ok(opened) => return Ok(opened), Err(failure) if failure.busy && Instant::now() < deadline => { std::thread::sleep(STEP); @@ -374,7 +435,7 @@ fn open_after_release(dev: &str, dop: DopFormat) -> Result<(PCM, usize), PcmOpen /// negotiated period size (frames). A rejection (busy device, rate / /// format unsupported) is an error → the caller either reserves the /// card and retries, or falls back to DSD → PCM. -fn open_pcm(dev: &str, dop: DopFormat) -> Result<(PCM, usize), PcmOpenError> { +fn open_dop_pcm(dev: &str, dop: DopFormat) -> Result<(PCM, usize), PcmOpenError> { let pcm = PCM::new(dev, Direction::Playback, false).map_err(|e| PcmOpenError { busy: e.errno() == libc::EBUSY, err: AppError::Audio(format!("alsa open {dev}: {e}")), @@ -393,6 +454,7 @@ fn open_pcm(dev: &str, dop: DopFormat) -> Result<(PCM, usize), PcmOpenError> { .map_err(|e| AppError::Audio(format!("alsa set_format S32_LE: {e}")))?; hwp.set_access(Access::RWInterleaved) .map_err(|e| AppError::Audio(format!("alsa set_access: {e}")))?; + set_period_and_buffer(&hwp)?; pcm.hw_params(&hwp) .map_err(|e| AppError::Audio(format!("alsa hw_params: {e}")))?; } @@ -426,10 +488,621 @@ fn open_pcm(dev: &str, dop: DopFormat) -> Result<(PCM, usize), PcmOpenError> { Ok((pcm, period_frames)) } +/// Rate asked for when the engine has not settled on one yet (nothing +/// has played since launch). 48 kHz rather than 44.1 because it is the +/// rate a modern DAC is most likely to run natively. +const DEFAULT_PCM_RATE: u32 = 48_000; + +/// The wire formats the PCM path will accept from a `hw:` device, best +/// first. Falling all the way through means the device speaks nothing we +/// can write, and the caller drops to cpal shared mode. +/// +/// This mirrors [`super::wasapi_exclusive`]'s chain in intent but **not** +/// in layout — see [`AlsaSampleFormat::S24In32`] for the one place where +/// copying the other backend's packer would be a 48 dB mistake. +const FORMAT_FALLBACK_CHAIN: [AlsaSampleFormat; 5] = [ + AlsaSampleFormat::F32, + AlsaSampleFormat::S32, + AlsaSampleFormat::S24Packed, + AlsaSampleFormat::S24In32, + AlsaSampleFormat::S16, +]; + +/// A sample format a raw `hw:` device can be opened with. +/// +/// There is no plug layer underneath us — that is the entire point of +/// `hw:` — so every one of these is a format the hardware itself +/// accepts, and the conversion from the ring's `f32` is ours to do. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AlsaSampleFormat { + /// `SND_PCM_FORMAT_FLOAT_LE`. Exactly what the ring already holds, + /// so the "conversion" is a byte copy. Uncommon on USB DACs (the + /// converter is integer), routine on HDMI and on some onboard + /// codecs — worth one attempt for the devices that do take it. + F32, + /// `SND_PCM_FORMAT_S32_LE`. The whole 32-bit container is + /// significant. What most audiophile USB DACs advertise even when + /// the converter behind it stops at 24 bits, which costs us + /// nothing: an `f32` carries 24 bits of mantissa anyway. + S32, + /// `SND_PCM_FORMAT_S24_3LE` — 24 bits, three bytes, no container + /// and so no alignment question. Preferred over [`Self::S24In32`] + /// for exactly that reason. + S24Packed, + /// `SND_PCM_FORMAT_S24_LE`: 24 bits **right-aligned in the low three + /// bytes** of a 32-bit word. + /// + /// This is the exact opposite of WASAPI's `Pcm24Padded`, where + /// `WAVEFORMATEXTENSIBLE` left-aligns the valid bits and zero-pads + /// the bottom. The same 24 bits, shifted by 8 in opposite + /// directions: apply that backend's `<< 8` here and every sample is + /// multiplied by 256 into permanent clipping; apply this one's + /// layout there and the signal comes out 48 dB down. Two APIs, two + /// conventions, one standing temptation to copy the other's packer. + /// + /// The top byte carries the sign extension a plain `i32` store + /// produces. ALSA defines the format as "the low three bytes", so + /// the driver reads those and what sits above them is ignored. + S24In32, + /// `SND_PCM_FORMAT_S16_LE`. Universal last resort. + S16, +} + +impl AlsaSampleFormat { + /// Bytes this format occupies on the wire, per channel per frame. + fn bytes_per_sample(self) -> usize { + match self { + Self::F32 | Self::S32 | Self::S24In32 => 4, + Self::S24Packed => 3, + Self::S16 => 2, + } + } + + fn to_alsa(self) -> Format { + match self { + Self::F32 => Format::FloatLE, + Self::S32 => Format::S32LE, + Self::S24Packed => Format::S243LE, + Self::S24In32 => Format::S24LE, + Self::S16 => Format::S16LE, + } + } + + /// Label for the diagnostics line, matching ALSA's own spelling so + /// it can be grepped against `aplay --dump-hw-params`. + fn label(self) -> &'static str { + match self { + Self::F32 => "FLOAT_LE", + Self::S32 => "S32_LE", + Self::S24Packed => "S24_3LE", + Self::S24In32 => "S24_LE", + Self::S16 => "S16_LE", + } + } +} + +/// Frames per period we ask ALSA for — about 23 ms at 44.1 kHz. +const TARGET_PERIOD_FRAMES: Frames = 1024; + +/// Periods per buffer. Four is the usual choice: deep enough to absorb a +/// scheduling hiccup on a thread that is not realtime, shallow enough +/// that a pause or a seek is heard now rather than after the buffer +/// plays out. +const TARGET_PERIODS: Frames = 4; + +/// Ask for a period the ring can feed and a buffer only a few periods +/// deep, on both streams. +/// +/// Without this, `HwParams::any` leaves the sizes at whatever the driver +/// offers, and `snd_pcm_hw_params` then takes its maximum. Measured on a +/// `snd-dummy` card: a 16 384-frame period, and a buffer deep enough that +/// starting a track, seeking and changing track each took about ten +/// seconds — the wait was the buffer draining. +/// +/// The period also has to stay small against +/// [`super::output::RING_CAPACITY`], because one period is drained from +/// the ring in a single pass and whatever the ring cannot supply is +/// written as silence. At 16 384 frames a period was two thirds of the +/// whole ring, which makes an underrun the normal case rather than the +/// exception. +fn set_period_and_buffer(hwp: &HwParams<'_>) -> AppResult<()> { + hwp.set_period_size_near(TARGET_PERIOD_FRAMES, ValueOr::Nearest) + .map_err(|e| AppError::Audio(format!("alsa set_period_size: {e}")))?; + hwp.set_buffer_size_near(TARGET_PERIOD_FRAMES * TARGET_PERIODS) + .map_err(|e| AppError::Audio(format!("alsa set_buffer_size: {e}")))?; + Ok(()) +} + +/// Pack the mixed `f32` samples into the byte image the negotiated +/// format expects, little-endian throughout. +/// +/// Saturation is done by clamping to `[-1.0, 1.0]` before scaling rather +/// than by checking the result: `as` casts on floats saturate in Rust, +/// so an out-of-range sample would land on `i32::MAX` silently instead +/// of at full scale. Gain (volume, normalize, the mono mix) is applied +/// upstream in [`super::output::fill_pcm_period`], so a sample that clips +/// here is one +/// the chain genuinely pushed past 0 dBFS. +/// +/// Every path is bounded by both slice lengths, so a short `samples` +/// leaves the tail of `bytes` at whatever it held — the caller keeps one +/// buffer for the life of the stream and rewrites it whole each period. +fn pack_samples(format: AlsaSampleFormat, samples: &[f32], bytes: &mut [u8]) { + match format { + AlsaSampleFormat::F32 => { + for (sample, chunk) in samples.iter().zip(bytes.chunks_exact_mut(4)) { + chunk.copy_from_slice(&sample.to_le_bytes()); + } + } + AlsaSampleFormat::S32 => { + for (sample, chunk) in samples.iter().zip(bytes.chunks_exact_mut(4)) { + // Scaled in f64 because an f32 cannot hold the scale + // factor: `2_147_483_647.0f32` rounds to 2^31, so every + // sample would be scaled by a hair too much and full + // scale would only land right because the cast saturates. + let v = (f64::from(sample.clamp(-1.0, 1.0)) * 2_147_483_647.0) as i32; + chunk.copy_from_slice(&v.to_le_bytes()); + } + } + AlsaSampleFormat::S24Packed => { + for (sample, chunk) in samples.iter().zip(bytes.chunks_exact_mut(3)) { + let v = (sample.clamp(-1.0, 1.0) * 8_388_607.0) as i32; + chunk[0] = (v & 0xFF) as u8; + chunk[1] = ((v >> 8) & 0xFF) as u8; + chunk[2] = ((v >> 16) & 0xFF) as u8; + } + } + AlsaSampleFormat::S24In32 => { + // Right-aligned: the 24-bit value sits in the low three + // bytes, unshifted. See the variant's doc — this is where + // WASAPI's `<< 8` does not belong. + for (sample, chunk) in samples.iter().zip(bytes.chunks_exact_mut(4)) { + let v = (sample.clamp(-1.0, 1.0) * 8_388_607.0) as i32; + chunk.copy_from_slice(&v.to_le_bytes()); + } + } + AlsaSampleFormat::S16 => { + for (sample, chunk) in samples.iter().zip(bytes.chunks_exact_mut(2)) { + let v = (sample.clamp(-1.0, 1.0) * 32_767.0) as i16; + chunk.copy_from_slice(&v.to_le_bytes()); + } + } + } +} + +/// A device opened and negotiated, with the terms it agreed to. +struct OpenPcm { + pcm: PCM, + format: AlsaSampleFormat, + /// What the device landed on, which is not necessarily what we + /// asked for — the caller publishes this so the decoder resamples + /// to it. + sample_rate: u32, + channels: u16, + period_frames: usize, + /// Logged, not used: it is the number that says how long a pause or + /// a seek takes to be heard, so a latency report can be read without + /// asking for another run. + buffer_frames: Frames, +} + +/// Open `dev` for ordinary PCM, walking the format chain until one +/// sticks. +/// +/// The rate is a preference here, not a demand ([`ValueOr::Nearest`]): +/// a device that only does 44.1 kHz is still a device we can drive, we +/// just publish 44.1 and let the decoder's resampler meet it. That is +/// the one substantive difference from [`open_dop_pcm`], where a rate +/// the device can't do exactly has to fail the open. +/// +/// Both preferences arrive from `SharedPlayback`, where **zero means +/// "no output has ever opened"** rather than a real value — the state +/// starts at 0 and is only filled in by whichever backend opened last. +/// Taken literally, a zero channel count would ask the card for mono +/// on the first launch with exclusive already on, and a card that +/// accepts mono would get it: every track downmixed, for as long as +/// the setting stayed on. +fn open_pcm_negotiated( + dev: &str, + preferred_rate: u32, + preferred_channels: u16, +) -> Result { + let rate = match preferred_rate { + 0 => DEFAULT_PCM_RATE, + rate => rate, + }; + let mut last: Option = None; + for channels in channel_candidates(preferred_channels) { + for format in FORMAT_FALLBACK_CHAIN { + match try_open_pcm(dev, channels, rate, format) { + Ok(opened) => return Ok(opened), + // A card another client holds gives the same answer to + // every format in the list. Stop and let the caller ask + // for it through the reservation protocol instead of + // knocking nine more times. + Err(failure) if failure.busy => return Err(failure), + Err(failure) => last = Some(failure), + } + } + } + + Err(last.unwrap_or_else(|| { + AppError::Audio(format!( + "alsa: {dev} accepted none of the formats we know how to write" + )) + .into() + })) +} + +/// Channel counts to try, in order. +/// +/// Stereo closes the list because it is the layout every DAC takes, +/// while asking for the engine's current count first keeps a +/// multichannel output from being quietly folded down to two. Zero is +/// the "nothing has opened an output yet" reading, not a request for +/// no channels — see [`open_pcm_negotiated`]. +/// +/// Split out from the open so that reading can be checked without a +/// sound card, which is the only place it can be checked at all. +fn channel_candidates(preferred: u16) -> Vec { + let mut out = vec![match preferred { + 0 => 2, + channels => channels, + }]; + if !out.contains(&2) { + out.push(2); + } + out +} + +/// One attempt at one (channels, rate, format) triple. A fresh handle +/// per attempt: a rejected `hw_params` leaves the PCM in a state we would +/// have to reason about, and re-opening a `hw:` device costs microseconds. +fn try_open_pcm( + dev: &str, + channels: u16, + rate: u32, + format: AlsaSampleFormat, +) -> Result { + let pcm = PCM::new(dev, Direction::Playback, false).map_err(|e| PcmOpenError { + busy: e.errno() == libc::EBUSY, + err: AppError::Audio(format!("alsa open {dev}: {e}")), + })?; + + { + let hwp = + HwParams::any(&pcm).map_err(|e| AppError::Audio(format!("alsa hwparams: {e}")))?; + hwp.set_channels(u32::from(channels)) + .map_err(|e| AppError::Audio(format!("alsa set_channels {channels}: {e}")))?; + hwp.set_rate(rate, ValueOr::Nearest) + .map_err(|e| AppError::Audio(format!("alsa set_rate {rate}: {e}")))?; + hwp.set_format(format.to_alsa()) + .map_err(|e| AppError::Audio(format!("alsa set_format {}: {e}", format.label())))?; + hwp.set_access(Access::RWInterleaved) + .map_err(|e| AppError::Audio(format!("alsa set_access: {e}")))?; + set_period_and_buffer(&hwp)?; + pcm.hw_params(&hwp) + .map_err(|e| AppError::Audio(format!("alsa hw_params: {e}")))?; + } + + // Scoped: `hw_params_current` borrows the PCM, and the borrow would + // otherwise still be live at the `Ok(OpenPcm { pcm, .. })` move. + let (sample_rate, channels, period_frames, buffer_frames) = { + let hwp = pcm + .hw_params_current() + .map_err(|e| AppError::Audio(format!("alsa hw_params_current: {e}")))?; + ( + hwp.get_rate() + .map_err(|e| AppError::Audio(format!("alsa get_rate: {e}")))?, + hwp.get_channels() + .map_err(|e| AppError::Audio(format!("alsa get_channels: {e}")))?, + hwp.get_period_size() + .map_err(|e| AppError::Audio(format!("alsa get_period_size: {e}")))? + as usize, + hwp.get_buffer_size() + .map_err(|e| AppError::Audio(format!("alsa get_buffer_size: {e}")))?, + ) + }; + if period_frames == 0 { + return Err(AppError::Audio("alsa reported a zero period size".into()).into()); + } + if channels == 0 { + return Err(AppError::Audio("alsa reported zero channels".into()).into()); + } + + pcm.prepare() + .map_err(|e| AppError::Audio(format!("alsa prepare: {e}")))?; + + Ok(OpenPcm { + pcm, + format, + sample_rate, + channels: channels as u16, + period_frames, + buffer_frames, + }) +} + +/// Why [`write_full_period`] came back. +enum PeriodOutcome { + /// The whole period reached the device. + Written, + /// The engine asked us to stop while we were writing. + Shutdown, + /// The device is gone; the string is what to report. + DeviceLost(String), +} + +/// Hand one whole period to the device, re-offering whatever `writei` +/// declined to take. +/// +/// The PCM is opened blocking, but `writei` is still allowed to accept +/// fewer frames than offered — on a signal, or after a recovery that +/// swallowed part of the period. The tail has to be re-offered rather +/// than dropped, and only the *unwritten* remainder is re-sent, never +/// frames the device already took. +/// +/// `items_per_frame` is the stride of one frame in `buf`'s own unit: +/// the channel count for the DoP path's `i32` words, the frame's byte +/// width for the PCM path's packed bytes. +fn write_full_period( + pcm: &PCM, + io: &IO<'_, S>, + buf: &[S], + items_per_frame: usize, + frames: usize, + shutdown_rx: &Receiver<()>, +) -> PeriodOutcome { + let mut frames_done = 0usize; + while frames_done < frames { + match io.writei(&buf[frames_done * items_per_frame..]) { + Ok(0) => { + // A blocking device that reports no progress and no + // error has nothing left to recover from. + return PeriodOutcome::DeviceLost( + "alsa accepted 0 frames on a blocking write".into(), + ); + } + Ok(n) => frames_done += n, + Err(err) => { + if shutdown_rx.try_recv().is_ok() { + return PeriodOutcome::Shutdown; + } + if let Err(rec) = pcm.try_recover(err, true) { + tracing::warn!(?rec, "alsa write failed and recovery failed"); + return PeriodOutcome::DeviceLost(format!("alsa write failed: {rec}")); + } + // Recovered - re-prepare if needed, then retry the tail. + if pcm.state() == State::Setup { + let _ = pcm.prepare(); + } + } + } + } + PeriodOutcome::Written +} + +/// The ordinary-PCM half of the backend: the audiophile path for every +/// track that isn't DSD. +fn pcm_output_thread_main( + shared: Arc, + mut consumer: Consumer, + shutdown_rx: Receiver<()>, + init_tx: Sender>, + app: AppHandle, + device_name: Option, +) { + let dev = match resolve_hw_device(&device_name) { + Ok(dev) => dev, + Err(err) => { + tracing::warn!(%err, "alsa exclusive: can't map the selected output to a hw: device"); + let _ = init_tx.send(Err(err)); + return; + } + }; + + // Ask for what the engine is already running at — the cpal default, + // or whatever a previous output negotiated — so that turning + // exclusive on doesn't also silently change the resampler's target. + // Both can still be zero here, meaning nothing has opened an output + // yet; `open_pcm_negotiated` owns that reading. + let preferred_rate = shared.sample_rate.load(Ordering::Acquire); + let preferred_channels = shared.channels.load(Ordering::Acquire); + + let (_reservation, opened) = match open_reserving_the_card(&dev, "pcm", || { + open_pcm_negotiated(&dev, preferred_rate, preferred_channels) + }) { + Ok(opened) => opened, + Err(err) => { + tracing::warn!( + %err, + device = %dev, + "alsa exclusive init failed; falling back to shared mode" + ); + let _ = init_tx.send(Err(err)); + return; + } + }; + let OpenPcm { + pcm, + format, + sample_rate, + channels, + period_frames, + buffer_frames, + } = opened; + + // `io_bytes` rather than a typed `io_*`: the packed image is bytes + // whatever the negotiated format is, and ALSA derives the frame + // count from the buffer's byte length against the format it agreed + // to — so one write path covers all five. + let io = pcm.io_bytes(); + + shared.sample_rate.store(sample_rate, Ordering::Release); + shared.channels.store(channels, Ordering::Release); + let _ = init_tx.send(Ok(())); + + let channels = channels as usize; + let frame_bytes = channels * format.bytes_per_sample(); + + tracing::info!( + device = %dev, + sample_rate, + channels, + format = format.label(), + period_frames, + buffer_frames, + "alsa exclusive stream opened" + ); + + let mut samples: Vec = vec![0.0; period_frames * channels]; + let mut wire: Vec = vec![0u8; period_frames * frame_bytes]; + // Every format in the chain is signed and little-endian, and silence + // in all of them is an all-zero byte pattern — so one pre-zeroed + // buffer serves the paused and drain paths whichever we landed on. + let silence: Vec = vec![0u8; period_frames * frame_bytes]; + + let exit = 'run: loop { + if shutdown_rx.try_recv().is_ok() { + break ExitReason::Shutdown; + } + + let out: &[u8] = if shared.paused_output.load(Ordering::Acquire) { + // Hard pause: write silence so the device doesn't underrun + // and click, and so the pause is heard now rather than after + // the pre-buffer drains. + &silence + } else if shared.drain_silent.load(Ordering::Acquire) { + // Drop whatever is queued and emit silence, so the decoder's + // spin-wait on an empty ring completes within one period. + while consumer.pop().is_ok() {} + &silence + } else { + let written = + super::output::fill_pcm_period(&shared, &mut consumer, &mut samples, channels); + pack_samples(format, &samples, &mut wire); + if written > 0 { + shared.samples_played.fetch_add(written, Ordering::Relaxed); + } + &wire + }; + + match write_full_period(&pcm, &io, out, frame_bytes, period_frames, &shutdown_rx) { + PeriodOutcome::Written => {} + PeriodOutcome::Shutdown => break 'run ExitReason::Shutdown, + PeriodOutcome::DeviceLost(reason) => break 'run ExitReason::DeviceLost(reason), + } + + if shutdown_rx.try_recv().is_ok() { + break ExitReason::Shutdown; + } + }; + + // `io` borrows `pcm`; both drop here. Dropping the PCM calls + // `snd_pcm_close`, which releases the exclusive `hw:` handle for the + // next opener — the sound server included. + drop(io); + + match exit { + ExitReason::Shutdown => { + tracing::debug!("alsa exclusive output thread exiting"); + } + ExitReason::DeviceLost(reason) => { + tracing::warn!( + %reason, + "alsa exclusive output thread lost the device; requesting rebuild" + ); + super::output::notify_device_lost( + &app, + &shared, + format!("audio device error: {reason}"), + ); + super::output::schedule_device_rebuild(&app, super::output::RebuildTarget::Resolve); + } + } +} + #[cfg(test)] mod tests { use super::hw_card_index; + use super::{pack_samples, AlsaSampleFormat}; + + #[test] + fn an_unopened_engine_asks_for_stereo_not_mono() { + // `SharedPlayback` starts at zero channels and only fills in + // when a backend opens. Read literally, the first launch with + // exclusive already on would ask the card for one channel — and + // a card that grants it would downmix every track from then on. + assert_eq!(super::channel_candidates(0), vec![2]); + } + + #[test] + fn a_known_layout_is_tried_before_stereo_and_stereo_closes_the_list() { + assert_eq!(super::channel_candidates(6), vec![6, 2]); + assert_eq!(super::channel_candidates(2), vec![2]); + // A genuinely mono device keeps its own layout first. + assert_eq!(super::channel_candidates(1), vec![1, 2]); + } + + /// The trap this whole variant exists to document. ALSA puts the 24 + /// bits in the LOW three bytes; WASAPI's 24-in-32 puts them in the + /// HIGH three. Copying that backend's `<< 8` over here would send + /// every sample out 256x too large. + #[test] + fn twenty_four_in_thirty_two_is_right_aligned_not_left() { + let mut bytes = [0u8; 4]; + pack_samples(AlsaSampleFormat::S24In32, &[1.0], &mut bytes); + // 8_388_607 = 0x7F_FF_FF, little-endian, top byte untouched. + assert_eq!(bytes, [0xFF, 0xFF, 0x7F, 0x00]); + // The left-aligned layout would have been [0x00, 0xFF, 0xFF, 0x7F]. + assert_ne!(bytes, [0x00, 0xFF, 0xFF, 0x7F]); + } + + #[test] + fn a_negative_twenty_four_bit_sample_keeps_its_sign_extension() { + let mut bytes = [0u8; 4]; + pack_samples(AlsaSampleFormat::S24In32, &[-1.0], &mut bytes); + // -8_388_607 as i32 = 0xFF_80_00_01. The driver reads the low + // three bytes; the 0xFF above them is the sign extension a plain + // i32 store produces and is ignored. + assert_eq!(bytes, [0x01, 0x00, 0x80, 0xFF]); + } + + #[test] + fn the_packed_form_spends_three_bytes_and_no_container() { + let mut bytes = [0u8; 3]; + pack_samples(AlsaSampleFormat::S24Packed, &[1.0], &mut bytes); + assert_eq!(bytes, [0xFF, 0xFF, 0x7F]); + } + + #[test] + fn sixteen_bit_full_scale_lands_on_the_endpoints() { + let mut bytes = [0u8; 4]; + pack_samples(AlsaSampleFormat::S16, &[1.0, -1.0], &mut bytes); + assert_eq!(i16::from_le_bytes([bytes[0], bytes[1]]), 32_767); + assert_eq!(i16::from_le_bytes([bytes[2], bytes[3]]), -32_767); + } + + #[test] + fn a_sample_past_full_scale_saturates_instead_of_wrapping() { + // Anything above 0 dBFS is clamped before the scale, so it comes + // out at the endpoint. Wrapping here would turn a loud passage + // into a burst of full-scale noise of the opposite sign. + let mut bytes = [0u8; 4]; + pack_samples(AlsaSampleFormat::S32, &[9.0], &mut bytes); + assert_eq!(i32::from_le_bytes(bytes), 2_147_483_647); + pack_samples(AlsaSampleFormat::S32, &[-9.0], &mut bytes); + assert_eq!(i32::from_le_bytes(bytes), -2_147_483_647); + } + + #[test] + fn float_output_is_a_byte_copy() { + let mut bytes = [0u8; 4]; + pack_samples(AlsaSampleFormat::F32, &[0.25], &mut bytes); + assert_eq!(f32::from_le_bytes(bytes), 0.25); + } + #[test] fn a_numeric_hw_name_yields_its_index() { assert_eq!(hw_card_index("hw:0,0"), Some(0)); diff --git a/src-tauri/crates/app/src/audio/coreaudio_exclusive.rs b/src-tauri/crates/app/src/audio/coreaudio_exclusive.rs index b13505c6..c08a265a 100644 --- a/src-tauri/crates/app/src/audio/coreaudio_exclusive.rs +++ b/src-tauri/crates/app/src/audio/coreaudio_exclusive.rs @@ -1,4 +1,5 @@ -//! macOS-only CoreAudio hog-mode DoP output backend (#495 / #497). +//! macOS-only CoreAudio hog-mode output backend (#495 / #497 for DoP, +//! then ordinary PCM). //! //! The macOS equivalent of [`super::wasapi_exclusive`] / [`super::alsa_exclusive`]: //! it takes **hog mode** on the output device (exclusive access — the @@ -20,6 +21,13 @@ //! won't accept the DoP rate in 32-bit, the open fails and the engine //! falls back to the ordinary DSD → PCM path. //! +//! **Ordinary PCM** goes through [`open_and_run_pcm`] instead, and the +//! difference is what it does *not* do: it takes hog mode and leaves the +//! device's physical format exactly as it found it. Re-clocking a device +//! the whole machine shares is a price only a marker cadence justifies; +//! for PCM the decoder meets the device instead. A failed open there +//! falls back to cpal shared mode. +//! //! Same SPSC ring contract as the other backends (`Producer` → //! `Consumer`, words carried as `f32` bit patterns). @@ -61,11 +69,11 @@ const DEVICE_POLL_INTERVAL: Duration = Duration::from_secs(1); /// [`OutputHandle`], or an error (device busy / hog denied / format /// unsupported) surfaced synchronously so the caller can fall back to /// DSD → PCM. -pub fn spawn_coreaudio_dop_output_thread( +pub fn spawn_coreaudio_exclusive_output_thread( shared: Arc, app: AppHandle, device_name: Option, - dop: DopFormat, + dop: Option, ) -> AppResult<(Producer, OutputHandle)> { let (producer, consumer) = RingBuffer::::new(RING_CAPACITY); let (shutdown_tx, shutdown_rx) = bounded::<()>(1); @@ -75,7 +83,10 @@ pub fn spawn_coreaudio_dop_output_thread( let thread_app = app.clone(); let thread_device = device_name.clone(); let join: JoinHandle<()> = std::thread::Builder::new() - .name("waveflow-coreaudio-dop".into()) + .name(match dop { + Some(_) => "waveflow-coreaudio-dop".into(), + None => "waveflow-coreaudio-exclusive".to_string(), + }) .spawn(move || { output_thread_main( thread_shared, @@ -87,7 +98,7 @@ pub fn spawn_coreaudio_dop_output_thread( dop, ) }) - .map_err(|e| AppError::Audio(format!("spawn coreaudio dop thread: {e}")))?; + .map_err(|e| AppError::Audio(format!("spawn coreaudio exclusive thread: {e}")))?; match init_rx.recv() { Ok(Ok(())) => Ok(( @@ -96,8 +107,12 @@ pub fn spawn_coreaudio_dop_output_thread( shutdown_tx, join, device_name, - wasapi_exclusive: false, - dop: Some(dop), + // Hog mode means the system stops mixing anything + // else into this device — the same ownership WASAPI + // and ALSA report. It used to say `false` here, which + // had the pipeline panel deny a grab that had happened. + exclusive: true, + dop, }, )), Ok(Err(err)) => { @@ -105,7 +120,7 @@ pub fn spawn_coreaudio_dop_output_thread( Err(err) } Err(_) => Err(AppError::Audio( - "coreaudio dop thread died before reporting init result".into(), + "coreaudio exclusive thread died before reporting init result".into(), )), } } @@ -117,13 +132,13 @@ fn output_thread_main( init_tx: Sender>, app: AppHandle, device_name: Option, - dop: DopFormat, + dop: Option, ) { let device_id = match resolve_device(&device_name) { Some(id) => id, None => { let _ = init_tx.send(Err(AppError::Audio( - "coreaudio: no output device found for DoP".into(), + "coreaudio: no output device found".into(), ))); return; } @@ -137,12 +152,15 @@ fn output_thread_main( } // Everything past hog acquisition must release it on the way out. - let result = open_and_run(&shared, consumer, &shutdown_rx, &init_tx, device_id, dop); + let result = match dop { + Some(dop) => open_and_run(&shared, consumer, &shutdown_rx, &init_tx, device_id, dop), + None => open_and_run_pcm(&shared, consumer, &shutdown_rx, &init_tx, device_id), + }; release_hog(device_id); match result { - Ok(ExitReason::Shutdown) => tracing::debug!("coreaudio dop output thread exiting"), + Ok(ExitReason::Shutdown) => tracing::debug!("coreaudio exclusive output thread exiting"), // Same contract as the WASAPI / ALSA backends (#405): a device // that vanishes mid-stream must be reported, or the engine keeps // a handle to a thread that no longer feeds anything and the UI @@ -150,7 +168,7 @@ fn output_thread_main( Ok(ExitReason::DeviceLost(reason)) => { tracing::warn!( %reason, - "coreaudio dop output thread lost the device; requesting rebuild" + "coreaudio exclusive output thread lost the device; requesting rebuild" ); super::output::notify_device_lost( &app, @@ -159,7 +177,7 @@ fn output_thread_main( ); super::output::schedule_device_rebuild(&app, super::output::RebuildTarget::Resolve); } - Err(err) => tracing::warn!(%err, "coreaudio dop output thread stopped on error"), + Err(err) => tracing::warn!(%err, "coreaudio exclusive output thread stopped on error"), } } @@ -300,15 +318,156 @@ fn open_and_run( // Init succeeded — tell the spawn call, then park until teardown. let _ = init_tx.send(Ok(())); - // CoreAudio doesn't error into our thread when the DAC is unplugged: - // it simply stops pulling the render callback, which would leave us - // parked forever on a dead output. `AliveListener` subscribes to the - // device's `IsAlive` property, so the loss arrives as a flag flip and - // the wait below only decides how soon we look at it. - // - // It registers a listener holding a pointer to itself, so it must not - // move afterwards — it stays a local of this frame, and its `Drop` - // unregisters. + let exit = park_until_the_device_goes(shutdown_rx, device_id); + + // Stop + drop the unit here (frees the render callback + its captured + // ring consumer) before the caller releases hog mode. + let _ = audio_unit.stop(); + drop(audio_unit); + Ok(exit) +} + +/// The ordinary-PCM half: hog the device and feed it `f32` at whatever +/// rate it is already running. +/// +/// Deliberately lighter than [`open_and_run`]. DoP has to pin the +/// device's *physical* format, because a marker cadence that gets +/// resampled is noise; ordinary PCM does not, and pinning it would +/// re-clock a device the rest of the machine shares for no gain — our +/// decoder can meet the device instead. So this takes the rate and the +/// channel count the device already has, publishes them, and lets the +/// resampler do the meeting. What exclusive buys here is hog mode: the +/// system stops mixing anything else in. +fn open_and_run_pcm( + shared: &Arc, + mut consumer: Consumer, + shutdown_rx: &Receiver<()>, + init_tx: &Sender>, + device_id: AudioDeviceID, +) -> AppResult { + // Read, don't set: this is the device's own current format, and the + // whole point of the PCM path is that we leave it alone. + let current = read_physical_stream_format(device_id).map_err(|e| { + AppError::Audio(format!( + "coreaudio: can't read the device's current physical format ({e})" + )) + })?; + + // A device reporting neither is one we can't describe to the + // decoder. Falling back to invented numbers would publish a rate the + // hardware isn't running at, and every track would play at the wrong + // speed rather than not at all. + let sample_rate = current.mSampleRate; + if !(sample_rate.is_finite() && sample_rate > 0.0) { + return Err(AppError::Audio(format!( + "coreaudio: device reports a {sample_rate} Hz format" + ))); + } + let channels = current.mChannelsPerFrame; + if channels == 0 { + return Err(AppError::Audio( + "coreaudio: device reports zero channels".into(), + )); + } + + let stream_format = StreamFormat { + sample_rate, + sample_format: SampleFormat::F32, + flags: LinearPcmFlags::IS_FLOAT | LinearPcmFlags::IS_PACKED, + channels, + }; + + let mut audio_unit = audio_unit_from_device_id_uninitialized(device_id, false) + .map_err(|e| AppError::Audio(format!("coreaudio audio unit: {e}")))?; + audio_unit + .set_stream_format(stream_format, Scope::Input, Element::Output) + .map_err(|e| AppError::Audio(format!("coreaudio set stream format: {e}")))?; + + // Runs on CoreAudio's realtime thread: ring pops and atomics only, + // no allocation and no locking. + let cb_shared = shared.clone(); + audio_unit + .set_render_callback(move |args: Args>| { + let Args { + data: data::Interleaved { + buffer, channels, .. + }, + num_frames, + .. + } = args; + // Never trust the two to agree: a shorter buffer than the + // frame count implies would panic on the slice, on the one + // thread that must not. + let wanted = num_frames.saturating_mul(channels).min(buffer.len()); + let buffer = &mut buffer[..wanted]; + + let paused = cb_shared.paused_output.load(Ordering::Acquire); + let draining = cb_shared.drain_silent.load(Ordering::Acquire); + if paused || draining { + if draining { + while consumer.pop().is_ok() {} + } + buffer.fill(0.0); + } else { + let written = + super::output::fill_pcm_period(&cb_shared, &mut consumer, buffer, channels); + if written > 0 { + cb_shared + .samples_played + .fetch_add(written, Ordering::Relaxed); + } + } + Ok(()) + }) + .map_err(|e| AppError::Audio(format!("coreaudio set render callback: {e}")))?; + + audio_unit + .initialize() + .map_err(|e| AppError::Audio(format!("coreaudio initialize: {e}")))?; + + // Published as integers because that is what the rest of the engine + // speaks; CoreAudio carries the rate as a float, and a device on a + // fractional rate would be rounded here rather than silently + // mismatched later. + shared + .sample_rate + .store(sample_rate.round() as u32, Ordering::Release); + shared.channels.store(channels as u16, Ordering::Release); + + audio_unit + .start() + .map_err(|e| AppError::Audio(format!("coreaudio start: {e}")))?; + + tracing::info!( + device_id, + sample_rate, + channels, + "coreaudio exclusive stream opened" + ); + + let _ = init_tx.send(Ok(())); + + let exit = park_until_the_device_goes(shutdown_rx, device_id); + + // Stop + drop the unit here (frees the render callback and the ring + // consumer it captured) before the caller releases hog mode. + let _ = audio_unit.stop(); + drop(audio_unit); + Ok(exit) +} + +/// Park until the engine asks us to stop, or the device goes away. +/// +/// CoreAudio doesn't error into our thread when the DAC is unplugged: it +/// simply stops pulling the render callback, which would leave us parked +/// forever on a dead output. `AliveListener` subscribes to the device's +/// `IsAlive` property, so the loss arrives as a flag flip and the wait +/// below only decides how soon we look at it. +/// +/// The listener registers a pointer to itself, so it must not move once +/// registered — it stays a local of this frame, and its `Drop` +/// unregisters. +fn park_until_the_device_goes(shutdown_rx: &Receiver<()>, device_id: AudioDeviceID) -> ExitReason { let mut alive = AliveListener::new(device_id); let watching = match alive.register() { Ok(()) => true, @@ -321,7 +480,7 @@ fn open_and_run( } }; - let exit = loop { + loop { match shutdown_rx.recv_timeout(DEVICE_POLL_INTERVAL) { Ok(()) | Err(RecvTimeoutError::Disconnected) => break ExitReason::Shutdown, Err(RecvTimeoutError::Timeout) => { @@ -337,13 +496,7 @@ fn open_and_run( } } } - }; - - // Stop + drop the unit here (frees the render callback + its captured - // ring consumer) before the caller releases hog mode. - let _ = audio_unit.stop(); - drop(audio_unit); - Ok(exit) + } } /// Puts the device's physical stream format back when the DoP stream diff --git a/src-tauri/crates/app/src/audio/engine.rs b/src-tauri/crates/app/src/audio/engine.rs index e44f8c79..8f4f53d2 100644 --- a/src-tauri/crates/app/src/audio/engine.rs +++ b/src-tauri/crates/app/src/audio/engine.rs @@ -339,15 +339,17 @@ pub struct AudioEngine { /// `set_output_device` without plumbing the handle through every /// Tauri command call site. app: AppHandle, - /// Windows-only opt-in: WASAPI Exclusive Mode preference. Read - /// at boot from `profile_setting['audio.wasapi_exclusive']`, - /// flipped by `set_wasapi_exclusive`. Used by `set_output_device` - /// to preserve the mode across hot-swaps. - wasapi_exclusive: std::sync::atomic::AtomicBool, - /// Whether the current output stream is actually running in - /// WASAPI Exclusive Mode. This can differ from the preference - /// when init falls back to cpal shared mode. - wasapi_exclusive_active: std::sync::atomic::AtomicBool, + /// Opt-in: own the output device outright rather than share it + /// with the system mixer — WASAPI Exclusive Mode on Windows, a raw + /// `hw:` device on Linux, hog mode on macOS. Read at boot from + /// `profile_setting['audio.exclusive_output']`, flipped by + /// `set_exclusive_output`. Used by `set_output_device` to preserve + /// the mode across hot-swaps. + exclusive_output: std::sync::atomic::AtomicBool, + /// Whether the current output stream really owns its device. This + /// can differ from the preference when init falls back to cpal + /// shared mode. + exclusive_output_active: std::sync::atomic::AtomicBool, /// Debounce guard for [`Self::try_rebuild_after_device_error`] /// (#175). Windows session resets and USB DAC flaps fire the /// cpal `DeviceNotAvailable` callback on a random thread; the @@ -355,9 +357,9 @@ pub struct AudioEngine { /// flap would otherwise queue two concurrent rebuilds that /// each interrupt the same track. rebuild_in_progress: std::sync::atomic::AtomicBool, - /// Session-only kill switch for WASAPI Exclusive after a flap storm + /// Session-only kill switch for exclusive output after a flap storm /// (#322). Once tripped, every rebuild / hot-swap stays on cpal - /// shared regardless of the `wasapi_exclusive` preference, so a + /// shared regardless of the `exclusive_output` preference, so a /// device that resets on every exclusive grab (Realtek onboard) /// stops thrashing and playback survives. Reset when the user /// re-toggles exclusive or picks a device. Does NOT touch the @@ -375,7 +377,7 @@ pub struct AudioEngine { rebuild_gate: Mutex, /// Last non-library source captured at the boundary of [`Self::send`] /// (#230). The three output-rebuild paths - /// ([`Self::set_output_device`], [`Self::set_wasapi_exclusive`], + /// ([`Self::set_output_device`], [`Self::set_exclusive_output`], /// [`Self::force_rebuild_output`]) snapshot /// `shared.current_track_id`; for radio and remote queues that id is a /// negative sentinel from @@ -488,14 +490,14 @@ impl AudioEngine { /// startup once the persisted `audio.output_device` profile setting /// is known. `None` means "use the OS default". /// - /// `wasapi_exclusive` is the persisted opt-in for Windows - /// Exclusive Mode (silently no-op on Linux/macOS). On a failing - /// init the engine falls back to cpal shared mode automatically; - /// see [`spawn_output_with_mode`] for the contract. + /// `exclusive_output` is the persisted opt-in for owning the + /// device (silently no-op where no exclusive PCM backend exists). + /// On a failing init the engine falls back to cpal shared mode + /// automatically; see [`spawn_output_with_mode`] for the contract. pub fn new_with_device( app: AppHandle, device_name: Option, - wasapi_exclusive: bool, + exclusive_output: bool, ) -> Arc { let (cmd_tx, cmd_rx) = unbounded::(); let shared = Arc::new(SharedPlayback::new()); @@ -505,15 +507,15 @@ impl AudioEngine { // rows and self-send the next `LoadAndPlay`. let (analytics_tx, analytics_rx) = unbounded_channel::(); - let (output, decoder, wasapi_exclusive_active) = match spawn_output_with_mode( + let (output, decoder, exclusive_output_active) = match spawn_output_with_mode( shared.clone(), app.clone(), device_name, - wasapi_exclusive, + exclusive_output, None, ) { Ok((producer, handle)) => { - let active = handle.wasapi_exclusive; + let active = handle.exclusive; // `spawn_output_thread` returns only after the cpal // stream has opened, so `shared.sample_rate` / // `shared.channels` are already populated by the time @@ -548,8 +550,8 @@ impl AudioEngine { output: Mutex::new(output), decoder: Mutex::new(decoder), app, - wasapi_exclusive: std::sync::atomic::AtomicBool::new(wasapi_exclusive), - wasapi_exclusive_active: std::sync::atomic::AtomicBool::new(wasapi_exclusive_active), + exclusive_output: std::sync::atomic::AtomicBool::new(exclusive_output), + exclusive_output_active: std::sync::atomic::AtomicBool::new(exclusive_output_active), rebuild_in_progress: std::sync::atomic::AtomicBool::new(false), exclusive_suppressed: std::sync::atomic::AtomicBool::new(false), exclusive_flaps: Mutex::new(FlapWindow::default()), @@ -740,7 +742,7 @@ impl AudioEngine { super::output::RebuildTarget::Resolve => self.current_output_device(), super::output::RebuildTarget::Device(device) => device, }; - let pref_exclusive = self.wasapi_exclusive.load(Ordering::Relaxed); + let pref_exclusive = self.exclusive_output.load(Ordering::Relaxed); // #322: an exclusive-mode flap storm. A device that resets on every // exclusive grab fires DeviceNotAvailable ~300 ms after each @@ -758,7 +760,7 @@ impl AudioEngine { self.exclusive_suppressed.store(true, Ordering::Relaxed); exclusive = false; tracing::warn!( - "WASAPI exclusive disabled for this session after repeated device \ + "Exclusive output disabled for this session after repeated device \ flaps; staying on shared mode. Re-enable it in Settings to retry." ); } @@ -812,7 +814,7 @@ impl AudioEngine { /// stream was given up and the flag is still accurate. fn publish_output_lost_if_gone(&self, guard: &Option) { if guard.is_none() { - self.wasapi_exclusive_active + self.exclusive_output_active .store(false, std::sync::atomic::Ordering::Release); let _ = self.app.emit("player:audio-mode-changed", ()); } @@ -894,7 +896,7 @@ impl AudioEngine { // engages the exclusive path (raw `hw:` / hog mode), so nothing // else gates it. On any other platform DoP can't run at all. #[cfg(target_os = "windows")] - let exclusive_available = self.wasapi_exclusive.load(Ordering::Relaxed); + let exclusive_available = self.exclusive_output.load(Ordering::Relaxed); // Linux (raw `hw:`) and macOS (CoreAudio hog mode) engage the // exclusive path from the DoP toggle itself — no separate opt-in. #[cfg(any(target_os = "linux", target_os = "macos"))] @@ -932,7 +934,7 @@ impl AudioEngine { // previous exclusive client still holds the device, so release it // first (#322 reasoning) — this path always replaces the stream. let device = guard.as_ref().and_then(|h| h.device_name.clone()); - let pref_exclusive = self.wasapi_exclusive.load(Ordering::Relaxed); + let pref_exclusive = self.exclusive_output.load(Ordering::Relaxed); if let Some(old) = guard.take() { old.stop(); } @@ -947,8 +949,8 @@ impl AudioEngine { Some(dop_fmt), ) { Ok((producer, handle)) => { - self.wasapi_exclusive_active - .store(handle.wasapi_exclusive, Ordering::Release); + self.exclusive_output_active + .store(handle.exclusive, Ordering::Release); *guard = Some(handle); let _ = self.app.emit("player:audio-mode-changed", ()); tracing::info!( @@ -978,8 +980,8 @@ impl AudioEngine { None, ) { Ok((producer, handle)) => { - self.wasapi_exclusive_active - .store(handle.wasapi_exclusive, Ordering::Release); + self.exclusive_output_active + .store(handle.exclusive, Ordering::Release); *guard = Some(handle); let _ = self.app.emit("player:audio-mode-changed", ()); Ok((Some(producer), false)) @@ -1024,8 +1026,10 @@ impl AudioEngine { // path only runs after a DeviceNotAvailable error, so the old stream // is already dead — there's no working state to roll back to. When // the old stream is shared we keep the spawn-first order so a failed - // spawn can still roll back. - let pre_release = guard.as_ref().is_some_and(|h| h.wasapi_exclusive); + // spawn can still roll back — except where entering exclusive can't + // evict it, see [`must_release_before_reopening`]. + let pre_release = + must_release_before_reopening(guard.as_ref().map(|h| h.exclusive), exclusive); if pre_release { if was_playing { self.cmd_tx @@ -1081,8 +1085,8 @@ impl AudioEngine { return Err(err); } *guard = Some(handle); - self.wasapi_exclusive_active.store( - guard.as_ref().map(|h| h.wasapi_exclusive).unwrap_or(false), + self.exclusive_output_active.store( + guard.as_ref().map(|h| h.exclusive).unwrap_or(false), std::sync::atomic::Ordering::Release, ); // Settings' exclusive-mode toggle only re-reads its state on @@ -1092,7 +1096,7 @@ impl AudioEngine { let _ = self.app.emit("player:audio-mode-changed", ()); // Resume best-effort. Same async pattern as - // `set_output_device` and `set_wasapi_exclusive` — pull the + // `set_output_device` and `set_exclusive_output` — pull the // track row off the synchronous path so a slow DB doesn't // hold the audio recovery up. Radio sessions resume by // re-dispatching the cached `LoadUrlAndPlay` instead of @@ -1126,7 +1130,7 @@ impl AudioEngine { // Fetch ReplayGain at resume time so a user who // enabled the toggle keeps their analysed gain // across an unintended device flap — matches - // set_output_device and set_wasapi_exclusive. + // set_output_device and set_exclusive_output. let replay_gain = crate::commands::player::fetch_replay_gain(&pool, track_id).await; let _ = cmd_tx.send(AudioCmd::LoadAndPlay { @@ -1197,7 +1201,7 @@ impl AudioEngine { self.shared.clone(), self.app.clone(), device_name, - self.wasapi_exclusive + self.exclusive_output .load(std::sync::atomic::Ordering::Relaxed), None, )?; @@ -1232,7 +1236,7 @@ impl AudioEngine { })(); if let Err(err) = send_result { handle.stop(); - // Mirror force_rebuild_output / set_wasapi_exclusive: if the + // Mirror force_rebuild_output / set_exclusive_output: if the // SwapProducer send failed the closure had already run // `guard.take()`, so no output thread remains — the exclusive // flag must stop claiming one and Settings must re-read (#405). @@ -1243,8 +1247,8 @@ impl AudioEngine { } *guard = Some(handle); - self.wasapi_exclusive_active.store( - guard.as_ref().map(|h| h.wasapi_exclusive).unwrap_or(false), + self.exclusive_output_active.store( + guard.as_ref().map(|h| h.exclusive).unwrap_or(false), std::sync::atomic::Ordering::Release, ); // See force_rebuild_output's comment (issue #405) — a device @@ -1306,9 +1310,9 @@ impl AudioEngine { /// Flip the WASAPI Exclusive Mode preference and re-open the /// output stream using the new mode. No-ops on non-Windows. /// Re-uses the active device name so the user keeps their pick. - pub fn set_wasapi_exclusive(&self, enabled: bool) -> AppResult<()> { + pub fn set_exclusive_output(&self, enabled: bool) -> AppResult<()> { let previous = self - .wasapi_exclusive + .exclusive_output .swap(enabled, std::sync::atomic::Ordering::Relaxed); if previous == enabled { return Ok(()); @@ -1345,11 +1349,14 @@ impl AudioEngine { // order that's correct for a device *switch*, where the two streams // target different endpoints — therefore fails every time here. The // command returned `Err`, so the preference was never persisted and - // `wasapi_exclusive_active` kept reporting the old mode: the toggle + // `exclusive_output_active` kept reporting the old mode: the toggle // sat latched on the very mode the user was trying to leave, with a // restart as the only way out. Release the old exclusive stream - // FIRST so the new open finds a free device. - let pre_release = guard.as_ref().is_some_and(|h| h.wasapi_exclusive); + // FIRST so the new open finds a free device — and on macOS the + // other direction needs it too, see + // [`must_release_before_reopening`]. + let pre_release = + must_release_before_reopening(guard.as_ref().map(|h| h.exclusive), enabled); if pre_release { if was_playing { if let Err(e) = self.cmd_tx.send(AudioCmd::Stop) { @@ -1362,7 +1369,7 @@ impl AudioEngine { // apply. (The spawn / send_result failure paths below // deliberately keep the new pref instead, because by // then the old stream is already gone.) - self.wasapi_exclusive + self.exclusive_output .store(previous, std::sync::atomic::Ordering::Relaxed); return Err(AppError::Audio(format!( "audio command channel closed: {e}" @@ -1408,7 +1415,7 @@ impl AudioEngine { // handle, so there is no output thread at all. Tell // Settings the flag is stale (#405), then schedule a // rebuild that re-opens in the mode now recorded in - // `wasapi_exclusive`. Pass `active` explicitly: the + // `exclusive_output`. Pass `active` explicitly: the // teardown emptied `self.output`, so a self-resolve // would reopen the OS default instead of the user's // device. @@ -1424,13 +1431,13 @@ impl AudioEngine { // as the failed-Stop path — otherwise a later // device-error rebuild would read the new pref and flip // to the exclusive mode this toggle never applied. - self.wasapi_exclusive + self.exclusive_output .store(previous, std::sync::atomic::Ordering::Relaxed); } return Err(err); } }; - let active_mode = handle.wasapi_exclusive; + let active_mode = handle.exclusive; // Group the whole hand-off so ANY failing step still runs the // `handle.stop()` below. `handle` owns a live output thread on a @@ -1464,7 +1471,7 @@ impl AudioEngine { return Err(err); } *guard = Some(handle); - self.wasapi_exclusive_active + self.exclusive_output_active .store(active_mode, std::sync::atomic::Ordering::Release); // Redundant with the caller's own re-read after a manual toggle // (ExclusiveModeCard.tsx), but kept for consistency with the @@ -1521,15 +1528,51 @@ impl AudioEngine { Ok(()) } - /// Whether the current output stream is actually running in - /// WASAPI Exclusive Mode. Always `false` on Linux / macOS and - /// also `false` after a Windows fallback to cpal shared mode. - pub fn wasapi_exclusive(&self) -> bool { - self.wasapi_exclusive_active + /// Whether the current output stream really owns its device — + /// `false` after a fallback to cpal shared mode, and on any + /// platform with no exclusive PCM backend. + pub fn exclusive_output(&self) -> bool { + self.exclusive_output_active .load(std::sync::atomic::Ordering::Acquire) } } +/// Whether the old output has to be released *before* the new one is +/// opened, rather than the other way round. `old_is_exclusive` is `None` +/// when there is no stream installed at all. +/// +/// Spawn-first is the order we want wherever it works: a failed open then +/// costs nothing, because the stream the user is listening to is still +/// installed and still playing. Two situations take that away. +/// +/// - **The old stream is exclusive**, on any platform. It owns the device +/// outright and nothing — shared or exclusive — can open that device +/// until it lets go (#322, then #405 for the other direction). +/// - **We are entering exclusive on macOS.** Hog mode is recorded as a +/// *pid*, and the client it would have to evict here is our own cpal +/// stream, in this very process — so it evicts nothing. Windows kicks +/// the shared client off when the endpoint is seized, and on Linux the +/// reservation protocol makes the sound server hand the card over; +/// macOS has neither. The new AudioUnit then comes up on a device our +/// old one is still driving and renders nothing: no sound, and a +/// position counter frozen where it stood. +/// +/// Measured on a MacBook Air, and only on the toggle. Armed before +/// launch the same code opens on an idle device and plays, which is +/// what made this look like a backend fault rather than an ordering +/// one. +/// +/// Releasing first is safe in the second case because +/// [`spawn_output_with_mode`] falls back to shared mode on its own, so +/// the caller still comes back holding a stream. +fn must_release_before_reopening(old_is_exclusive: Option, entering_exclusive: bool) -> bool { + match old_is_exclusive { + None => false, + Some(true) => true, + Some(false) => cfg!(target_os = "macos") && entering_exclusive, + } +} + /// Update the [`AudioEngine::radio_resume`] snapshot in place /// according to the command about to be sent. Lifted out of the /// `send` method as a free function so the lifecycle invariant @@ -1606,6 +1649,43 @@ fn apply_radio_resume_update(snapshot: &Mutex>, cmd: &A } } +#[cfg(test)] +mod reopen_order_tests { + use super::must_release_before_reopening; + + #[test] + fn nothing_to_release_when_no_stream_is_installed() { + assert!(!must_release_before_reopening(None, true)); + assert!(!must_release_before_reopening(None, false)); + } + + #[test] + fn an_exclusive_stream_is_always_released_first() { + // It owns the device outright: no open of any kind succeeds until + // it lets go. True in both directions and on every platform. + assert!(must_release_before_reopening(Some(true), true)); + assert!(must_release_before_reopening(Some(true), false)); + } + + #[test] + fn entering_exclusive_over_a_shared_stream_depends_on_the_platform() { + // Windows evicts the shared client when the endpoint is seized and + // Linux asks the sound server for the card, so both keep the + // spawn-first order and the rollback it buys. macOS records hog + // mode against a pid and would be asked to evict this very + // process, so it cannot. + assert_eq!( + must_release_before_reopening(Some(false), true), + cfg!(target_os = "macos") + ); + } + + #[test] + fn shared_to_shared_has_nothing_to_reorder() { + assert!(!must_release_before_reopening(Some(false), false)); + } +} + #[cfg(test)] mod flap_window_tests { use super::*; diff --git a/src-tauri/crates/app/src/audio/output.rs b/src-tauri/crates/app/src/audio/output.rs index e8b96711..c026da7c 100644 --- a/src-tauri/crates/app/src/audio/output.rs +++ b/src-tauri/crates/app/src/audio/output.rs @@ -240,10 +240,11 @@ pub struct DopFormat { } /// Pick the right output backend based on the runtime preference. -/// On Windows + `exclusive=true`, tries the WASAPI Exclusive backend -/// first and falls back to cpal shared if init fails (device busy, no -/// exclusive format support, COM apartment conflict, …). On other -/// platforms or with `exclusive=false`, always uses cpal. +/// With `exclusive=true`, tries the platform's exclusive backend first +/// and falls back to cpal shared if init fails (device busy, no +/// supported format, COM apartment conflict, …): WASAPI Exclusive on +/// Windows, a raw `hw:` device on Linux, hog mode on macOS. With +/// `exclusive=false`, always cpal. /// /// The fallback is silent at the caller level — the warning is logged /// so the user can see in `waveflow.log` why exclusive didn't engage. @@ -271,13 +272,18 @@ pub fn spawn_output_with_mode( Some(dop), ); #[cfg(target_os = "linux")] - return super::alsa_exclusive::spawn_alsa_dop_output_thread(shared, app, device_name, dop); + return super::alsa_exclusive::spawn_alsa_exclusive_output_thread( + shared, + app, + device_name, + Some(dop), + ); #[cfg(target_os = "macos")] - return super::coreaudio_exclusive::spawn_coreaudio_dop_output_thread( + return super::coreaudio_exclusive::spawn_coreaudio_exclusive_output_thread( shared, app, device_name, - dop, + Some(dop), ); #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] { @@ -308,8 +314,58 @@ pub fn spawn_output_with_mode( } } } - #[cfg(not(target_os = "windows"))] - let _ = exclusive; // unused on non-Windows targets + // Linux: the same bargain through a raw `hw:` device. The card is + // usually held by PipeWire or PulseAudio at this point, so the + // backend asks for it through the reservation protocol before + // concluding it can't be had. + #[cfg(target_os = "linux")] + if exclusive { + match super::alsa_exclusive::spawn_alsa_exclusive_output_thread( + shared.clone(), + app.clone(), + device_name.clone(), + None, + ) { + Ok(pair) => { + tracing::info!("audio output: ALSA exclusive (raw hw:) engaged"); + return Ok(pair); + } + Err(err) => { + tracing::warn!( + %err, + "ALSA exclusive init failed, falling back to shared mode" + ); + } + } + } + + // macOS: hog mode, which stops the system mixing anything else into + // the device. Unlike the DoP path it leaves the device's physical + // format alone — re-clocking a device the whole machine shares is a + // price only a marker cadence justifies paying. + #[cfg(target_os = "macos")] + if exclusive { + match super::coreaudio_exclusive::spawn_coreaudio_exclusive_output_thread( + shared.clone(), + app.clone(), + device_name.clone(), + None, + ) { + Ok(pair) => { + tracing::info!("audio output: CoreAudio exclusive (hog mode) engaged"); + return Ok(pair); + } + Err(err) => { + tracing::warn!( + %err, + "CoreAudio exclusive init failed, falling back to shared mode" + ); + } + } + } + + #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] + let _ = exclusive; // no exclusive PCM backend on this target spawn_output_thread(shared, app, device_name) } @@ -340,7 +396,7 @@ pub(super) fn notify_device_lost(app: &AppHandle, shared: &Arc, /// - the cpal error callback and the WASAPI-exclusive `DeviceLost` exit /// both fire while the (now-dead) handle is still parked in /// `self.output`, so its `device_name` is still readable → [`Resolve`]; -/// - the `set_wasapi_exclusive` failure path has already `take()`n the +/// - the `set_exclusive_output` failure path has already `take()`n the /// old handle, emptying `self.output`, so a self-resolve would return /// `None` and reopen the OS default instead of the user's pick (#405) /// → [`Device`] carries the device captured before the teardown. @@ -406,6 +462,64 @@ pub(super) fn schedule_device_rebuild(app: &AppHandle, target: RebuildTarget) { }); } +/// Drain one period out of the ring into `samples`, applying the same +/// per-sample chain the cpal callback applies: volume, the normalize +/// attenuation, and the optional mono downmix. Shared by all three +/// exclusive backends. +/// +/// Returns how many samples were actually pulled. Silence written +/// because the ring ran dry is deliberately NOT counted — every backend +/// agrees on that, because `samples_played` is the only clock the +/// progress bar, the lyrics sync and play-event crediting have, and +/// crediting an underrun would make the track run ahead of itself. +pub(super) fn fill_pcm_period( + shared: &SharedPlayback, + consumer: &mut Consumer, + samples: &mut [f32], + channels: usize, +) -> u64 { + let volume = shared.volume(); + let normalize = shared.normalize_enabled.load(Ordering::Relaxed); + let mono = shared.mono_enabled.load(Ordering::Relaxed); + // Normalization applies a -3 dB reduction to leave headroom. + let norm_gain: f32 = if normalize { 0.707 } else { 1.0 }; + let mut written: u64 = 0; + + if mono && channels >= 2 { + for frame in samples.chunks_mut(channels) { + let mut sum = 0.0_f32; + let mut got = 0usize; + for _ in 0..frame.len() { + if let Ok(s) = consumer.pop() { + sum += s; + got += 1; + } + } + let value = if got > 0 { + written += got as u64; + (sum / channels as f32) * volume * norm_gain + } else { + 0.0 + }; + for slot in frame.iter_mut() { + *slot = value; + } + } + } else { + for slot in samples.iter_mut() { + *slot = match consumer.pop() { + Ok(s) => { + written += 1; + s * volume * norm_gain + } + Err(_) => 0.0, + }; + } + } + + written +} + /// Handle retained by the engine so it can tear the output thread down /// cleanly on shutdown or device switch. Separate from the decoder-side /// `Producer` which is handed off independently — see the tuple returned @@ -417,10 +531,11 @@ pub struct OutputHandle { /// `None` means the OS default device. Saved so a hot-swap can /// no-op when the user picks the same device again. pub device_name: Option, - /// Whether this handle is really using WASAPI Exclusive Mode. - /// The user preference can request exclusive mode, but startup may - /// fall back to cpal shared mode when the device rejects it. - pub wasapi_exclusive: bool, + /// Whether this handle really owns its device — WASAPI Exclusive + /// Mode on Windows, a raw `hw:` handle on Linux. The user + /// preference can request it, but startup may fall back to cpal + /// shared mode when the device rejects it. + pub exclusive: bool, /// `Some(fmt)` when this output is carrying a DoP (DSD over PCM) /// stream, opened at exactly that rate (`dsd_rate / 16`) and channel /// count, #495. `None` for every ordinary PCM output. The engine @@ -492,7 +607,7 @@ pub fn spawn_output_thread( shutdown_tx, join, device_name, - wasapi_exclusive: false, + exclusive: false, dop: None, }, )), @@ -759,3 +874,52 @@ where Ok(stream) } + +#[cfg(test)] +mod tests { + use super::fill_pcm_period; + use crate::audio::state::SharedPlayback; + use rtrb::RingBuffer; + + #[test] + fn a_dry_ring_yields_silence_that_is_not_credited() { + // The counter drives the progress bar and play crediting: an + // underrun must leave the track where it was, not advance it. + let shared = SharedPlayback::new(); + let (_producer, mut consumer) = RingBuffer::::new(8); + let mut samples = [1.0_f32; 4]; + let written = fill_pcm_period(&shared, &mut consumer, &mut samples, 2); + assert_eq!(written, 0); + assert_eq!(samples, [0.0; 4]); + } + + #[test] + fn volume_is_applied_here_because_a_raw_device_has_no_mixer() { + let shared = SharedPlayback::new(); + shared.set_volume(0.5); + let (mut producer, mut consumer) = RingBuffer::::new(8); + for _ in 0..4 { + producer.push(1.0).expect("ring has room"); + } + let mut samples = [0.0_f32; 4]; + let written = fill_pcm_period(&shared, &mut consumer, &mut samples, 2); + assert_eq!(written, 4); + assert_eq!(samples, [0.5; 4]); + } + + #[test] + fn the_mono_downmix_averages_the_frame_across_every_channel() { + let shared = SharedPlayback::new(); + shared + .mono_enabled + .store(true, std::sync::atomic::Ordering::Relaxed); + let (mut producer, mut consumer) = RingBuffer::::new(8); + // One stereo frame, hard-panned left. + producer.push(1.0).expect("ring has room"); + producer.push(0.0).expect("ring has room"); + let mut samples = [0.0_f32; 2]; + let written = fill_pcm_period(&shared, &mut consumer, &mut samples, 2); + assert_eq!(written, 2); + assert_eq!(samples, [0.5, 0.5]); + } +} diff --git a/src-tauri/crates/app/src/audio/stream_cache.rs b/src-tauri/crates/app/src/audio/stream_cache.rs index 3af8cc48..54b7a9ac 100644 --- a/src-tauri/crates/app/src/audio/stream_cache.rs +++ b/src-tauri/crates/app/src/audio/stream_cache.rs @@ -477,9 +477,17 @@ mod tests { assert!(lookup(dir.path(), &name).is_none()); } - /// Unix only: Windows path components are UTF-16 and cannot carry the - /// byte sequences this guards against. - #[cfg(unix)] + /// Linux only, and the exclusions are for two different reasons. + /// Windows path components are UTF-16 and cannot carry the byte + /// sequences this guards against at all. macOS can express them but + /// APFS refuses to store them: `create_dir_all` fails outright, so + /// the test cannot even set its scene there. `cfg(unix)` covered both + /// Unixes and was red on macOS from the day it landed — invisibly, + /// since no CI job builds this project on a Mac. + /// + /// The code under test still matters on macOS: a path can be handed + /// to us from outside the filesystem's own naming rules. + #[cfg(all(unix, not(target_os = "macos")))] #[test] fn a_parent_directory_that_is_not_utf8_does_not_hide_a_working_file() { use std::os::unix::ffi::OsStrExt; diff --git a/src-tauri/crates/app/src/audio/wasapi_exclusive.rs b/src-tauri/crates/app/src/audio/wasapi_exclusive.rs index eb3ce0e8..3327e0ee 100644 --- a/src-tauri/crates/app/src/audio/wasapi_exclusive.rs +++ b/src-tauri/crates/app/src/audio/wasapi_exclusive.rs @@ -93,7 +93,7 @@ pub fn spawn_exclusive_output_thread( shutdown_tx, join, device_name, - wasapi_exclusive: true, + exclusive: true, dop, }, )), @@ -802,58 +802,12 @@ fn run_event_loop( while consumer.pop().is_ok() {} &silent_buf } else { - let volume = shared.volume(); - let normalize = shared.normalize_enabled.load(Ordering::Relaxed); - let mono = shared.mono_enabled.load(Ordering::Relaxed); - let norm_gain: f32 = if normalize { 0.707 } else { 1.0 }; - - // Samples actually pulled from the ring this period. Drives - // `SharedPlayback::samples_played`, which is the only source - // the progress bar, lyrics sync and play-event crediting have - // for "where are we in the track" — see the counting note in - // `state.rs`. Silence written on an underrun is deliberately - // NOT counted, matching the cpal callback. - let mut written: u64 = 0; - - if mono && channels >= 2 { - // Mono downmix: average all channels per frame. - let mut i = 0; - while i + channels <= need_samples { - let mut sum = 0.0_f32; - let mut got = 0usize; - for slot in &mut samples[i..i + channels] { - match consumer.pop() { - Ok(s) => { - sum += s; - got += 1; - *slot = 0.0; // placeholder, overwritten below - } - Err(_) => *slot = 0.0, - } - } - let v = if got > 0 { - written += got as u64; - (sum / channels as f32) * volume * norm_gain - } else { - 0.0 - }; - for slot in &mut samples[i..i + channels] { - *slot = v; - } - i += channels; - } - } else { - // Normal multi-channel path. - for slot in samples.iter_mut() { - *slot = match consumer.pop() { - Ok(s) => { - written += 1; - s * volume * norm_gain - } - Err(_) => 0.0, - }; - } - } + // Same fill every backend uses: volume, the normalize + // attenuation, the optional mono downmix, and an underrun + // left uncredited. It lives in `output` so the three + // exclusive paths and the cpal callback can't drift apart. + let written = + super::output::fill_pcm_period(&shared, &mut consumer, &mut samples, channels); // Pack `samples` into the byte layout the negotiated // exclusive format expects (#174). Hot path: no diff --git a/src-tauri/crates/app/src/commands/player.rs b/src-tauri/crates/app/src/commands/player.rs index 5c4203cc..3e0067a4 100644 --- a/src-tauri/crates/app/src/commands/player.rs +++ b/src-tauri/crates/app/src/commands/player.rs @@ -84,12 +84,14 @@ pub struct PlayerStateSnapshot { /// True when the active output is shipping native DSD via DoP /// (#495) — reflects what really engaged, not just the opt-in. pub dop_active: bool, - /// True when the stream really owns the device: WASAPI Exclusive on - /// Windows, false everywhere else and false after a fallback to - /// shared mode. Without it the UI cannot tell a stream that reaches - /// the DAC untouched from one the system mixer re-clocks on its way - /// there, which is the difference between bit-perfect and merely - /// un-processed. + /// True when the stream really owns the device — WASAPI Exclusive + /// on Windows, a raw `hw:` ALSA device on Linux, CoreAudio hog mode + /// on macOS. Read from the engine's *active* state, not the opt-in, + /// so it is false after a silent fallback to shared mode and on any + /// platform with no exclusive backend. Without it the UI cannot tell + /// a stream that reaches the DAC untouched from one the system mixer + /// re-clocks on its way there, which is the difference between + /// bit-perfect and merely un-processed. pub exclusive_active: bool, } @@ -726,7 +728,7 @@ pub async fn player_get_state( repeat_mode, current_track, engine.current_output_is_dop(), - engine.wasapi_exclusive(), + engine.exclusive_output(), ); // When the engine is Idle but we resolved a resume point, use the // persisted position instead of the (zero) live counter. @@ -1819,23 +1821,34 @@ pub async fn player_set_output_device( Ok(()) } -/// Toggle WASAPI Exclusive Mode (Windows-only audiophile path). +/// Toggle exclusive output — the audiophile path where the app owns +/// the device instead of sharing it with the system mixer. /// -/// Re-opens the active output stream in exclusive event-driven mode, -/// negotiating the layout from the endpoint's own format -/// (`PKEY_AudioEngine_DeviceFormat`) first and only then the -/// shared-mode mix format (#409). Bypasses the Windows audio -/// engine so no other app can mix in / DSP / resample our audio. -/// Falls back silently to cpal shared mode if init fails (device -/// busy, unsupported format, no exclusive support on the driver) — -/// see `audio/wasapi_exclusive.rs` for the contract. +/// Re-opens the active output stream through the platform's exclusive +/// backend: /// -/// No-op on Linux / macOS; the persisted setting is still written so -/// the value follows the user across platforms. +/// - **Windows**, WASAPI Exclusive Mode, event-driven, negotiating the +/// layout from the endpoint's own format +/// (`PKEY_AudioEngine_DeviceFormat`) first and only then the +/// shared-mode mix format (#409) — see `audio/wasapi_exclusive.rs`. +/// - **Linux**, a raw `hw:` ALSA device, asking the sound server to +/// release the card first — see `audio/alsa_exclusive.rs`. +/// - **macOS**, CoreAudio hog mode, which leaves the device's physical +/// format alone — see `audio/coreaudio_exclusive.rs`. /// -/// Persisted in `profile_setting['audio.wasapi_exclusive']`. +/// Either way no other app can mix in, DSP or resample our audio. Falls +/// back silently to cpal shared mode if init fails (device busy, +/// unsupported format, no exclusive support in the driver). +/// +/// The persisted setting is written on every platform, including those +/// with no exclusive backend at all, so the value follows the user +/// across machines. +/// +/// Persisted in `profile_setting['audio.exclusive_output']`. The boot +/// read in `lib.rs` also accepts the legacy `audio.wasapi_exclusive` +/// row, from when this was a Windows-only setting. #[tauri::command] -pub async fn player_set_wasapi_exclusive( +pub async fn player_set_exclusive_output( state: tauri::State<'_, AppState>, engine: tauri::State<'_, Arc>, enabled: bool, @@ -1843,15 +1856,15 @@ pub async fn player_set_wasapi_exclusive( let engine_clone: Arc = engine.inner().clone(); // Same rationale as `player_set_output_device`: opening / tearing // down a WASAPI stream blocks for a few hundred ms. - tokio::task::spawn_blocking(move || engine_clone.set_wasapi_exclusive(enabled)) + tokio::task::spawn_blocking(move || engine_clone.set_exclusive_output(enabled)) .await - .map_err(|e| AppError::Audio(format!("set wasapi exclusive task: {e}")))??; + .map_err(|e| AppError::Audio(format!("set exclusive output task: {e}")))??; if let Ok(pool) = state.require_profile_pool().await { let now = chrono::Utc::now().timestamp_millis(); let stored = if enabled { "1" } else { "0" }; let _ = sqlx::query( "INSERT INTO profile_setting (key, value, value_type, updated_at) - VALUES ('audio.wasapi_exclusive', ?, 'bool', ?) + VALUES ('audio.exclusive_output', ?, 'bool', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at", ) .bind(stored) @@ -1862,13 +1875,12 @@ pub async fn player_set_wasapi_exclusive( Ok(()) } -/// Read the current WASAPI Exclusive Mode state from the audio engine. -/// Always `false` on Linux / macOS. Used by the Settings card to -/// reflect whether the engine actually engaged exclusive mode (the -/// init could have silently fallen back to shared). +/// Read from the audio engine whether the output really owns its +/// device. Used by the Settings card to reflect what actually engaged, +/// since init can have silently fallen back to shared mode. #[tauri::command] -pub fn player_get_wasapi_exclusive(engine: tauri::State<'_, Arc>) -> bool { - engine.inner().wasapi_exclusive() +pub fn player_get_exclusive_output(engine: tauri::State<'_, Arc>) -> bool { + engine.inner().exclusive_output() } /// Replace the queue with the given track list and start playing at diff --git a/src-tauri/crates/app/src/lib.rs b/src-tauri/crates/app/src/lib.rs index d54fc267..f0f2a336 100644 --- a/src-tauri/crates/app/src/lib.rs +++ b/src-tauri/crates/app/src/lib.rs @@ -254,7 +254,7 @@ pub fn run() { // last picked instead of the OS default. Empty string in // the row means "follow the OS default" — see // `player_set_output_device`. - let (persisted_device, persisted_wasapi_exclusive) = + let (persisted_device, persisted_exclusive_output) = tauri::async_runtime::block_on(async { let state = app.state::(); let Ok(pool) = state.require_profile_pool().await else { @@ -268,8 +268,17 @@ pub fn run() { .ok() .flatten() .filter(|s: &String| !s.is_empty()); + // Two keys because the setting outgrew its name: it + // was `audio.wasapi_exclusive` while exclusive output + // existed only on Windows. Reading both carries a + // Windows user's opt-in across the rename, and the + // current name wins when both rows exist — the legacy + // one is then whatever they had before the last toggle. let exclusive: bool = sqlx::query_scalar::<_, String>( - "SELECT value FROM profile_setting WHERE key = 'audio.wasapi_exclusive'", + "SELECT value FROM profile_setting + WHERE key IN ('audio.exclusive_output', 'audio.wasapi_exclusive') + ORDER BY key = 'audio.exclusive_output' DESC + LIMIT 1", ) .fetch_optional(&*pool) .await @@ -282,7 +291,7 @@ pub fn run() { let engine: Arc = AudioEngine::new_with_device( engine_handle, persisted_device, - persisted_wasapi_exclusive, + persisted_exclusive_output, ); app.manage(engine); @@ -974,8 +983,8 @@ pub fn run() { commands::player::player_get_audio_settings, commands::player::player_list_output_devices, commands::player::player_set_output_device, - commands::player::player_set_wasapi_exclusive, - commands::player::player_get_wasapi_exclusive, + commands::player::player_set_exclusive_output, + commands::player::player_get_exclusive_output, commands::stats::stats_overview, commands::stats::stats_top_tracks, commands::stats::stats_top_artists, diff --git a/src/components/common/ToggleSwitch.tsx b/src/components/common/ToggleSwitch.tsx index 68ff410c..270281c4 100644 --- a/src/components/common/ToggleSwitch.tsx +++ b/src/components/common/ToggleSwitch.tsx @@ -9,6 +9,11 @@ interface ToggleSwitchProps { * copies in `SettingsView` and `EqualizerCard`; new cards should * prefer this shared export and the older inline copies can be * collapsed in a follow-up. + * + * `shrink-0` because a flex item shrinks past an explicit width: every + * row here is a flex line whose other half is a label, so a long enough + * subtitle squeezed the switch narrower than the ones above and below + * it. The width is the control's size, not a suggestion. */ export function ToggleSwitch({ enabled, onToggle, label }: ToggleSwitchProps) { return ( @@ -18,7 +23,7 @@ export function ToggleSwitch({ enabled, onToggle, label }: ToggleSwitchProps) { role="switch" aria-checked={enabled} aria-label={label} - className={`relative w-12 h-7 rounded-full transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-zinc-900 ${ + className={`relative w-12 h-7 shrink-0 rounded-full transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-zinc-900 ${ enabled ? "bg-emerald-500" : "bg-zinc-300 dark:bg-zinc-600" }`} > diff --git a/src/components/views/SettingsView.tsx b/src/components/views/SettingsView.tsx index 6458e54f..6b1973b9 100644 --- a/src/components/views/SettingsView.tsx +++ b/src/components/views/SettingsView.tsx @@ -2561,8 +2561,8 @@ export function SettingsView({ onNavigate }: SettingsViewProps) { - {/* WASAPI Exclusive Mode — Windows-only, the card hides - itself on other platforms via UA sniff. */} + {/* Exclusive output — every desktop platform has a + backend now, so the card no longer hides itself. */} {/* Audio mono */} diff --git a/src/components/views/settings/ExclusiveModeCard.tsx b/src/components/views/settings/ExclusiveModeCard.tsx index 2739ff6a..e3dcc106 100644 --- a/src/components/views/settings/ExclusiveModeCard.tsx +++ b/src/components/views/settings/ExclusiveModeCard.tsx @@ -4,17 +4,19 @@ import { Lock } from "lucide-react"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { - playerGetWasapiExclusive, - playerSetWasapiExclusive, + playerGetExclusiveOutput, + playerSetExclusiveOutput, } from "../../../lib/tauri/player"; import { ToggleSwitch } from "../../common/ToggleSwitch"; /** - * WASAPI Exclusive Mode card — Windows-only audiophile path. + * Exclusive output card — the audiophile path where the app owns the + * device instead of sharing it with the system mixer. * - * Detection: we check `navigator.userAgent` for "Windows" since the - * setting is silently no-op on Linux / macOS and showing it there - * would mislead users. + * Shown on every desktop platform, because every one of them now has a + * backend: WASAPI Exclusive on Windows, a raw ALSA `hw:` device on + * Linux, hog mode on macOS. The card used to sniff the user agent to + * hide itself where the toggle did nothing. * * The toggle calls the backend which: * 1. Persists the preference in `profile_setting`. @@ -29,22 +31,14 @@ export function ExclusiveModeCard() { const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - // Windows-only gate. Sniffing UA is fine here — Tauri's WebView is - // platform-pinned, so the result is stable for the lifetime of the - // process. - const isWindows = - typeof navigator !== "undefined" && - navigator.userAgent.toLowerCase().includes("windows"); - useEffect(() => { - if (!isWindows) return; - playerGetWasapiExclusive() + playerGetExclusiveOutput() .then(setEnabled) .catch((err) => { console.error("[ExclusiveModeCard] get failed", err); setEnabled(false); }); - }, [isWindows]); + }, []); // The engine can rebuild the output stream on its own — a device // flap (issue #405), a device switch from the output-device picker — @@ -55,7 +49,6 @@ export function ExclusiveModeCard() { // carries no payload; a re-fetch here mirrors the one `toggle()` // already does after a manual click. useEffect(() => { - if (!isWindows) return; let unlisten: UnlistenFn | null = null; // `listen()` is async, so the effect can unmount before it resolves. // Without this flag the cleanup below runs while `unlisten` is still @@ -66,7 +59,7 @@ export function ExclusiveModeCard() { (async () => { try { const stop = await listen("player:audio-mode-changed", () => { - playerGetWasapiExclusive() + playerGetExclusiveOutput() .then(setEnabled) .catch((err) => { console.error( @@ -88,18 +81,16 @@ export function ExclusiveModeCard() { cancelled = true; if (unlisten) unlisten(); }; - }, [isWindows]); - - if (!isWindows) return null; + }, []); const toggle = async (next: boolean) => { setBusy(true); setError(null); try { - await playerSetWasapiExclusive(next); + await playerSetExclusiveOutput(next); // Re-read so the displayed state reflects the engine's actual // mode after fallback. - const actual = await playerGetWasapiExclusive(); + const actual = await playerGetExclusiveOutput(); setEnabled(actual); if (next && !actual) { setError(t("settings.exclusive.fallback")); @@ -113,7 +104,7 @@ export function ExclusiveModeCard() { // otherwise the switch keeps showing the mode the user just tried // to leave and looks stuck. try { - setEnabled(await playerGetWasapiExclusive()); + setEnabled(await playerGetExclusiveOutput()); } catch (refreshErr) { console.error( "[ExclusiveModeCard] refresh after failed toggle", @@ -128,9 +119,13 @@ export function ExclusiveModeCard() { return (
-
- -
+
+