Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/architecture/audio.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ The period has its own, quieter effect: one period is drained from the ring in a

`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.
**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 (a shared client doesn't block the exclusive open at all) 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

Expand Down
6 changes: 4 additions & 2 deletions docs/features/playback.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,17 +86,19 @@ Three paths replace the output stream, and they must all end in the same place:

| 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_output_device` | user picks another endpoint | order from `must_release_before_reopening`; a failed open after releasing first reopens the previous device |
| `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 |

[`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.
- **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. On Windows a shared client is no obstacle at all (measured on Windows 11: an exclusive `Initialize` succeeds alongside one of our own shared streams on the same endpoint) 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.

`set_output_device` needs the first case too (#604): a pinned device that has vanished falls back to the default endpoint, which can be the very one the old exclusive stream still holds. Releasing first there gives up the rollback spawn-first had, so the switch buys it back: when the new device won't open at all, it reopens the previous one and still returns the error, so the new choice isn't persisted. If the previous one won't reopen either, it schedules the same rebuild `set_exclusive_output` does, aimed explicitly at that previous device. It decides on the release, not by comparing endpoints — a name is no identity (ALSA reaches one card as `default`, `plughw:0,0` and `hw:0,0`), and a wrong "different" verdict would put the collision back.

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.
Expand Down
167 changes: 142 additions & 25 deletions src-tauri/crates/app/src/audio/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1192,19 +1192,122 @@ impl AudioEngine {
.load(std::sync::atomic::Ordering::Acquire);
let position_ms = self.shared.current_position_ms();

// Step 2 — open the new output thread first. The old one is
// still running, which is fine: PipeWire / PulseAudio / ALSA
// dmix all support multiple concurrent streams, and the two
// streams target different devices anyway. If this fails we
// return immediately without disturbing the working stream.
let (producer, handle) = spawn_output_with_mode(
// Step 2 — release the old stream first when the new one cannot be
// opened alongside it, then open the replacement.
//
// Spawn-first is the order we want for a device *switch*: the two
// streams target different endpoints, so a failed open costs
// nothing and the stream the user is listening to survives. PipeWire,
// PulseAudio and ALSA dmix all take concurrent streams, and Windows
// has no quarrel with two clients on two endpoints.
//
// A vanished device breaks that premise, and #604 is what it looks
// like. When the name we were asked for is no longer enumerated,
// `pick_device` falls back to the **default** endpoint — which can
// be the one the old stream is holding exclusively. The open is then
// refused with `AUDCLNT_E_DEVICE_IN_USE` against ourselves, we drop
// to shared mode, and nothing tells the user why.
//
// Measured on Windows 11 rather than assumed: an exclusive client
// does block a second exclusive open of the same endpoint, a
// *shared* client does not block one at all, and the block clears
// about 19 ms after the client is dropped. So the conflict is real,
// it is ours, and releasing first is enough to clear it.
let entering_exclusive = self
.exclusive_output
.load(std::sync::atomic::Ordering::Relaxed);
let pre_release =
must_release_before_reopening(guard.as_ref().map(|h| h.exclusive), entering_exclusive);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Stop is sent here rather than twice: the step below skips its own
// send when this one already happened.
let stopped_early = pre_release && was_playing;
// The device to put back if the replacement will not open at all.
// Only a pre-release needs one: spawn-first leaves the old stream
// installed when the new one fails.
let mut previous_device = None;
if pre_release {
if was_playing {
self.cmd_tx
.send(AudioCmd::Stop)
.map_err(|e| AppError::Audio(format!("audio command channel closed: {e}")))?;
}
if let Some(old) = guard.take() {
previous_device = Some(old.device_name.clone());
old.stop();
}
}

// Set when the replacement failed and the previous device came back
// instead: playback carries on there, and the caller still gets the
// error, so the new choice is not persisted.
let mut switch_error = None;
let (producer, handle) = match spawn_output_with_mode(
self.shared.clone(),
self.app.clone(),
device_name,
self.exclusive_output
.load(std::sync::atomic::Ordering::Relaxed),
entering_exclusive,
None,
)?;
) {
Ok(pair) => pair,
Err(err) => {
// Releasing first gave up the rollback that spawn-first gets
// for free, so buy it back by reopening the previous device.
// A target that will not open at all, such as a headset still
// listed but already gone, must not cost the user the output
// they were listening to.
//
// Keyed on the release rather than on comparing endpoints: a
// name is no identity (ALSA reaches one card as `default`,
// `plughw:0,0` and `hw:0,0`), and a wrong "different" verdict
// would put #604 back.
let reopened = previous_device.clone().and_then(|previous| {
spawn_output_with_mode(
self.shared.clone(),
self.app.clone(),
previous,
entering_exclusive,
None,
)
.inspect_err(|reopen_err| {
tracing::warn!(
%reopen_err,
"set_output_device: the previous device did not reopen either"
);
})
.ok()
});
match reopened {
Some(pair) => {
tracing::warn!(
%err,
"set_output_device: the new device did not open, back on the previous one"
);
switch_error = Some(err);
pair
}
None => {
// No output at all now, and the toggle must stop
// claiming one (#405). Without a pre-release the old
// stream is still installed and still playing, and
// this no-ops.
self.publish_output_lost_if_gone(&guard);
// Same recovery as `set_exclusive_output`'s: retry the
// previous device once the OS has settled, instead of
// leaving the engine with no output until the user
// picks again. Passed explicitly because the release
// emptied `self.output`, so a self-resolve would
// reopen the OS default instead of that device (#405).
if let Some(previous) = previous_device {
super::output::schedule_device_rebuild(
&self.app,
super::output::RebuildTarget::Device(previous),
);
}
return Err(err);
}
}
}
};

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Step 3 — interrupt any current playback. The decoder will
// walk back out of `play_track` and start polling for fresh
Expand All @@ -1216,7 +1319,7 @@ impl AudioEngine {
// has died (engine teardown / crash). Tear the freshly opened
// output back down so it doesn't outlive the engine.
let send_result = (|| {
if was_playing {
if was_playing && !stopped_early {
self.cmd_tx
.send(AudioCmd::Stop)
.map_err(|e| AppError::Audio(format!("audio command channel closed: {e}")))?;
Expand Down Expand Up @@ -1304,7 +1407,12 @@ impl AudioEngine {
}
}

Ok(())
// Back on the previous device after a failed switch: playback is
// restored, but the switch itself did not happen.
match switch_error {
Some(err) => Err(err),
None => Ok(()),
}
}

/// Flip the WASAPI Exclusive Mode preference and re-open the
Expand Down Expand Up @@ -1550,21 +1658,29 @@ impl AudioEngine {
/// 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.
/// stream, in this very process — so it evicts nothing. On Linux the
/// reservation protocol makes the sound server hand the card over, and
/// on Windows a shared client is simply no obstacle (below); 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.
/// A **shared** stream is deliberately not a reason to release first, and
/// that is measured rather than assumed. On Windows 11, `Initialize` in
/// exclusive mode succeeds while one of our own shared clients is open and
/// running on the same endpoint; only an *exclusive* client draws
/// `AUDCLNT_E_DEVICE_IN_USE`, and it stops doing so about 19 ms after that
/// client is dropped. Keeping spawn-first here is what preserves the
/// rollback for the ordinary case.
///
/// Releasing first is safe wherever it applies 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<bool>, entering_exclusive: bool) -> bool {
match old_is_exclusive {
None => false,
Expand Down Expand Up @@ -1669,11 +1785,12 @@ mod reopen_order_tests {

#[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.
// A shared client blocks nothing on Windows (measured: an
// exclusive `Initialize` succeeds alongside one) 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")
Expand Down
9 changes: 6 additions & 3 deletions src-tauri/crates/app/src/audio/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -527,9 +527,12 @@ pub(super) fn fill_pcm_period(
pub struct OutputHandle {
pub shutdown_tx: Sender<()>,
pub join: JoinHandle<()>,
/// Resolved device name actually used by this output thread —
/// `None` means the OS default device. Saved so a hot-swap can
/// no-op when the user picks the same device again.
/// Device name this output was *asked* for — `None` means the OS
/// default. It stays the request even when the backend fell back to
/// the default because the name was no longer enumerated: the picker
/// highlights it, a same-device pick no-ops on it, and a rebuild or a
/// DoP reopen goes back to it, so the user's pin survives until the
/// device returns. That also makes it no endpoint identity.
pub device_name: Option<String>,
/// Whether this handle really owns its device — WASAPI Exclusive
/// Mode on Windows, a raw `hw:` handle on Linux. The user
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/crates/app/src/audio/wasapi_exclusive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -807,7 +807,7 @@ fn run_event_loop(
// 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);
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
Expand Down