Skip to content

Native Windows support for AirPods (self-contained windows/ folder) - #716

Open
arctumn wants to merge 42 commits into
librepods-org:mainfrom
arctumn:windows-native
Open

Native Windows support for AirPods (self-contained windows/ folder)#716
arctumn wants to merge 42 commits into
librepods-org:mainfrom
arctumn:windows-native

Conversation

@arctumn

@arctumn arctumn commented Aug 11, 2026

Copy link
Copy Markdown

The short version

Native AirPods support for Windows, as a self-contained windows/ folder at the repo root, next to android/ — with zero changes to the Linux app (linux-rust) or its branch. A headless daemon owns two open-source kernel drivers (AAP L2CAP + a virtual mic) and serves a native WinUI 3 app over IPC. Everything below is developed and hardware-validated on real AirPods Pro on a Windows machine. Related to #433 (native Windows, no WSL). Supersedes #710 (which bundled a cross-platform refactor of the Linux crate).

How it compares

Front-ends (WinUI · iced-Linux · Android)

The Windows client (WinUI) next to the reference Android app and the Linux iced app. On Windows the native client is WinUI, not iced. Legend: ✓ yes · — no · ◐ partial.

Feature WinUI (Windows) iced (Linux) Android
Battery (L / R / Case)
Noise control (Off / ANC / Transparency / Adaptive)
Ear-detection auto-pause ✓ (SMTC) ✓ (MPRIS)
Conversational Awareness ducking (host-side)
Adaptive / Personalized Volume, Allow-Off
Adaptive noise strength (0x2E)
Hi-res AAC-ELD microphone (system input)
Per-person hearing-aid audiogram (ATT 0x001F)
Heart rate (AirPods Pro 3) ◐ blocked, off by default 1 1
Rename device
Connection island / native toasts
Localization ✓ (4 languages) — (English) ◐ (2)
System tray ✓ (ksni) n/a (Quick-Settings tile)
BLE connect-prompt ("never steal from phone")
Multi-vendor (Nothing earbuds)

1 HR is blocked on current AirPods Pro 3 firmware for everyone — the protocol changed and is unmapped on any platform (see "What doesn't work").

vs MagicPods

The closest Windows counterpart is MagicPods — a mature, polished app worth acknowledging. This is a fair-as-I-can comparison; the point is to show what a fully-FOSS alternative brings.

LibrePods (this PR) MagicPods
License / price Free, GPL — fully FOSS (app + both kernel drivers) $9.99 Store; app open-source
Battery (L / R / Case)
Noise control (Off / ANC / Transparency / Adaptive) ✓ (kernel L2CAP driver) ✓ (MagicAAP driver)
Adaptive noise strength (0x2E), Allow-Off, Adaptive/Personalized Volume partial
Conversational Awareness ducking (host-side)
Hi-res AAC-ELD microphone as a native input ✓ (own WDM/ACX virtual mic driver) — (system HFP / 16 kHz)
Per-person hearing-aid audiogram
Heart-rate monitoring (AirPods Pro 3) ⚠ implemented, blocked on current firmware (off by default)
Connection island / toasts / animations
Multi-vendor (Galaxy Buds / Sony / Beats) — (AirPods)
Low-latency gaming mode
Driver signing test-signed (Test Mode); EV / attestation for a no-Test-Mode build Test Mode, or an attestation-signed "Official" build

Where MagicPods is ahead (multi-vendor breadth, low-latency mode, a signed no-Test-Mode build) is noted honestly. Where this project is ahead: 100% free and open — drivers included — with a hi-res mic, a per-person hearing-aid audiogram, and host-side CA ducking, all under windows/ with no impact on the Linux app.

What works today

All hardware-validated on real AirPods Pro (Windows):

  • Battery (L / R / Case), over both AAP and BLE
  • Noise control — Off / ANC / Transparency / Adaptive
  • Ear-detection auto-pause (via SMTC)
  • Hi-res AAC-ELD microphone as a native Windows input for any app (48 kHz; auto-activates when an app records) — validated by a real recording
  • Per-person hearing-aid audiogram (8-band L/R dB HL) written over the ATT/GATT channel (PSM 0x001F)
  • Conversational Awareness ducking (host-side), Adaptive / Personalized Volume, Allow-Off, Adaptive noise strength (0x2E)
  • Device info + rename
  • BLE proximity connect prompt (asks before connecting, never steals from your phone), plus a Repair connection action
  • A headless daemon (librepodsd) that owns the exclusive driver + AAP session and serves thin UI clients over named-pipe IPC

