Skip to content

audio: raise a turned-down microphone, and let the user fix what the model mishears - #22

Open
andredezzy wants to merge 9 commits into
digimata:masterfrom
andredezzy:feat/raise-input-gain
Open

audio: raise a turned-down microphone, and let the user fix what the model mishears#22
andredezzy wants to merge 9 commits into
digimata:masterfrom
andredezzy:feat/raise-input-gain

Conversation

@andredezzy

@andredezzy andredezzy commented Aug 1, 2026

Copy link
Copy Markdown

Raise a turned-down microphone for the length of a recording, and put the user's setting back afterwards.

Includes #14 as a dependency: the gain is applied to the resolved capture device, which is what the picker chooses.

My MacBook Pro microphone sat at 0.133 input gain, and nobody sets that on purpose. Measured on the same sentence, same voice, at three levels: 0.133 gives 0.083 peak, 0 clipped samples; 1.000 gives 566 clipped samples and +14.0 dB. The 1.5 to 3.5 kHz band, where the consonants that separate words live, came in 11.6 dB weaker than at 1.000. Whisper reads a 0.70 gain during the recording, and if the level is below 0.50 it restores your value on release.

Doctor reports a microphone parrot cannot turn up, because some devices expose no settable volume scalar.

@andredezzy andredezzy changed the title audio: raise a turned-down microphone for the length of a recording audio: raise a turned-down microphone, and let the user fix what the model mishears Aug 1, 2026
@andredezzy
andredezzy force-pushed the feat/raise-input-gain branch 2 times, most recently from 012a2b7 to c2a60a2 Compare August 1, 2026 12:46
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.
A MacBook's built-in microphone was found at 13% input gain, and that one
setting is the largest measured accuracy factor in this project. Same
speaker, same phrase, same model:

  gain 0.133  peak 0.083  baseline           "revisá-los por request"
  gain 0.700  peak 0.375  +11.6 dB at 1.5-3.5 kHz  "pull requests" correct
  gain 0.900  peak 1.000  566 clipped samples      one dictation came back empty

Across a whole corpus the difference is 84.8% -> 94.7% word accuracy. The
consonants that separate words live in that band; below the floor they are
not in the recording to begin with, and no decoder setting recovers them.

Recording raises the gain to the measured optimum and puts the user's value
back on stop. Devices already above 50% are left alone, and a device that
refuses the write is left alone. 0.9 is deliberately not the target: the
extra energy up there is clipping distortion, and it transcribes worse.
@andredezzy
andredezzy force-pushed the feat/raise-input-gain branch from c2a60a2 to 9589e0e Compare August 1, 2026 21:39
Recording raises a turned-down microphone by itself, but a device that
refuses the write does so silently, and the user would be left with a
quiet recording and no reason for it. The check only speaks up in that
case: adjustable devices are handled without saying anything.
@andredezzy
andredezzy force-pushed the feat/raise-input-gain branch from 9589e0e to dac8224 Compare August 1, 2026 21:58
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