Skip to content

menu bar: pick the input device (capture through AUHAL) - #14

Open
andredezzy wants to merge 7 commits into
digimata:masterfrom
andredezzy:feat/input-device-picker
Open

menu bar: pick the input device (capture through AUHAL)#14
andredezzy wants to merge 7 commits into
digimata:masterfrom
andredezzy:feat/input-device-picker

Conversation

@andredezzy

@andredezzy andredezzy commented Jul 31, 2026

Copy link
Copy Markdown

Pick the input device from the menu bar, and record from the one you picked.

AVAudioEngine's input node follows the system default and ignores a device set on the engine, so honouring a choice at all needs AUHAL. Capture goes through kAudioUnitSubType_HALOutput with the device written explicitly, bound at the device's own sample rate and resampled here, because a Bluetooth headset that reports 16 kHz will not accept 48.

Seven commits, each with the reproduction in the body:

  • capture through AUHAL so a device choice is honoured
  • bind AUHAL at the device's own rate and resample here
  • write the device explicitly so "Same as System" records
  • rebuild the engine for each recording
  • the menu itself, the "Same as System" row, and hiding CoreAudio's private aggregate from the list

Measured on this machine with a Baseus BP1 Pro over Bluetooth and the built-in microphone.

@andredezzy
andredezzy force-pushed the feat/input-device-picker branch from 2df0031 to 2221adc Compare July 31, 2026 00:22
FernandoGomes83 added a commit to FernandoGomes83/parrot that referenced this pull request Jul 31, 2026
FernandoGomes83 added a commit to FernandoGomes83/parrot that referenced this pull request Jul 31, 2026
Transcripts no longer logged by default (timing/length only;
--echo-transcripts opts in), LaunchAgent discards output unless
--log-file (0600), doctor flags and install --purge-legacy-logs
removes the old world-readable /tmp logs, event tap narrowed to
flagsChanged only and re-enabled when the system disables it,
install.sh verifies sha256 + attestation, release workflow signs
build provenance. Also fixes MenuBarController super.init order
from the digimata#14 merge.
@andredezzy
andredezzy force-pushed the feat/input-device-picker branch from 2221adc to 7d05c2f Compare July 31, 2026 18:04
@andredezzy andredezzy changed the title menu bar: pick the input device menu bar: pick the input device (capture through AUHAL) Jul 31, 2026
@andredezzy
andredezzy force-pushed the feat/input-device-picker branch from 7d05c2f to 3e2158d Compare July 31, 2026 19:22
UNVERIFIED -- not reproduced locally, do not upstream as-is.

A long-lived AVAudioEngine reports the input format it cached at construction,
so after the default device changes installTap gets a format the bus no longer
has and AVFAudio raises 'com.apple.coreaudio.avfaudio': "Failed to create tap
due to format mismatch, <AVAudioFormat 1 ch, 16000 Hz, Float32>". That is an
ObjC exception, so the daemon dies rather than throwing.

Confirmed the format is stale (engine reported 48000 Hz while the device sat at
44100 Hz) but installTap tolerates a rate-only divergence, so the failing
condition needs a device identity change to reproduce.
Records from the chosen microphone without touching the system default input,
so other apps are unaffected.

The preference is stored as the device UID, not the AudioDeviceID: CoreAudio
reassigns the numeric id across reboots and reconnects, so a stored id can
silently point at a different microphone. It resolves to an id per recording,
and an absent device falls back to the system default until it reappears.

AudioCapture binds the node with auAudioUnit.setDeviceID(_:) before reading the
input format, since the node reports the format of whichever device it is bound
to. The submenu is rebuilt in menuWillOpen so a newly plugged mic shows up
without watching CoreAudio for device changes.
CoreAudio creates an aggregate device per audio client the moment an input node
is touched, named CADefaultDeviceAggregate-<pid>-N. It is visible only to that
client, reports input channels, and is not flagged hidden — so parrot was
listing its own plumbing as a selectable microphone.

The composition dictionary of those aggregates carries the private key, which is
what separates them from an aggregate the user built in Audio MIDI Setup; the
latter is not private and stays listed.
AVAudioEngine's input node binds to whatever input is current the moment the
node is first touched, and nothing reconfigures it afterwards. Measured against a
Bluetooth headset while the built-in mic stayed the system default:

    auAudioUnit.setDeviceID(_:)            noErr, 0 frames in 3s
    AudioUnitSetProperty(CurrentDevice)    noErr, 0 frames in 3s
    setDeviceID + engine.reset()           noErr, 0 frames in 3s
    headset as system default, no binding  115200 frames, peak 0.0212

Every binding call reports success and then delivers silence, which is how the
menu's device picker could look correct and record nothing.

A standalone AUHAL unit honours kAudioOutputUnitProperty_CurrentDevice: same
headset, same conditions, 48000 frames in 3s. Verified end to end afterwards —
5.06 s captured at rms 0.028 through the headset with the system default left on
the built-in microphone.

Two things leave the code with AVAudioEngine. AUHAL converts to 16 kHz mono
itself, so AVAudioConverter is gone; and there is no installTap(onBus:format:),
so the uncatchable AVFAudio format-mismatch exception that could terminate the
daemon on a device change is no longer reachable. The unit is disposed on stop so
a Bluetooth headset does not keep its microphone link open.
AUHAL input does not resample. Asking a 48 kHz device to deliver 16 kHz renders
nothing at all: the built-in microphone captured 0.00 s while a 16 kHz Bluetooth
headset — whose native rate already matched the request — captured fine. The
first version of this only ever ran against the headset, so the conversion path
was never exercised.

Read the device's own format from the input scope, ask AUHAL for float at that
same rate, and put AVAudioConverter back in charge of reaching 16 kHz mono.
AUHAL then does only what it can do: pick the device and hand over float.

`AudioUnitRender` failures are now logged. Swallowing that status is what made a
failed render look exactly like a microphone that heard nothing.
Left unwritten, kAudioOutputUnitProperty_CurrentDevice still reports the
right device and AudioOutputUnitStart returns noErr, but the unit renders
zero frames. Measured: 0 frames in 2 s unbound against 30400 bound to the
same device id.

The system default input is now resolved and written like any other
choice, so the default preference is no longer the broken one.
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