Skip to content

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

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

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