Skip to content

Commit ca60a90

Browse files
arctumnclaude
andcommitted
windows: STOP_AUDIO is what breaks stereo — never leave mic mode mid-session
Root cause, proven live over the l2cap-tx pipe: sending `STOP_AUDIO` (leaving hi-res mic mode) is what leaves the AirPods rendering audio on ONE SIDE only. It self-heals after minutes; re-sending `START_AUDIO` restores stereo instantly, which proves mic mode itself is fine and only the EXIT is broken. So we never leave mic mode during a session. Clearing `mic_on` is enough: the receive loop stops decoding the 0x58 uplink and the virtual mic goes silent, which is what "off" means to the user. The buds keep the uplink armed until the session ends (a disconnect resets them anyway). This also retires `a2dp::reset()`. It never fixed a mono A2DP negotiation — btvs captures show every AVDTP SET_CONFIGURATION and all 2891 SBC frames are 0x03 JointStereo, always, with no RECONFIGURE and no SCO/HFP anywhere. It only "worked" because it rebuilt the audio link right after the bad exit, at the cost of killing the AAP channel and bricking the driver into Code 38. The comment atop a2dp.rs claiming playback "degrades to mono/right" is wrong and predates the virtual-mic driver. Dropping it also removes a ~2.65 s dropout per release. Why only we hit this: Linux sends STOP_AUDIO too, but PulseAudio switches the card into HFP for mic use and back to A2DP after, and that profile switch rebuilds the stream and wipes the bad state. macOS never takes this path — its mic rides HFP/eSCO with a custom codec (AT+BCS=128) and is call-scoped. Our virtual mic deliberately never leaves A2DP, so we are the first to get stuck. Also fixes the zombie AAP channel. The channel can sit OPEN but silent for as long as Windows still sees the AirPods (observed: 56 minutes) — `status_fails` resets every pass, so the loop never releases or rebuilds, and the in-place re-arm was gated behind `mic_on`. Attached to the PC, but not to the daemon. Now: after 30 s of silence with the mic off we set `link_stale` and nudge in place by re-asserting the noise mode we already believe is active (a write the buds accept, nothing audible, and a live link answers with a 0x0D that clears the flag). We deliberately do NOT drop + reopen the driver — that is what "Repair connection" does, and repeated churn is what bricks it into Code 38. `link_stale` rides the Snapshot (serde/JSON default, so older snapshots load). The app now offers Repair while disconnected OR stale, and paints the status dot orange in that state. Before, `connected` stayed true forever, so the Repair button was hidden exactly when it was needed and the only way to surface it was to turn Bluetooth off. Mic card text now describes what it actually does, and the "Microphone released" notification is gone: nothing is released any more, so announcing it was both wrong and noise. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UAAKZFpHoNY9pskrdvLKaP
1 parent 393c651 commit ca60a90

8 files changed

Lines changed: 125 additions & 36 deletions

File tree

windows/daemon/src/main.rs

Lines changed: 84 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
#![windows_subsystem = "windows"]
77
#![allow(dead_code)]
88

9+
// No longer called — kept for a quick revert if the stereo-restore ever proves
10+
// necessary; see the note in set_mic(). (Crate-level allow(dead_code) covers it.)
911
mod a2dp;
1012
mod aap;
1113
mod bt;
@@ -450,21 +452,41 @@ fn l2cap_reader(pipe: Arc<Pipe>, ctx: Ctx) {
450452
}
451453
}
452454

453-
/// Enable/disable the hi-res mic stream (manual path), with the A2DP restore.
455+
/// Enable/disable the hi-res mic stream (manual path).
454456
fn set_mic(ctx: &Ctx, on: bool) {
455457
let was = ctx.mic_on.swap(on, Ordering::Relaxed);
456458
if on && !was {
457459
if let Some(drv) = ctx.driver_cell.lock().unwrap().clone() {
458460
let _ = drv.send(&aap::START_AUDIO);
459461
}
460-
ctx.overlay("Hi-res microphone on");
462+
ctx.overlay("Using the AirPods microphone");
461463
} else if !on && was {
462-
if let Some(drv) = ctx.driver_cell.lock().unwrap().clone() {
463-
let _ = drv.send(&aap::STOP_AUDIO);
464-
}
465-
ctx.overlay("Microphone released — restoring stereo…");
466-
a2dp::reset(ctx.mac);
467-
ctx.overlay("Stereo restored");
464+
// Deliberately NOT sending STOP_AUDIO — see the note below. Clearing
465+
// `mic_on` is enough: the receive loop stops decoding the 0x58 uplink and
466+
// the virtual mic goes silent, which is what "off" means to the user. The
467+
// buds keep the uplink armed until the session ends, and that is the point.
468+
//
469+
// No A2DP restore either. A btvs capture (trace/audio/audio-1.pcapng, 244 s) showed
470+
// every AVDTP SET_CONFIGURATION is identical SBC 44.1 kHz **JointStereo**, with
471+
// zero RECONFIGURE and no SCO/HFP anywhere — the hi-res uplink never degrades
472+
// playback to mono, so there is nothing to restore. Worse, a2dp::reset() is a
473+
// BluetoothSetServiceState disable/enable: in that same capture the AAP channel
474+
// (PSM 0x1001) opened once, went silent, and was never rebuilt while A2DP was
475+
// torn down and re-set-up five times — the churn that bricks the driver into
476+
// Code 38. Dropping it also removes a ~2.65 s audio dropout on every release.
477+
//
478+
// WHY STOP_AUDIO IS GONE (measured 2026-08-30, live over the l2cap-tx pipe):
479+
// sending it is what breaks playback to a single side. Leaving mic mode puts
480+
// the buds in a state where only one bud renders; it self-heals after minutes.
481+
// Re-sending START_AUDIO restores stereo INSTANTLY, which proves mic mode
482+
// itself is fine and only the exit is broken. This is also what a2dp::reset()
483+
// was really "curing": it rebuilt the audio link right after the bad exit, at
484+
// the cost of killing the AAP channel and bricking the driver into Code 38.
485+
// So we never leave mic mode mid-session. The uplink stays armed until the
486+
// session ends (a disconnect resets the buds anyway).
487+
//
488+
// No overlay here either: nothing is "released", so announcing it was both
489+
// wrong and noise. Going quiet is the honest signal.
468490
}
469491
ctx.push_state();
470492
}
@@ -895,7 +917,11 @@ fn run_receiver(ctx: Ctx) {
895917
let _ = driver.send(&aap::SET_FEATURES);
896918
thread::sleep(Duration::from_millis(300));
897919
let _ = driver.send(&aap::REQUEST_NOTIFS);
898-
ctx.state.lock().unwrap().connected = true;
920+
{
921+
let mut st = ctx.state.lock().unwrap();
922+
st.connected = true;
923+
st.link_stale = false; // fresh session — whatever was wedged is gone
924+
}
899925
pending_card = true;
900926
ctx.push_state();
901927
log("run_receiver: handshake done, connected=true");
@@ -951,6 +977,16 @@ fn run_receiver(ctx: Ctx) {
951977
// it isn't a clean 2, so we can see what "cased" vs "both-out-resting"
952978
// actually report (the teardown decision hinges on them differing).
953979
let mut status_diag = Instant::now();
980+
// Zombie-channel detection. The channel can sit OPEN but silent for as long
981+
// as Windows still sees the AirPods (observed: 56 minutes) — `status_fails`
982+
// is reset every pass in that case, so the loop never releases and never
983+
// rebuilds, and the in-place mic re-arm below is gated behind `mic_on`. With
984+
// the mic off nothing ever nudges it: the AirPods are attached to the PC but
985+
// not to us, and the hi-res mic is dead. We surface that as `link_stale` so
986+
// the app can offer "Repair connection" (its button used to be hidden,
987+
// because `connected` stays true), and we nudge in place first.
988+
let mut link_stale = false;
989+
let mut last_link_nudge = Instant::now();
954990
// Last time an AAP packet actually arrived. The driver State drops to 0
955991
// both on a transient channel re-negotiation (e.g. both buds just left the
956992
// ears) and on a real disconnect (cased / on the phone) — status alone
@@ -996,6 +1032,12 @@ fn run_receiver(ctx: Ctx) {
9961032
if n > 0 {
9971033
got_data = true;
9981034
last_data = Instant::now();
1035+
if link_stale {
1036+
link_stale = false;
1037+
ctx.state.lock().unwrap().link_stale = false;
1038+
ctx.push_state();
1039+
log("run_receiver: AAP channel talking again — link_stale cleared");
1040+
}
9991041
let data = &buf[..n];
10001042
// Forward the raw packet to the full app (if attached) so it
10011043
// runs its own AAP session over us.
@@ -1076,7 +1118,7 @@ fn run_receiver(ctx: Ctx) {
10761118
// uplink is fully operational end-to-end. Announce once.
10771119
if !mic_announced {
10781120
mic_announced = true;
1079-
ctx.overlay("Microphone ready — hi-res active");
1121+
ctx.overlay("Microphone ready — high quality");
10801122
}
10811123
}
10821124
}
@@ -1333,6 +1375,34 @@ fn run_receiver(ctx: Ctx) {
13331375
let _ = driver.send(&aap::START_AUDIO);
13341376
last_mic_rearm = Instant::now();
13351377
}
1378+
// Zombie channel: open, the OS still sees the buds, but nothing
1379+
// has arrived for a long while and the mic isn't on (so the
1380+
// re-arm above never fires). Nudge IN PLACE first — re-assert
1381+
// the noise mode we already believe is active. That's a write
1382+
// the buds accept, it changes nothing audible, and if the link
1383+
// is alive at all they answer with a 0x0D control status, which
1384+
// clears the flag above. We deliberately do NOT drop + reopen
1385+
// the driver here: that is what "Repair connection" does, and
1386+
// repeated drop+reopen churn is what bricks it into Code 38.
1387+
// Recovery stays the user's call — we just make it offerable.
1388+
if !ctx.mic_on.load(Ordering::Relaxed)
1389+
&& last_data.elapsed() >= Duration::from_secs(30)
1390+
{
1391+
if !link_stale {
1392+
link_stale = true;
1393+
ctx.state.lock().unwrap().link_stale = true;
1394+
ctx.push_state();
1395+
log("run_receiver: AAP channel silent >30s with mic off — link_stale set");
1396+
}
1397+
if last_link_nudge.elapsed() >= Duration::from_secs(30) {
1398+
let mode = ctx.state.lock().unwrap().anc;
1399+
if mode != 0 {
1400+
log("run_receiver: nudging the silent channel in place (re-assert noise mode)");
1401+
let _ = driver.send(&aap::anc_command(mode));
1402+
}
1403+
last_link_nudge = Instant::now();
1404+
}
1405+
}
13361406
false
13371407
} else {
13381408
status_fails += 1;
@@ -1389,20 +1459,17 @@ fn poll_mic(ctx: Ctx) {
13891459
if let Some(drv) = ctx.driver_cell.lock().unwrap().clone() {
13901460
let _ = drv.send(&aap::START_AUDIO);
13911461
}
1392-
ctx.overlay("Microphone in use — hi-res on");
1462+
ctx.overlay("Using the AirPods microphone");
13931463
ctx.push_state();
13941464
}
13951465
} else {
13961466
idle += 1;
13971467
if on && idle >= MIC_IDLE_STOP_POLLS {
13981468
on = false;
13991469
ctx.mic_on.store(false, Ordering::Relaxed);
1400-
if let Some(drv) = ctx.driver_cell.lock().unwrap().clone() {
1401-
let _ = drv.send(&aap::STOP_AUDIO);
1402-
}
1403-
ctx.overlay("Microphone released — restoring stereo…");
1404-
a2dp::reset(ctx.mac);
1405-
ctx.overlay("Stereo restored");
1470+
// No STOP_AUDIO, no A2DP restore and no overlay — see the note in
1471+
// set_mic(). Clearing `mic_on` stops the decoding; the uplink stays
1472+
// armed so we never cross the exit that leaves playback on one side.
14061473
ctx.push_state();
14071474
}
14081475
}

windows/ipc/src/lib.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@ pub struct Battery {
4343
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
4444
pub struct Snapshot {
4545
pub connected: bool,
46+
/// The AAP session is nominally up (`connected`) but the channel has gone
47+
/// silent — the AirPods are attached to Windows yet not talking to us, so the
48+
/// hi-res mic and every live notification are dead. The app surfaces "Repair
49+
/// connection" on this, because `connected` alone stays true forever in that
50+
/// state and used to hide the button exactly when it was needed.
51+
/// (serde default: older snapshots.)
52+
#[serde(default)]
53+
pub link_stale: bool,
4654
pub dev_name: String,
4755
pub battery: Battery,
4856
/// Noise-control mode: 0 = unknown, 1 = off, 2 = ANC, 3 = transparency, 4 = adaptive.

windows/winui/LibrePods.WinUI/Controls/DeviceHeader.xaml.cs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,21 @@ public void Update(Snapshot s)
4242
}
4343

4444
StatusText.Text = Localize.Get(s.Connected ? "Status_Connected" : "Status_Disconnected");
45+
// Green = talking to us; Orange = attached but the channel went silent
46+
// (Repair appears); Gray = not connected.
4547
StatusDot.Fill = new SolidColorBrush(
46-
s.Connected ? Microsoft.UI.Colors.LimeGreen : Microsoft.UI.Colors.Gray);
48+
!s.Connected ? Microsoft.UI.Colors.Gray
49+
: s.LinkStale ? Microsoft.UI.Colors.Orange
50+
: Microsoft.UI.Colors.LimeGreen);
4751
ConnectButton.Visibility = s.Connected ? Visibility.Collapsed : Visibility.Visible;
48-
// Rename + Disconnect only make sense while connected; Repair only while not.
52+
// Rename + Disconnect only make sense while connected. Repair is offered
53+
// while disconnected AND while the link is stale -- the AirPods attached to
54+
// Windows but no longer talking to the daemon. In that state Connected stays
55+
// true forever, so keying Repair off it alone hid the button exactly when it
56+
// was needed, and the only way to surface it was to turn Bluetooth off.
4957
RenameBtn.Visibility = s.Connected ? Visibility.Visible : Visibility.Collapsed;
5058
DisconnectButton.Visibility = s.Connected ? Visibility.Visible : Visibility.Collapsed;
51-
RepairButton.Visibility = s.Connected ? Visibility.Collapsed : Visibility.Visible;
59+
RepairButton.Visibility = (!s.Connected || s.LinkStale) ? Visibility.Visible : Visibility.Collapsed;
5260
if (!s.Connected) ExitEdit();
5361
}
5462

windows/winui/LibrePods.WinUI/Ipc/Messages.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ public sealed class Battery
2626
public sealed class Snapshot
2727
{
2828
[JsonPropertyName("connected")] public bool Connected { get; set; }
29+
30+
/// The session is nominally up but the AAP channel has gone silent: the AirPods
31+
/// are attached to Windows yet not talking to the daemon, so the hi-res mic and
32+
/// live notifications are dead. Surfaces "Repair connection" -- Connected alone
33+
/// stays true forever in that state and used to hide the button exactly then.
34+
[JsonPropertyName("link_stale")] public bool LinkStale { get; set; }
2935
[JsonPropertyName("dev_name")] public string DevName { get; set; } = "";
3036
[JsonPropertyName("battery")] public Battery Battery { get; set; } = new();
3137

windows/winui/LibrePods.WinUI/Strings/en-US/Resources.resw

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,8 @@
115115

116116
<!-- ===== MicCard ===== -->
117117
<data name="MicTitle.Text" xml:space="preserve"><value>Hi-res Microphone</value></data>
118-
<data name="MicAutoSwitch.Header" xml:space="preserve"><value>Auto-enable on recording</value></data>
119-
<data name="MicEnableBtn.Content" xml:space="preserve"><value>Enable hi-res mic now</value></data>
118+
<data name="MicAutoSwitch.Header" xml:space="preserve"><value>Use it automatically when an app records</value></data>
119+
<data name="MicEnableBtn.Content" xml:space="preserve"><value>Use the microphone</value></data>
120120

121121
<!-- ===== HearingAidCard ===== -->
122122
<data name="HearingAidTitle.Text" xml:space="preserve"><value>Hearing assistance</value></data>
@@ -202,8 +202,8 @@
202202
<data name="Action_Quit" xml:space="preserve"><value>Quit</value></data>
203203
<data name="Action_Connect" xml:space="preserve"><value>Connect</value></data>
204204

205-
<data name="Mic_Recording" xml:space="preserve"><value>Microphone: recording</value></data>
206-
<data name="Mic_Idle" xml:space="preserve"><value>Microphone: idle</value></data>
205+
<data name="Mic_Recording" xml:space="preserve"><value>In use - recording in high quality</value></data>
206+
<data name="Mic_Idle" xml:space="preserve"><value>Not in use</value></data>
207207

208208
<data name="Anc_Off" xml:space="preserve"><value>Off</value></data>
209209
<data name="Anc_NoiseCancellation" xml:space="preserve"><value>Noise Cancellation</value></data>

windows/winui/LibrePods.WinUI/Strings/es-ES/Resources.resw

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,8 @@
106106

107107
<!-- ===== MicCard ===== -->
108108
<data name="MicTitle.Text" xml:space="preserve"><value>Micrófono de alta resolución</value></data>
109-
<data name="MicAutoSwitch.Header" xml:space="preserve"><value>Activar automáticamente al grabar</value></data>
110-
<data name="MicEnableBtn.Content" xml:space="preserve"><value>Activar micrófono HD ahora</value></data>
109+
<data name="MicAutoSwitch.Header" xml:space="preserve"><value>Usar automáticamente cuando una app graba</value></data>
110+
<data name="MicEnableBtn.Content" xml:space="preserve"><value>Usar el micrófono</value></data>
111111

112112
<!-- ===== HearingAidCard ===== -->
113113
<data name="HearingAidTitle.Text" xml:space="preserve"><value>Asistencia auditiva</value></data>
@@ -193,8 +193,8 @@
193193
<data name="Action_Quit" xml:space="preserve"><value>Salir</value></data>
194194
<data name="Action_Connect" xml:space="preserve"><value>Conectar</value></data>
195195

196-
<data name="Mic_Recording" xml:space="preserve"><value>Micrófono: grabando</value></data>
197-
<data name="Mic_Idle" xml:space="preserve"><value>Micrófono: inactivo</value></data>
196+
<data name="Mic_Recording" xml:space="preserve"><value>En uso - grabando en alta calidad</value></data>
197+
<data name="Mic_Idle" xml:space="preserve"><value>No se está usando</value></data>
198198

199199
<data name="Anc_Off" xml:space="preserve"><value>Desactivado</value></data>
200200
<data name="Anc_NoiseCancellation" xml:space="preserve"><value>Cancelación de ruido</value></data>

windows/winui/LibrePods.WinUI/Strings/fr-FR/Resources.resw

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,8 @@
106106

107107
<!-- ===== MicCard ===== -->
108108
<data name="MicTitle.Text" xml:space="preserve"><value>Microphone haute résolution</value></data>
109-
<data name="MicAutoSwitch.Header" xml:space="preserve"><value>Activer automatiquement à l'enregistrement</value></data>
110-
<data name="MicEnableBtn.Content" xml:space="preserve"><value>Activer le micro HD maintenant</value></data>
109+
<data name="MicAutoSwitch.Header" xml:space="preserve"><value>Utiliser automatiquement quand une app enregistre</value></data>
110+
<data name="MicEnableBtn.Content" xml:space="preserve"><value>Utiliser le microphone</value></data>
111111

112112
<!-- ===== HearingAidCard ===== -->
113113
<data name="HearingAidTitle.Text" xml:space="preserve"><value>Assistance auditive</value></data>
@@ -193,8 +193,8 @@
193193
<data name="Action_Quit" xml:space="preserve"><value>Quitter</value></data>
194194
<data name="Action_Connect" xml:space="preserve"><value>Connecter</value></data>
195195

196-
<data name="Mic_Recording" xml:space="preserve"><value>Microphone : enregistrement</value></data>
197-
<data name="Mic_Idle" xml:space="preserve"><value>Microphone : inactif</value></data>
196+
<data name="Mic_Recording" xml:space="preserve"><value>Utilisé - enregistrement en haute qualité</value></data>
197+
<data name="Mic_Idle" xml:space="preserve"><value>Non utilisé</value></data>
198198

199199
<data name="Anc_Off" xml:space="preserve"><value>Désactivé</value></data>
200200
<data name="Anc_NoiseCancellation" xml:space="preserve"><value>Réduction de bruit</value></data>

windows/winui/LibrePods.WinUI/Strings/pt-PT/Resources.resw

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,8 @@
106106

107107
<!-- ===== MicCard ===== -->
108108
<data name="MicTitle.Text" xml:space="preserve"><value>Microfone de alta resolução</value></data>
109-
<data name="MicAutoSwitch.Header" xml:space="preserve"><value>Ativar automaticamente ao gravar</value></data>
110-
<data name="MicEnableBtn.Content" xml:space="preserve"><value>Ativar microfone agora</value></data>
109+
<data name="MicAutoSwitch.Header" xml:space="preserve"><value>Usar automaticamente quando uma app grava</value></data>
110+
<data name="MicEnableBtn.Content" xml:space="preserve"><value>Usar o microfone</value></data>
111111

112112
<!-- ===== HearingAidCard ===== -->
113113
<data name="HearingAidTitle.Text" xml:space="preserve"><value>Assistência auditiva</value></data>
@@ -193,8 +193,8 @@
193193
<data name="Action_Quit" xml:space="preserve"><value>Sair</value></data>
194194
<data name="Action_Connect" xml:space="preserve"><value>Ligar</value></data>
195195

196-
<data name="Mic_Recording" xml:space="preserve"><value>Microfone: a gravar</value></data>
197-
<data name="Mic_Idle" xml:space="preserve"><value>Microfone: inativo</value></data>
196+
<data name="Mic_Recording" xml:space="preserve"><value>Em uso - a gravar em alta qualidade</value></data>
197+
<data name="Mic_Idle" xml:space="preserve"><value>Não está a ser usado</value></data>
198198

199199
<data name="Anc_Off" xml:space="preserve"><value>Desligado</value></data>
200200
<data name="Anc_NoiseCancellation" xml:space="preserve"><value>Cancelamento de Ruído</value></data>

0 commit comments

Comments
 (0)