Native Windows support for AirPods (self-contained windows/ folder) - #716
Open
arctumn wants to merge 29 commits into
Open
Native Windows support for AirPods (self-contained windows/ folder)#716arctumn wants to merge 29 commits into
arctumn wants to merge 29 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.
added 4 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.
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.