Native Windows support for AirPods (self-contained windows/ folder) - #716
Open
arctumn wants to merge 42 commits into
Open
Native Windows support for AirPods (self-contained windows/ folder)#716arctumn wants to merge 42 commits into
arctumn wants to merge 42 commits into
Conversation
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.
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
marked this pull request as ready for review
August 11, 2026 17:15
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.
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The short version
Native AirPods support for Windows, as a self-contained
windows/folder at the repo root, next toandroid/— 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.
0x2E)0x001F)ksni)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.
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):
0x001F)0x2E)librepodsd) that owns the exclusive driver + AAP session and serves thin UI clients over named-pipe IPCWhat doesn't work (yet), honestly
How it's built
librepodsdowns 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.AF_BTHspike that returnedWSAENETDOWN): AAP runs over a classic-Bluetooth L2CAP channel (PSM0x1001) 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).linux-rust/is touched.ci-windows.ymlis path-scoped towindows/**, so a Windows build never gates a Linux release. FFmpeg (AAC-ELD decode) is fetched at build time (pinned + SHA256), not vendored.AAP Definitions.mdis included as protocol reference.Install & releases
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).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.
Feedback welcome — on the drivers, the daemon/IPC boundary, or anything I've framed unfairly above.