What doesn't work (yet), honestly

  • Heart rate (AirPods Pro 3) is implemented (a port of Add experimental AirPods heart-rate monitoring and RSSI #702) but does not produce data on current firmware. The HR packet protocol changed in the recent firmware and is currently unmapped on any platform — even the exact iOS-Health command, with an iPhone, yields no stream (confirmed with the original Android author). So it ships off by default, behind an experimental toggle. It needs the new firmware protocol re-mapped from an iPhone capture first — not a bug on our side (the enable is byte-identical to the working Android client; the buds ACK but never stream).
  • The two kernel drivers require Test Mode for personal use (they're test-signed). A no-Test-Mode build would need an EV cert + Microsoft attestation signing — future work.

How it's built

  • One daemon, thin clients. librepodsd owns the exclusive driver handle + the one AAP session; the WinUI app is a named-pipe IPC client. That keeps audio clean and lets the UI come and go.
  • Two kernel drivers, because Windows exposes no user-mode API for either (verified against the docs + a Winsock AF_BTH spike that returned WSAENETDOWN): AAP runs over a classic-Bluetooth L2CAP channel (PSM 0x1001) that only a kernel profile driver (BRB_L2CA_OPEN_CHANNEL) can open as a client; and there's no user-mode API to create a virtual audio device, so the hi-res mic is a WDM/ACX driver (trimmed from the MS sample to a capture-only path).
  • Decoupled from Linux. Nothing under linux-rust/ is touched. ci-windows.yml is path-scoped to windows/**, so a Windows build never gates a Linux release. FFmpeg (AAC-ELD decode) is fetched at build time (pinned + SHA256), not vendored. AAP Definitions.md is included as protocol reference.

Install & releases

  • Installer: windows/installer/install.ps1 (one-shot, admin) installs both drivers, copies the daemon + FFmpeg DLLs + the WinUI app, and adds them to startup (WinUI starts minimised to the tray).
  • Releases: pushing a windows-v* tag makes CI assemble a ready-to-run bundle (installer + drivers + daemon + WinUI) and publish it as a GitHub Release. Normal runs upload a transient artifact.

Native WinUI 3 client

The default Windows front-end — battery, noise control, hearing aid, hi-res mic — fully async, self-contained/unpackaged on .NET 10, with a native tray (window hides to it on close), toasts, an iOS-style connection island, and 4-language localization.

WinUI 3 client — device page (wide, 2-column, dark)

Light theme Narrow single-column
Same page in the light theme Responsive: collapses to one column when narrow
Hearing aid Settings
Per-person hearing-aid audiogram (8-band L/R) Settings (theme, language, experimental)
Tray menu Connection island
Tray menu (battery + Noise Control) iOS-style connection island

Feedback welcome — on the drivers, the daemon/IPC boundary, or anything I've framed unfairly above.

arctumn added 2 commits August 11, 2026 14:03
Adds the full native Windows stack under windows/, alongside android/ — with
zero changes to the Linux app (linux-rust) or its branch:

- KMDF profile driver (LibrePodsAAP) that opens the AAP L2CAP channel (PSM 0x1001)
  Windows won't let user-mode apps touch, bridged to user space over IOCTLs.
- WDM/ACX virtual audio driver (LibrePodsMic) exposing the AirPods hi-res AAC-ELD
  mic as a native Windows input.
- librepodsd daemon: owns the exclusive driver + AAP session and serves thin UI
  clients over named-pipe IPC — battery, ANC, ear-detection auto-pause (SMTC),
  Conversational Awareness ducking, adaptive/personalized volume, hi-res mic
  auto-activation, per-person hearing-aid audiogram over ATT (PSM 0x001F).
- Native C#/WinUI 3 client (librepods-winui) + a thin tray, both IPC clients of
  the daemon; localized (en/es/fr/pt), connection island + native toasts.
- Heart rate is implemented but blocked on current AirPods Pro 3 firmware
  (protocol changed, unmapped on every platform) — ships off behind a toggle.

Windows CI (ci-windows.yml) is path-scoped to windows/**, so it never gates the
Linux pipeline. AAP Definitions.md is included as protocol reference.
- Remove windows/dist/ (18 MB of build output, incl. the 15 MB iced librepods.exe)
  and gitignore it — CI builds these artifacts.
- Drop the "iced (cross-platform)" front-end switcher from the tray and WinUI:
  iced is Linux-only now, so the native WinUI client is the sole Windows UI, and
  the tray "Open App" launches it directly.
- Fix stale cross-platform references (le.rs, READMEs) and remove the stale
  HANDOFF handoff doc.
arctumn added 15 commits August 11, 2026 14:30
The AAC-ELD decode libs (avcodec/avutil/swresample) are downloaded by
windows/daemon/fetch-ffmpeg.sh (pinned URL + SHA256, providing both the MSVC .lib
and MinGW .dll.a import libs) instead of being committed — dropping ~28k lines of
FFmpeg headers from the repo. CI runs the fetch before building the daemon; only
the LGPL license and a short README stay tracked.
The release artifact carried only the exes — the daemon couldn't actually run
(no AAC-ELD runtime DLLs) and there were no drivers to install. Copy the fetched
FFmpeg DLLs next to librepodsd.exe and bundle the prebuilt, test-signed AAP + mic
drivers under dist/drivers/.
…tch)

The rolling `latest` tag gets rebuilt, so the pinned SHA256 broke between CI runs.
Pin to autobuild-2026-08-11-13-11 (immutable) instead.
…re-only)

The virtual mic only needs the capture path, but the driver still carried, from
the MS ACX sample, the render (speaker) circuit, save-to-disk debug recording,
and Cortana keyword detection. Remove:
- RenderCircuit.cpp, SaveData.cpp/.h, KeywordDetector.cpp/.h (files)
- the unused CRenderStreamEngine + CBufferedCaptureStreamEngine engine classes
- the CSaveData device-level work-item init/teardown in Device.cpp
- their SamplesCommon.vcxproj build entries

The live capture engine (CCaptureStreamEngine, fed by MicPipeRead) is untouched;
ToneGenerator/WaveReader remain as inert dead code for a follow-up. NOTE: the CI
doesn't build the drivers (needs the WDK), so this needs a local WDK build to
verify.
Second strip pass. The live capture engine's data comes from MicPipeRead, so the
tone-generator and wave-file-reader sources inherited from the MS ACX sample are
dead weight. Remove them and their hooks:
- ToneGenerator.cpp/.h, WaveReader.cpp/.h (files)
- the m_ToneGenerator/m_WaveReader members + wave registry settings + the
  ReadRegistrySettings method in CCaptureStreamEngine
- the CWaveReader device-level init/teardown in Device.cpp
- their SamplesCommon.vcxproj entries

PrepareHardware/ReleaseHardware now just chain to the base. Needs a WDK build.
driver-test / mic-rename / mic-test were driver bring-up utilities, not part of
the shipped product and not built by CI. Removes ~1.2k lines (incl. their
lockfiles). They live in git history if needed for debugging.
The WinUI app already carries a full system tray (H.NotifyIcon) and hides to it on
close, so the separate lightweight Rust tray became redundant once WinUI was the
only Windows UI. Remove windows/tray/ (~3.5k lines incl. its 2.5k-line lockfile),
drop its CI build step + artifact copy, and point startup.ps1 at
librepods-winui.exe. The daemon (which shares the ipc crate) is unaffected.
Completes the tray consolidation. With the Rust tray gone, the WinUI is the
always-on tray presence, so on launch it skips showing the window when passed
--tray/--minimized (startup.ps1 does). Login just puts the icon in the tray; the
window opens on demand (double-click / "Open") and hides back on close.
… formats

CircuitHelper now keeps only AllocateFormat (CaptureCircuit's only use); dropped
the render/jack/bridge/attribute-list helpers (all unreferenced). AudioFormats.h
keeps only Pcm48000c1 — the single 48kHz/mono format the capture pin advertises;
dropped the other 34 unused format definitions. ~1,560 lines. Needs a WDK build.
…Private.h)

Prune the big contiguous dead declaration blocks left in Private.h after the
capture-only strip: the Dsp / RenderMC / CaptureMC / RenderMCDsp / CaptureMCDsp
multicircuit contexts + callbacks, the Codec Render (speaker) circuit, and the
MicArray / MicrophoneHp / Speaker / HDMI declarations — all unreferenced (verified:
no .cpp/.h uses them; the live capture context types are untouched). ~577 lines.
Public.h is left alone (its dead blocks interlock with kept prototypes). Needs a
WDK build.
… on stall

Two connection-stability fixes, validated on hardware:

- Don't tear down the AAP session on the driver's flaky "State" — it reads
  not-connected whenever the buds play audio (the A2DP stream contends for the
  radio) or the channel is idle, a FALSE "gone". Trust Windows' own BT status
  instead: hold as long as the OS still sees the AirPods connected, and release
  only once it has lost them (really cased / handed to the phone). This stops the
  false teardowns whose reconnect toggled the OS audio and kicked calls.

- When the hi-res mic is engaged and the AAP channel has gone silent for a while
  (the mic uplink stalled) but the buds are still connected, rebuild the channel
  IN PLACE (drop + reopen, which re-arms START_AUDIO) WITHOUT touching the OS
  audio — so the microphone recovers without kicking the call.
Replace the pre-strip prebuilt AudioCodec.sys (45 KB) with the stripped build
(40 KB) — validated end-to-end by a real recording (clean 48 kHz mono voice).
Drop the stale audiocodec.cat; install.ps1 regenerates the catalog via inf2cat on
every install, so it isn't tracked. INF refreshed to match.
Removing windows/dist/ earlier dropped the end-user installer along with the
build output. Restore it as a tracked windows/installer/ (install.ps1 + bundled
devcon.exe), rewritten for the current architecture — installs both kernel drivers
(test-signed on the fly), copies the daemon + FFmpeg DLLs + the WinUI app to
%LOCALAPPDATA%\LibrePods, and adds the daemon + WinUI (--tray) to startup. Drops
the old references to the removed iced app, Rust tray and mic-rename tool.

CI: on a `windows-v*` tag, ci-windows.yml now assembles dist/ in the installer's
layout (driver/, driver-mic/, tools/, winui/, install.ps1, daemon + DLLs), zips it
and publishes a GitHub Release (softprops/action-gh-release). Normal branch/PR runs
still just upload the transient artifact.
…nore)

devcon creates the ROOT\AudioCodec mic device at install time; the installer needs
it bundled. Add a .gitignore negation + force-add past the global *.exe rule.
@arctumn
arctumn marked this pull request as ready for review August 11, 2026 17:15
@arctumn arctumn changed the title [Draft] Native Windows support for AirPods (self-contained windows/ folder) Native Windows support for AirPods (self-contained windows/ folder) Aug 11, 2026
arctumn added 8 commits August 11, 2026 18:26
…ay, island)

Replace the old screenshots with current ones — dark + light device pages, narrow
single-column, the per-person hearing-aid audiogram, settings, tray menu, and the
connection island. Drop the stale nav/toast shots.
…nstance

The daemon's Shutdown did a bare exit(0), which skips destructors, so the
exclusive LibrePodsAAP handle was never closed cleanly. That left the devnode
stuck in Code 38 (CM_PROB_DRIVER_FAILED_PRIOR_UNLOAD) and the next daemon failing
with "driver open FAILED" until a reboot.

- daemon: Shutdown now closes the driver handle (new Driver::close_now) before
  exit, running the L2CAP channel teardown the OS-on-exit close does not do
  reliably.
- app: on startup, gracefully shut down any orphaned daemon before spawning a
  fresh one (never a hard kill — a kill leaks the same handle), so the app always
  owns a single clean instance.
…st-privilege

The heart-rate doc claimed the gate is "Apple-host identity" and only cited the
AirPods' own DID. Update it with the full evidence and the sharper conclusion:

- all three possible host Device-IDs tested (AirPods 004C:2027:0100, Android
  004C:0000:0000, iPhone 004C:7805:1A50) — all ACK, none stream. Identity ruled out.
- independent macOS user-space probe (branch macos-hr-probe) reaches the same
  result from a host whose DID is byte-identical to an iPhone's.
- reframe: the gate is being the buds' privileged PRIMARY host, not identity.
  iOS third-party apps (Strava) don't open the AAP channel at all — they read the
  Apple-system-computed value via HealthKit; the direct AAP path we take is what's
  withheld from a non-primary host, and Windows has neither option.
… on every connect

The AAP driver opened a second L2CAP channel — the ATT/GATT channel on PSM 0x001F
used only for the hearing-aid audiogram — as a client on EVERY connect, even when
the hearing aid is never used. The buds' ATT server is dormant until hearing-assist
is enabled, so that channel just idled and was torn down ~30 s later, and each ATT
teardown on the shared ACL stalled the AAP channel: with the mic on it starved the
uplink (data_age climbing) until the mic-stall watchdog rebuilt the AAP channel,
which reopened the ATT channel — a self-feeding ~44 s drop/rebuild cycle.

Fix: don't open the ATT channel in LpConnect. Open it lazily on the first ATT write
(LpAttSend), i.e. only when the hearing aid is actually applied; it reconnects
transparently after an idle close (the remote-disconnect indication clears
AttConnected). With the hearing aid unused, the ATT channel never opens, the churn
is gone, and the AAP channel + audio stay stable (validated: channel_open stays 0,
zero mic-stall rebuilds across a session).
…default)

The Experimental section (heart-rate opt-in) was an always-visible card. Wrap it in
a collapsed Expander so it isn't immediately visible — the user must expand it to
reach the experimental toggles. Keeps HeartRateSetting's x:Name so the code-behind
is unchanged.
…he driver

The mic-uplink-stall watchdog dropped and reopened the driver (driver_cell = None →
break → reopen) whenever the AAP channel went silent for 8 s with the mic on. That
fired on ordinary call silence (you not speaking), and the repeated drop+reopen churn
tipped the OS into a code-22 disconnect and finally bricked the AAP devnode into
Code 38 (CM_PROB_DRIVER_FAILED_PRIOR_UNLOAD) — a stuck "driver open FAILED" loop.

Never drop the channel while the mic is in use. Instead re-arm the uplink in place by
re-sending START_AUDIO on the still-open channel, rate-limited to once every 5 s. A
silence becomes a harmless no-op; a real stall gets nudged back without touching the
L2CAP link, so the call isn't kicked and the driver never churns into Code 38.
Settings ▸ Experimental only gated the heart-rate card; the hearing-aid card (also
experimental, and the source of the ATT-channel instability fixed earlier) was always
visible. Generalise the opt-in: rename EnableHeartRate → EnableExperimental and gate
BOTH the heart-rate and hearing-aid cards on it (hidden by default; shown only when the
experimental toggle is on). Relabel the toggle "Show experimental features" with a
generalised warning across all four languages.
…eserved PSM

The AirPods repeatedly open an inbound L2CAP connection to PSM 0x001F (ATT/GATT) and
bthport refuses it "PSM not supported" because we register no server there. Tested
registering one (LpRegisterAttServer, from the hearing-aid M1): bthport rejects it with
STATUS_INVALID_PARAMETER (0xC000000D) — a profile driver may be an ATT client but not a
server on the reserved PSM. So the buds' inbound GATT connection is unavoidably refused
on Windows; record it next to the lazy-client note so it isn't re-attempted.
arctumn and others added 17 commits August 12, 2026 17:05
…ATT probe

Audio + mic ride the AAP L2CAP channel and are the core of the AirPods — they must
never be dropped for a non-essential feature.

- HR retry: remove the "transport rebuild" escalation entirely. When the enable
  retries are spent with only ACKs (this firmware ACKs service 19 but never streams),
  the campaign now GIVES UP instead of dropping + reopening the L2CAP channel — that
  reconnect was pointless churn that re-opened the audio link (6 rebuilds in one HR
  session in a capture). Dropped hr_wants_rebuild / hr_rebuilds / HR_MAX_REBUILDS /
  HrOutcome::Rebuild and the run_receiver rebuild branch; the user re-toggles HR
  off/on to retry.
- Experimental GATT-client discovery probe (gatt.rs) is gated behind the
  LIBREPODS_GATT_PROBE env flag so it never runs in normal use — experimental daemon
  probes stay opt-in, like the UI cards.
… alive

set_audio_connected enabled BOTH the A2DP AudioSink and the Handsfree (HFP) service
states on connect. Enabling HFP makes Windows activate the mono headset profile,
which competes with / drops the A2DP stereo output — the "mic works but no audio"
symptom. The hi-res virtual mic (over L2CAP/AAP) already replaces the HFP mic, so HFP
is redundant and harmful. Enable only AUDIO_SINK on connect; still tear HFP down on
disconnect. Keeps the audio + mic on the clean A2DP + virtual-mic path.
New Settings ▸ Startup card with a toggle that registers/unregisters the daemon + the
tray app in the per-user HKCU Run key (no admin), so login-startup can be turned on/off
from the app instead of only by the installer. StartupService manages both entries;
the toggle reflects the current state on load. Localized in all four languages.
…tification

The virtual-mic control device (\\.\LibrePodsMic) was opened once at daemon startup and
never reopened: if the mic driver wasn't enumerated yet at boot, or the handle later
broke (driver reinstall / re-plug), the mic stayed dead until a daemon restart — and
poll_mic even `break`s out (no auto-detection) when the pipe is None. Wrap it in a
self-healing MicPipeCell (Mutex<Option<MicPipe>>) that (re)opens on demand from every
write/status call; MicPipe::write/status now report failure so a dead handle is dropped
and reopened. Audio + mic recover on their own.

Also: announce "Microphone ready — hi-res active" once per capture session, when the
first decoded PCM actually reaches the virtual mic (the uplink is operational end-to-end),
re-announcing after a reconnect. Distinct from the earlier "in use" (requested) overlay.
…io fallback)

Enabling only A2DP on connect left the user with NO audio endpoint at all whenever
A2DP alone failed to connect (buds flapping / bad BT state) — there was no HFP
fallback. Restore enabling both A2DP + Handsfree. The original "mic but no audio"
routing concern is better addressed by choosing the default output device, not by
dropping HFP here.
…ware fix

LibrePods maintainer (Kavish, Discord 2026-08-13) confirmed the HR start that finally
works on newer AirPods Pro 3 firmware targets sensor service **84 (0x54)**, not 19
(0x13) — the HR service id moved — with an extra top-level field-2=2 vs the generic
sensor_stream. Add aap::hr_start() reproducing his exact frame and send it after the
0x30 enable; the decoder now accepts service 84 (keeping 19 as a legacy fallback). This
is the first concrete lead past our exhaustive "ACK but no data" wall — the earlier
captures showed only service 19 because we only ever asked for 19.
… the maintainer

The maintainer's startHr selects the sensor service from the firmware: version3 first
digit >= 8 uses HEARTRATE_COMMAND (84), older uses HEARTRATE (19). Implement the same:
hr_service(firmware) chooses 84/19 from the parsed 0x1D firmware string (defaults to
84 when unknown), and both the START and STOP now go through one aap::hr_stream(seq,
service, period) builder matching his SensorDataWX{ServiceSettings{service, setting=2,
config=0x01+interval_µs_LE}} — START at 1 Hz, STOP at interval 0, on the same service.
Decoder already accepts both 84 and 19. (Confirmed with the maintainer: standalone HR
still needs beta firmware — his own AirPods don't stream either, only his beta testers'
— so this is the correct, faithful implementation, ready for when the firmware streams.)
…rmware topology)

Per the maintainer + thibaup (Discord 2026-08-13), the HR service topology varies by
firmware: 8* sets the interval AND reports on 19; 9* sets on 84 (HEARTRATE_COMMAND) but
the readings arrive on 20 — and even the set service was seen to vary ("something weird
with apple"). Our decoder only watched 19/84, so on 9* it would MISS the reports (they
come on 20). Fix: set the 1 Hz interval on BOTH 84 and 19 (buds ignore the wrong one),
STOP likewise, and the decoder now accepts reports on 19, 20 AND 84. Drops the brittle
firmware-version guess (we don't have their exact version3 field). Standalone HR still
needs beta firmware, but this is now robust across the known service layouts.
The Windows docs had drifted from the code:

- README: dropped the "no system-volume control yet" claim (the daemon owns
  volume via WASAPI), led the install with installer/install.ps1 and the
  committed prebuilt driver packages instead of "build it with VS+WDK",
  documented the mic driver install, and refreshed the feature/tray-menu list.
- docs/README.md: dead link to a HANDOFF.md that doesn't exist, wrong relative
  paths, and links to protocol files that were moved/renamed.
- docs/daemon-ipc/PLAN.md: described an architecture with librepods-tray.exe +
  the iced librepods.exe, neither of which exists on Windows anymore; volume is
  daemon-side, not client-side; Phase 3 was resolved by the WinUI client.
- docs/hires-mic/PLAN.md: the trailing "phases 3 and 4 to follow" contradicted
  the DONE entries above it; noted the pipeline moved into the daemon and the
  cushion value in the shipped code.
- docs/heart-rate.md: reframed as "no working version on Windows yet" and
  recorded the tested unit/firmware.
- drivers/aap: status said on-hardware testing was "the next step"; install now
  starts from the prebuilt package.
- drivers/mic: written in future tense with unticked boxes for work already
  done; fixed two broken links and the build command.
- winui: removed references to the retired Rust tray.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYDBL3zcBRDBLispNbL7mN
… pruned)

The Windows CI broke with `curl: (22) 404` fetching FFmpeg: BtbN keeps only the
last ~14 daily autobuilds plus the last build of each month, and we were pinned
to a mid-month tag (autobuild-2026-08-11-13-11), which was deleted.

Re-pinned to autobuild-2026-07-31-14-10 — same FFmpeg build
(n7.1.5-12-g1fdbca85aa, LGPL shared) and the same av*-NN.dll SONAMEs, but from a
month-end tag, which BtbN retains for ~2 years. Documented that rule in the
script's header so the next version bump doesn't repeat this, and made the
download failure explain what to do instead of just exiting 22.

Verified locally: downloads, checksum matches, and the .lib/.dll.a import libs
plus avcodec-61/avutil-59/swresample-5 DLLs land in vendor/ffmpeg as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYDBL3zcBRDBLispNbL7mN
…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
The teardown BRBs — CLOSE_CHANNEL for the AAP and ATT channels, then
UNREGISTER_SERVER — were submitted from EvtDeviceReleaseHardware. WDF stops
the default I/O target *before* calling ReleaseHardware, so every one of them
failed at WdfRequestSend and never reached bthport. Silently: all three call
sites discarded the result with (VOID).

bthport was therefore left holding our PSM 0x001F server registration and the
open channels, referencing a driver that was going away. That stale reference
pins the whole Bluetooth branch. Measured 2026-08-31: with the LibrePodsAAP
service already STOPPED, `pnputil /restart-device` answered "System reboot is
needed to complete configuration operations!" for our devnode AND for the Intel
radio above it. Nothing short of a reboot cleared it.

Move the teardown to EvtDeviceSelfManagedIoSuspend, which runs while the target
is still started, and log each status instead of dropping it so the debug build
shows whether the BRBs land. ReleaseHardware keeps only the
InterfaceDereference, plus an idempotent repeat of the teardown for the paths
where Suspend is not reached.

Also drop the interface reference in EvtCleanupCallback. That runs on every
path, including ones where ReleaseHardware was skipped or bailed out early, and
a reference still held there pins the BTHENUM parent — and with it the radio —
for good.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ljr4VfCfTNYw244k8cnbEV
…e AirPods

The reported bug: a notification or a call on an iPhone takes the buds, and the
PC never gets its audio back — "tenho que desligar e voltar a ligar, mesmo em
chamada, que nao recupera". Several independent faults, each measured against
daemon.log on 2026-08-31.

DETECTION. `run_receiver` decided whether to release with
`bt::find_airpods().is_some()`, which does not answer the question: it
enumerates with fReturnRemembered and never reads fConnected, so it is Some for
any *paired* device. The branch was always taken, `status_fails` pinned at 0,
the session never released, `connected` stayed true, the BLE watcher (which only
scans while disconnected) never re-armed, and nothing ever asked for the audio
back. Signature: `fails=0` beside a data_age past 42 s. `bt::is_connected` asks
the real question.

ORDERING. `set_audio_connected` ran on four unsynchronized threads and
BluetoothSetServiceState blocks for seconds, so a DISABLE issued before an
ENABLE could land after it — buds connected on AAP, audio services off:

    08:47:49 Disconnect             -> queues DISABLE
    08:47:50 auto-connecting        -> queues ENABLE
    08:47:56 auto-connect  = true   <- ENABLE done
    08:48:01 disconnect    = true   <- DISABLE done 4 s LATER

`bt::request_audio` serializes them and drops any request a newer one overtook.
It also keeps the Win32 codes; the bool collapsed ERROR_SUCCESS, access-denied
and ERROR_INVALID_PARAMETER into "false" and made the path undebuggable. That
mattered: the codes showed BluetoothSetServiceState returns 87 whenever the
device is not connected and 0 whenever it is, which is why the first reclaim
campaign — which only ever called it while the buds were away — could not work.

PRIORITY. `Tier` encodes the hierarchy Apple routes by: 1 calls, 2 user-started
playback, 3 background/system sounds. A phone notification is tier 3 and must
not outrank a tier-1/2 session here, so a loss while we hold 1 or 2 starts a
bounded reclaim campaign; at tier 3 we let go as before. Tier 1 is the virtual
mic in use; tier 2 is SMTC *or* an active WASAPI render session, because Teams,
Discord and games never register with SMTC and read as silence there.

REPAIR. Reconnecting the AAP session is not enough — Windows keeps the device,
endpoint and profile healthy while the audio plays elsewhere, so it never
rebuilds the stream. `force_audio_rebuild` toggles the audio services, which is
exactly what the user does by hand, and it hangs off the handshake (where the
device is connected) rather than off the loss (where the call returns 87). Armed
for every tier: most losses happen at tier 3, and those were the ones that never
came back.

RESUMING. On a real phone call the buds stay away for minutes: the ~18 s of
connect retries ran out, `give_up` cleared connect_requested, and nothing tried
again. The BLE watcher could not help — it re-arms only after a 45 s absence,
and during the call they advertise throughout. Measured: released 14:37:05, gave
up 14:38:17, session back only at 14:38:33 because the user pressed Connect. The
idle gate now also resumes when Windows has the AirPods again, which steals
nothing; an explicit Disconnect still holds us off.

KEEPALIVE. The worst case leaves no passive signal at all: on 2026-08-31 the
sound died for ten and a half minutes with driver.status() at Ok(2) every
second, the device connected and the endpoint OK. But an AAP command is
acknowledged, so we can ask instead of watch. Every 30 s, and only while this
host is actually playing, re-assert the current noise mode and wait 5 s for any
inbound packet; two unanswered probes trigger the rebuild. The same poke fires
on the silence->playing edge, to pre-empt the fault rather than detect it.

Plus a 60 s heartbeat logging the real AAP data age. All the diagnostics lived
inside the `else` branch of driver.status(), so a healthy-looking status made us
blind by construction.

RepairConnection gains a five-second debounce — each press drops and reopens the
exclusive handle, and the log shows four in two seconds, which is the churn that
strands the devnode in Code 38. `devnode.rs` recovers from that without a
reboot: fire an on-demand elevated scheduled task (the mic-rename pattern) with
`schtasks /run`, rate-limited; the task re-checks the devnode and no-ops when it
is healthy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ljr4VfCfTNYw244k8cnbEV
Windows cannot see multipoint: it only knows about its own link, and during a
handoff the endpoint, the Bluetooth device and the AAP channel all keep
reporting healthy while the audio plays on a phone. The buds are the only party
that knows, and they say so — in one capture the phone appeared in AAP 0x2E 3.2
seconds before Windows noticed anything.

`parse_connected_devices` reads that list. Each entry is a 6-byte address in
big-endian (so it compares directly with a BLUETOOTH_ADDRESS) plus two flag
bytes. A first version read the address little-endian, which produced this
machine's own radio byte-reversed and so never matched itself.

The flag bytes are deliberately NOT interpreted. A first version guessed a
device class from them, on one capture where this PC was `02 06` and a phone
`01 00`; more data killed it — over 23 packets the same PC appeared as `02 06`
(15x), `01 06` (4x) and `02 04` (4x). A value that changes for a fixed device is
not its class. `flags_hex` keeps them in the log so more samples can settle what
they are, and no label is inferred: an iPhone shown as "Computer" is worse than
an honest "Other device".

`route_diag` dumps the routing/stream opcodes the daemon does not interpret
(0x2B Stream State Info, 0x2E, 0x44 Smart Routing 2.0, 0x52 Source Context), so
a reproduction shows which one announces a handoff.

`volume::any_render_active` walks the Core Audio render sessions on the default
endpoint. SMTC only sees apps that register a media transport session — Teams,
Zoom, Discord and games never do, and read as silence there. The system-sounds
session is skipped on purpose: that is Apple's tier 3, and Core Audio hands us
exactly that distinction through IsSystemSoundsSession, so a Windows chime does
not make us fight a phone over a ding of our own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ljr4VfCfTNYw244k8cnbEV
…dio" button

Two things the user could not see or do.

A "Shared with" card lists every host the AirPods report themselves connected to
(AAP 0x2E) — this PC plus whatever else holds them. It is the only place that
sharing is visible, since Windows knows nothing about multipoint. Hidden until
the first 0x2E arrives. Identification goes from the most trustworthy source
down: our own radios ("This PC"), then a device paired to this machine (Windows'
own name and Class of Device), then "Other device" with its address. A phone
sharing the buds is normally not paired here, so it lands on the last one; the
0x2E flag bytes are not a device class (see the parser) and naming it "iPhone"
would be invention.

"Restore audio" toggles the AirPods' audio services — the one-click version of
Disconnect then Connect, which is the only thing that reliably brings the sound
back. It deliberately does not touch the AAP session: in the failure it exists
for, the session is perfectly healthy and rebuilding it would be both useless
and disruptive. Shown only while connected, which is the opposite case to
"Repair connection".

Strings in all four shipped languages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ljr4VfCfTNYw244k8cnbEV
prebuilt/ exists so the driver can be installed without a WDK, so it has to
follow the source. Rebuilt from windows/drivers/aap at Release; the catalog is
regenerated over it (inf2cat reports no signability errors).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ljr4VfCfTNYw244k8cnbEV
…oducible

The dist folder had drifted into a second, older copy of the installer: its
install.ps1 still deployed the retired iced app and the old tray, knew nothing
about the daemon or the WinUI app, and pointed startup at librepods-tray.exe.
Every fix had to be made twice, and the copy under version control was not the
one being installed.

make-dist.ps1 assembles a dist from THIS repository and is now the only
supported way to produce one. Everything but the two built artifacts comes
straight out of the tree: the installer, the recovery script, devcon, and both
prebuilt driver packages. It also copies librepods-winui.pri back into the WinUI
publish output — that publish drops the app's own resource index, and without it
the app starts with no strings and no icons.

install.ps1 gains fix-driver.ps1 and its elevated on-demand task, so the daemon
can recover the AAP devnode from Code 38 without a UAC prompt and without a
reboot (see daemon/src/devnode.rs). The script decides for itself whether to
act, no-ops on a healthy devnode, and self-elevates when run by hand.

fix-driver.ps1's escalation order is measured, not guessed. Restarting our own
devnode is tried first; then the Bluetooth RADIO, because the stale PnP state
lives in the branch above our leaf node — with the LibrePodsAAP service already
STOPPED, `pnputil /restart-device` still answered "System reboot is needed" for
the leaf. remove-device is demoted to last: on its own the rescan rebinds into
the same stale branch and lands back on Code 38, which only obscures the
diagnosis. When PnP reports CM_PROB_NEED_RESTART the script stops and says a
reboot is genuinely required rather than churning the device further.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ljr4VfCfTNYw244k8cnbEV
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant