Microphone input through virtio-snd
The speaker chain is in virtio_sound.md; this is the input side: what the
macOS plugin does to capture, what the guest plugin does, and what
VirtualAudio needed before a recording app's route would build. Sources, all
local:
- macOS
/System/Library/Audio/Plug-Ins/HAL/AppleVirtIOSound.driver, arm64e slice, disassembled withotool -tV(offsets in §1 are that slice's). This is Apple's own virtio-snd HAL plugin, the reference the guest plugin was modeled on for output. - The guest's own
AudioServerDriver, dumped in the guest (asd-guest-dump.txt,asdimp-out.txtfrom the speaker work): it hasaddInputStream:,setReadInputBlock:,setInputLatency:andsetInputSafetyOffset:, so the input API is there andAudioServerDriver.tbdneeds nothing new (it binds whole classes). - The guest's
VirtualAudiobinaries saved under~/.vphone/va-analysis/(iPadOS 26.6.2 and iOS 27.0), and audiomxd logs captured on a test guest (mictest-ipad, iPad16,1 / 26.6.2) while this was built.
0. Where the chain stands
1. The reference implementation: AVIOStream input in the macOS plugin
1.1 Device construction (AVIOPlugin halInitializeWithPluginHost:, 0xe08)
Per AppleVirtIOSound service: IOServiceOpen on the plugin's own task, then
three registry counts through a shared helper (0x1ab8) —
AVIOSoundJackCountKey, AVIOSoundStreamCountKey, AVIOSoundChannelMapCountKey
(strings at 0x6d57/0x6d6d/0x6d85). The jack count drives the control set
(§1.3); the stream count drives the enumeration loop.
Stream enumeration, one PCM_INFO (selector 1) per virtio stream ID, parsed
out of the 32-byte virtio_snd_pcm_info:
Each accepted stream becomes one AVIOStream
(initWithDirection:('inpt'/'outp') withPlugin:, 0x13f4), with:
- a serial
dispatch_queue_create("com.apple.AVIOStream.data", NULL); - an
IONotificationPortwhose dispatch queue is that same queue — async completions are delivered on it (this is why every handler begins withdispatch_assert_queue$V2); _volume = 1.0f, state machineInitial → SetParameters → Prepared → Started → Stopped → Released.
For each rate the device advertises, a copy of the 48 kHz
ASDStreamFormat is made with setSampleRate: + setMinimumSampleRate: +
setMaximumSampleRate: pinned to that rate, then all copies go to
setPhysicalFormats: and the 48 kHz one to setPhysicalFormat:.
Registration is by direction (0x1500-0x1550): [device addInputStream:stream]
for 'inpt', [device addOutputStream:stream] for 'outp'. The macOS plugin creates and registers
input streams unconditionally, on the same device as the output streams.
Device-level properties the input path needs: setCanBeDefaultInputDevice: is
among the three setCanBeDefault*: calls (0x1080-0x109c),
setInputSafetyOffset: 100 (0x10cc), and setTimestampPeriod: rate × 260 / 1000 (0x10f4) — the input side shares the device clock, exactly
as virtio_sound.md §6 already established for output.
1.2 Format selection (the formats bitmap, 0x11dc-0x1234)
First matching bit wins, in this order — the winner sets
mBitsPerChannel and the 'lpcm' flags:
VZHostAudioInputStreamSource offers float32/48 kHz like the sink, so the
guest plugin's existing VPVirtIOSoundChooseFormat preference already
matches Apple's ladder for the formats that matter.
1.3 The input control set (0x159c-0x177c)
After the stream loop, per direction (the guest plugin builds the same set
for each of its two devices; virtio_sound.md, validation item 5):
- Output, when
hasOutput:ASDSelectorValue'ispk'named "Speakers" →ASDSelectorControl(initWithIsSettable:YES element:0 scope:'outp' … andObjectClassID:'dsrc') →addValue:/setSelectedValues:→[device addControl:]→_addMuteAndVolumeControlsInScope:'outp'(0x1b48). - Input, when
hasInput(0x168c onwards): the identical shape withASDSelectorValue'imic'named "Microphone", scope'inpt', then_addMuteAndVolumeControlsInScope:'inpt'— mute and volume controls in the input scope too (the assert at 0x6e4b names both scopes legal).
Same lifecycle constraints as the output controls apply on the guest
(virtio_sound.md item 5): build through the explicit-class initializers,
add after device activation, never booleanValue.
1.4 Stream start (-[AVIOStream startStream] block invoke, 0x40e0-0x4290)
On the stream queue, the state machine runs
SET_PARAMS (selector 3, 24-byte struct) → PREPARE (selector 4, scalar) →
if direction == 'inpt': submit the initial receive buffers — the loop at
0x41a8-0x41b8 calls _readInputIntoRingBuffer exactly four times
(kInputInitialBuffers = 4), after asserting the ring holds at least four
periods → START (selector 5).
Those four periods in flight then keep themselves alive: every completion submits one more (§1.6). There is no timer on the input side — unlike the output side's 1/24 s flush timer, capture is completion-driven.
1.5 RX submission (-[AVIOStream _readInputIntoRingBuffer], 0x4410)
Asserts on-queue, direction == 'inpt', isStreaming, ring present. Ring
geometry (the C++ AVIOStreamRingBuffer, layout recovered from
readInputFrames and this function):
Submission gate: capacity − (inFlight − read) ≥ period, else return
(a full ring means the HAL is not keeping up; capture pauses, nothing is
overwritten or dropped). Then:
and the transfer itself (0x45c0-0x45ec) — the exact RX counterpart of the plugin's TX call:
Three async reference slots are passed (same kAsyncReferenceCount the guest
plugin already uses for TX), in IOKit's own layout: slot 0 is reserved for
the wake port, references[1] (stored at sp+0x78, 0x4584) is the C
trampoline signed paciza (0x4ec4), references[2] (sp+0x80, 0x4590) the
retained Objective-C completion block as the refcon. If the call fails
synchronously, the completion block is invoked immediately with
(kern_return, 0) (0x45f8-0x4620).
The completion block's type encoding is v16@?0i8I12 —
void (^)(kern_return_t result, UInt32 bytesTransferred).
1.6 RX completion (trampoline 0x4ec4 → block invoke 0x4fd0)
The trampoline receives the refcon (the block) from IOKit's async
machinery, invokes the block with whatever IOKit passed after it left in
place (x1, x2), and releases it. The block reads neither: the invoke
(0x4fd0) keeps only its own captures across dispatch_assert_queue. On the
stream queue:
ring->didWriteInputInFlightBytes()(0x54dc) — takes the ring alone and advanceswritePositionby one period (+0x14), whatever the kernel reported. A completion is a period; a failed or short one is counted the same.- Retirement bookkeeping (
0x496c) for the submitted ring — a stream whose ring was replaced mid-flight (_ringBuffersToDelete/_deleteRingBufferIfDone:/_isActiveStreamingRingBuffer:) retires it without resubmitting. - If the completing ring is still the stream's current ring → call
_readInputIntoRingBufferagain. This is the steady-state loop: four in flight, one resubmitted per completion.
1.7 The HAL pull (readInputBlock getter 0x32b4 → invoke 0x3350)
The getter rebuilds and returns the block on every call — it is a property
getter override, and this is Apple's own answer to the ASD
releases-blocks-on-stop behavior the vphone plugin works around with
installIOBlocks (virtio_sound.md §6): the macOS plugin never lets the
property read nil. (The device-level getZeroTimestampBlock /
willDoReadInputBlock / willDoWriteMixBlock getters, 0x2358/0x2434/0x24e0,
are the same pattern.)
The invoke is AVIOStreamRingBuffer::readInputFrames inlined — signature the
full IO-block shape (int)(UInt32 frameCount, const AudioServerPlugInIOCycleInfo *cycleInfo, void *mainBuffer, void *secondaryBuffer, UInt32 clientID):
byteCount = bytesPerFrame × frameCount;- if
byteCount > capacityorbyteCount > (writePosition − readPosition)→bzero(mainBuffer, byteCount)— an underrun delivers silence, never stale audio and never an error; - else
memcpyout of_buffer + (readPosition % capacity), split in two when the range wraps the end of the buffer; readPosition += byteCount(assertreadPosition <= writePosition);- optional gain:
volumeProcessor->process(frameCount, mainBuffer, (float)*(double *)((char *)cycleInfo + 0x50))— the scalar the macOS plugin reads out of the cycle info; skipping this (gain 1.0) is fine for a first implementation.
So the input data path is: kernel fills slot → completion advances
writePosition and resubmits → the HAL's IO cycle pulls
readInputFrames → CoreAudio. The ring absorbs the rate mismatch; overrun
pauses submission, underrun gives silence.
1.8 willDo* answers (block invokes 0x3e0 / 0x4b4)
The device's willDoReadInputBlock / willDoWriteMixBlock answers are built
by the getters from the device's direction presence: *willDo = <has that direction>, *willDoInPlace = true; the only error path is NULL out-pointers
('unop'). The guest plugin answers the same way, for each device's own direction.
2. The AudioServerDriver input API
Introspected from the host's shared cache with class_copyMethodList. The
AudioServerDriver.tbd the guest plugin links needs no additions: it binds
whole classes (objc-classes:), and selectors resolve at runtime.
ASDAudioDevice, input-relevant surface: addInputStream:,
removeInputStream:, inputStreams, hasInput, canBeDefaultInputDevice
(+ setter), inputLatency, inputSafetyOffset, inputMaximumIOFrameSize
(+ setters), willDoReadInputBlock (+ setter + willDoReadInputBlockUnretainedPtr),
startIOForClient: / stopIOForClient:, beginIOOperationBlock /
endIOOperationBlock.
ASDStream: initWithDirection:withPlugin: (direction is the scope
fourcc), setDirection:/direction, readInputBlock (+ setter +
readInputBlockUnretainedPtr), readIsolatedInputBlock, terminalType
(+ setter), startingChannel, latency, setIsActive:,
channelCategoryForChannelIndex: / channelNameForChannelIndex: /
channelNumberForChannelIndex:, and a DSP-hook family
(processInputBlock, convertInputBlock, mixOutputBlock, …) the virtio
plugin leaves alone.
The guest's framework has the same input surface: its own method dump lists
addInputStream:, removeInputStream:, inputStreams, hasInput,
setCanBeDefaultInputDevice:, setInputLatency:, setInputSafetyOffset:,
setWillDoReadInputBlock: on ASDAudioDevice and readInputBlock,
setReadInputBlock:, readInputBlockUnretainedPtr on ASDStream. Every
block property has an …UnretainedPtr twin, the copy the I/O thread calls,
which performStopIO and stopStream clear (virtio_sound.md §6): the read
block, like the mix block, has to be set again before every start.
3. The host side
VZHostAudioInputStreamSource opens the Mac's microphone when the guest
starts its input stream, in the vphone-vm process. macOS asks the user on
behalf of the process responsible for that, and refuses a process whose
bundle has no NSMicrophoneUsageDescription. VPhone.bundle had none.
VPhone.bundle/Contents/Info.plistnow carries the description, andInfoPlist.xcstrings(the catalog that already translated the location prompt forVPhoneLocation.app) is compiled into the bundle's ownResourcesas well, so the prompt is in the user's language.ValidateBundle.shchecks both.- Launchpad's
Info.plistand catalog carry the same text. A VM that Launchpad starts is responsible for itself (responsibility_spawnattrs_setdisclaim), but when that call is unavailable the VM is attributed to Launchpad, the same arrangement as the location descriptions there. - The bundle's programs are signed ad hoc, so the permission macOS records is tied to a code hash: a new bundle version asks again.
Which hash, and what would make macOS ask once, measured on the host
(2026-10-04) with a small tool that reads
AVCaptureDevice.authorizationStatus(for: .audio), started responsible for
itself as Launchpad starts a VM. Both builds of it were signed ad hoc with
designated => identifier "…", the one requirement an ad hoc signature could
share between builds:
So the permission follows the code hash of the responsible process, not its
path, and for ad hoc code a designated requirement that names only an
identifier changes nothing. The responsible process of a VM that Launchpad
starts is that bundle's vphone-cli (responsibility_get_pid_responsible_for_pid
on a running vphone-vm and on its Virtualization XPC service both give it),
which is why every bundle build is asked again. A program started by one that
holds the permission is not asked, and stays so after its parent exits.
Asking once therefore needs a process with a stable signature to be
responsible for the VM: Launchpad itself, which is signed with a Developer ID
and already carries the description, or a small signed launcher inside it
that starts vphone-cli and stays. Launchpad disclaims the VM on purpose, so
that is its decision to change and is not done here.
The host captures at its own rate and Virtualization.framework hands the guest the 48 kHz float frames the stream was set up for.
4. The routing side
The device's name decides the port
VirtualAudio's device factory builds a physical device, and a port, only for
the UIDs in its table (0xe1354… in the 26.6.2 binary): Speaker,
AppleSongbirdDSP, PuffinInput, PuffinOutput, Actuator, AOP Audio-1,
HP16Mic, Digital Mic, DigitalMic, Mic, Hawking, Flicker,
Penrose, … One UID names one kind of port, so the speaker (PuffinOutput,
pspk) and the microphone cannot be one HAL device: the plugin publishes two
per AppleVirtIOSound service.
Three names were candidates for the microphone:
So the microphone device is Digital Mic unless
VPhoneVirtIOSoundInputDeviceUID names another. With it, at audiomxd launch
VirtualAudio reads the input volume control (Device Digital Mic, hardware volume range: -60.000000 0.000000 [inpt/0]) and builds the speech-detection
aggregate on it (VAD [vspd] AggDev, master Digital Mic).
The route a recording app asks for
Voice Memos records with category cpar (play and record), mode imdf.
RoutingHandler_PlaybackAndRecord_GenericConfig1 builds that route in full:
and then declines it for the capability the playback handler declined the
ringtone route for (virtualaudio_speaker_route_throws.md): HAL Speaker
Protection, which only a physical codec reports. The gate has the same shape
in both handlers and on both builds:
system-virtualaudio-cfw-speaker_protection_gate therefore writes a second
site, record ….playback_and_record: the log block's head becomes a branch
back to the gate's fall-through, exactly as in the playback handler, found by
this handler's own file name
(RoutingHandler_PlaybackAndRecord_GenericConfig1.cpp). The verb
cfw patch-virtualaudio-sp-gate patches both; cfw install and
cfw update-environment already run it.
This handler also masks a volume-mode lookup with the 33-bit packing mask
(0xea3fc), but branches on it between two ways of building the route. It
is not a precondition and nothing declines there.
The format the route sets on the streams
With the gate opened the route reached its last step and failed there:
Stream 44 is the speaker's, left at 44100 by the last system sound. A
playback route moves the rate through the device (nsrt, which the plugin
already answers itself because ASDAudioDevice refuses it for a plugin
device; virtio_sound.md, validation item 6). The duplex aggregate sets the
stream's physical format instead, and ASDStream refuses that the same
way. The plugin's streams now take a refused pft /sfmt set whose rate
the device supports and make it the device's rate change, which reaches
every stream: stream 1: format set to 48000 Hz through the device.
Two messages in the same capture did not stop the route and are left alone:
error 'what' setting property data for property [ssrc/inpt/0] on id 37
(VirtualAudio selecting a data source on the device; the plugin's selector
control answers the control-level property, not this device-level one) and
We expect this device to report a correct clock domain, but it is coming back as NULL/zero.
5. What the guest plugin does
VPhoneGuestComponents/VirtIOSound:
- Two devices per service, sharing one user-client connection
(
VPVirtIOSoundConnection). The macOS plugin has one device with both directions on one connection and a queue per stream; the guest keeps that and only splits the HAL device. A secondIOServiceOpenwas not tried. - Streams:
VPVirtIOSoundStreamholds what both directions share (the virtio stream's identity and format, the serial queue, SET_PARAMS and PREPARE, the property inventory);VPVirtIOSoundOutputStreamis the speaker path as it was;VPVirtIOSoundInputStreamis new. - Input transfers (
submitReads): four periods of the input ring are with the kernel at a time (selector 9, the slot as the output structure), submitted between PREPARE and START as the macOS plugin does, and each completion submits the next. A completion counts as one period, as in the macOS plugin; a failed one is zeroed first, and is not replaced at once but a period later, so a kernel that refuses the selector costs four calls a period and no more. - The ring (
VPVirtIOSoundInputRing,consumed <= filled <= submitted) and the reader (VPVirtIOSoundInputReader) are inVPVirtIOSoundRing.[ch]and tested on the host (make test-virtiosound). The reader is where the guest departs from the macOS plugin, which copies out whatever is there and zero-fills a short read: captured frames arrive a period (85 ms) at a time and the HAL reads a few milliseconds at a time, so read straight through the ring runs empty just before every period lands. Reads are served once two periods are buffered, a read that finds too little waits for two again, and more than four periods buffered are trimmed back to two, which bounds the delay when the host captures faster than the guest's clock reads. The device reports the two periods asinputLatency. - Stop: the reads still out come back as the host fills them, a period apart, and the device is released (STOP, RELEASE) after the last, so the host microphone closes about a third of a second after the guest stops recording. Releasing first would leave the kernel holding ring slots with nothing known about when it returns them; that is the fallback after one second, for a host that has stopped filling them, and the stream then waits for the slots before it can be set up again. A start that finds the stream still draining carries on with the device as it is.
- A full ring (the HAL not reading) leaves fewer than four reads out, and no completion would bring the count back; the stream looks again a period later.
- Stream formats: a format set that
ASDStreamrefuses becomes the device's rate change when the device supports the rate (§4), on both kinds of stream. - Device:
canBeDefaultInputDevice,inputSafetyOffset100, the wire rate and the speaker's 44100 twin (captured frames are converted as they are read; "Reading at 44100" below),willDoReadInputBlockanswering for the device's own direction, and the macOS plugin's input control set: data source'imic'"Microphone", mute and volume in the input scope, built the way the speaker's are.
Reading at 44100
The speaker's device answers 44100 beside the wire's 48000, and a session
that plays and records sets the rate it wants on both streams of its
aggregate. With the microphone answering 48000 only, a session that asked for
44100 did not fail; it was given 48000. Measured on avtest-ipad
(iPad16,1 / 26.6.2) with a test app (playAndRecord, preferred rate 44100,
an AVAudioEngine playing a 440 Hz tone and tapping its input):
The app then ran at 48000 and captured 47,600 frames a second. A device with a codec honours 44100, and an app that builds its formats on the rate it asked for does not expect otherwise, so the microphone now answers it too.
The virtio stream stays at the wire rate, as the speaker's does. The input
stream carries a 44100 physical format beside its own, the device lists both
rates, and when the device runs at the other one the read block converts:
VPVirtIOSoundConverter (VPVirtIOSoundConverter.[ch], tested on the host)
is asked how many wire frames the read takes, those come from the reader as
before, and each frame handed to the HAL is the straight line between the two
wire frames around it. The phase is kept in whole units of 1/44100 of a wire
frame, so the count is exact and the ratio does not drift. It is the
speaker path's conversion turned around, and it filters nothing first: what
lies between 22,050 and 24,000 Hz folds back, which a microphone has next to
none of. Only 32-bit float with one or two channels is converted, which is
what the host sends; any other format keeps the wire rate alone.
The same app after the change, both rates in one launch:
out on the microphone line counts wire frames whatever rate the HAL reads
at. The pitch says the conversion runs the right way: with the guest's tone
coming out of the Mac's speakers into its microphone, the capture's power at
440 Hz was 44–45 dB at both rates, against 9–18 dB at 404 Hz and 479 Hz,
where a rate taken the wrong way round would have put it.
Listened to (2026-10-04): 25 s recorded at 44100 on avtest-ipad through
the Mac's AirPods Pro microphone, with a build from before the 120 Hz low
cut, sounds normal: no clicks, no metallic edge, no change of speed. Its
level is -22 dB mean, -4 dB peak, and above 17 kHz it is at -74 dB, so the
unfiltered straight-line conversion left no fold-back worth hearing.
6. Reveal and validation
Measured on mictest-ipad (iPad16,1, iPadOS 26.6.2), recording in Voice
Memos:
-
Plugin:
vpquery.logafter=== plugin load ===lists both streams'PCM_INFO,stream 0: input, 48000 Hz, 2 channels, 32-bit float, period 32768 bytes, lead 2 period(s),device Digital Mic: 48000 Hz nominal …,microphone controls added: dsrc 'imic', mute, volume,published 2 virtio sound device(s). -
HAL:
HALS_Device::Activate: activating device 37: Digital Mic, and the input-scope queries arrive ('stm#','saft','ltnc','dflt'on'inpt'). -
VirtualAudio:
VA Init Status: 0,Adding port [ type: pmbi; uid: Built-In Microphone; … rout: 1 ], noNo match found for internal sub-port ID. -
Route:
cfw update-environmentlogsSP-gate playbackAndRecord handler 0xe92c0 … 0xec370; log block 0xea588; gate 0xea274; Record then leaves noHAL Speaker Protection is missingand noRoute change failed, andvpquery.logreadsstream 1: format set to 48000 Hz through the device. -
The kernel serves selector 9:
stream 0: first read back: result 0x0, argument 0x0. The completion carries no byte count, which is why the macOS plugin counts a period. -
Sound arrives. A 41.7 s recording, decoded on the host: a noise floor near -47 dBFS and events up to -16 dBFS RMS, 0.19 s of zeros at its start (the lead). Its stop lines:
silentis the lead (8192 frames) and the reads before the first period; nothing after. -
A second recording, minutes later (the device set up again):
8.42 s, 98 reads, in … (47665/s) … 8960 silent. -
A recording started 0.34 s after the last stopped, while its reads were still out:
6.22 s, 73 reads, in 299008 frames (48072/s) … 6720 silent, and noreads still out, releasingline. audiomxd left no crash report. -
Speaker unchanged: Safari playing for 47.47 s leaves
557 writes, 0 starved, 0 bytes dropped, in … (44096/s, hal 44100). The same test while another VM on the host was using seven cores read25 starved; a busy host shows up there, on either side of this change.
The first recording after the VM starts is different, both times it was
measured. Its first run ended after 2.9 s (Voice Memos reconfigures once)
with the first read back 1.9 s and 2.8 s after the start, 16 reads, in 65536 frames (22427/s) and 4 reads, and on the speaker 394240 and
752640 bytes dropped: the host held both directions while it opened the
microphone. Later starts have no such pause.
Not done:
-
Whether the Mac showed its microphone prompt for VPhone, with the usage description, was not observed (the recording had sound, so access was granted one way or the other).
-
An iPhone guest (
mictest-iphone, iPhone99,11 / iOS 27.0) gets as far as the route and no further. Both devices are published,cfw installopens the gate there (0x10e468→0x10e6f0), and Voice Memos' Record builds its route:RoutingHandler_Record_N51.cpp:125 Activating route: [ … Category: 'cpar'; Mode: 'spcp' … Device Type: vdfi; { source: 'pmbi'; iPhone Microphone; Built-In Microphone … } ], aggregateVAD [vdfi] AggDevonDigital Mic, DSP chainflexible_video_recording. No I/O starts, because the app gives up first. On iOS 27 Voice Memos records through anAVCaptureSession, and cameracaptured has no capture source to give it:That is the capture stack's device discovery on a VM, not the sound plugin. No sound has been recorded on an iPhone guest: nothing run there records without
AVCaptureSession. The cause and the cameracaptured hook that publishes the microphone anyway are inios27_capture_microphone_source.md.
7. The tunings were the board's, the microphone is the Mac's (2026-10-04)
With the capture source published (ios27_capture_microphone_source.md)
Voice Memos on the iPhone guest recorded, and what it recorded went through
four states. Everything here was measured on mictest-iphone (iPhone99,11,
iOS 27.0) with the Mac's built-in microphone, by decoding the recording on
the host and, from the third state on, comparing it with the capture as the
plugin received it (/var/mobile/vpinput.raw, below).
Silence: the spatial capture route
The recording was a .qta with a four-channel ambisonic track and a stereo
one, both digital silence, while the HAL's own level report on the input
showed sound (RTAID … node=-Input … rms:[-20.5], peaks:[-1.4]). The
session's mode was SpatialCapture and the route's DSP chain
flexible_video_recording, whose graph
(/Library/Audio/Tunings/AID8018/VAD/flexible_video_recording.dspg) begins
[def numMics 4]: four microphones in, wind suppression and a beamformer
between them, first-order ambisonics and a stereo fallback out. The plugin
has two channels, both the Mac's one. Where in that graph they become zero
was not traced. audiomxd also logs, on every route with a microphone,
31091 is 0x7973, the first two bytes of the placeholder syscfg/MiGB that no
VM's syscfg fills. The exception is caught and the route goes on; whether it
matters to the silence is not known, and the routes below work with it still
logged.
Voice Memos asks for that mode because the guest says it can: the audio node
devicetree-cfw-product_audio_node copies from the D47 carries
supports-spatial-audio-capture and supports-audio-mix. The second is
what puts the Audio Mix analysis in the capture graph, whose neural net
faults in cameracaptured (ios27_capture_microphone_source.md §7). Neither
is true of a VM. devicetree-cfw-product_audio_microphone_array removes
both from every guest's tree and preboot-cfw-devicetree_microphone_array
(vphone-cli cfw patch-dt-microphone-array) from a tree already restored.
supports-spatial-facetime and stereo-sound-recording stay: nothing run
here depended on them.
Without them the session is PlayAndRecord / default mode, the route
[vdef] (cpar/imdf) port pmbi, and the recording a mono .m4a with sound
in it.
Loud, and it sounded like wind: the general microphone chain
That route's chain was bottom_mic_general: the per-microphone correction
(built_in_mic_hardware: trim gains, one FIR each), CoexKill, a channel
selector, an equalizer, a loudness normalizer (AULDNM), a multiband
compressor (AUMeisterStueck) and a limiter (AUControlFreak). Against the
capture, band by band (dBFS RMS over the same 13 s):
About 8 dB everywhere, speech against the limiter, and the pauses lifted from
-47 to -37 dBFS. The same microphone on an iPad guest (tuning set AID2019)
goes through a chain named placeholder and comes out as it went in.
The D47 set has a second configuration for each microphone,
<mic>_measurement, for AVAudioSessionModeMeasurement: the correction,
CoexKill, the selector and two AUNBandEQs, no dynamics.
system-virtualaudio-cfw-microphone_graph_chains gives every
<mic>_general entry in graph_configurations.plist its sibling's graph,
austrip and propstrip (vphone-cli cfw patch-virtualaudio-microphone-chains); the entry's channel map and other
properties stay.
Clipped: the board microphone's gain
Through the measurement chain the recording was 18 dB above the capture in
every band (-5.7 dBFS RMS, peaks at +14 dBFS). The strip's second equalizer
carries it: decoding bottom_mic_measurement.austrip, AUNBEQ_2's saved
state is one record of 81 parameters whose parameter 0, AUNBandEQ's global
gain, is 18.0; AUNBEQ_1's is 0, with one active band, a high-pass at 30 Hz
(the preset is named windFilterNBandEQPreset). The gain is the digital
gain of the D47's own microphone. The same patch sets every AUNBandEQ's
global gain in each *_mic*_measurement.austrip to 0
(vphone-cli cfw patch-virtualaudio-microphone-gain); the state's layout is
big-endian records of scope, element, count and count pairs of parameter ID
and Float32, and a strip that does not parse that way is refused.
After it the recording has the capture's levels: speech at -18 to -22 dBFS and pauses at -45 dBFS in both, peaks at -3.1 against -3.4 dBFS. (The two were not the same span, so no band table: the dump stops at 16 MB and the recording ran on.)
The Mac's rumble: a low cut in the plugin
What was left is in the capture itself. In its pauses (dBFS RMS):
A phone's microphone does not pass much of that range; the D47 chain's only
high-pass is the 30 Hz one above. The plugin now runs each captured period
through a fourth-order Butterworth high-pass at 120 Hz before the HAL reads
it (VPVirtIOSoundFilter.c, on the input stream's completion queue, state
reset at each start). On the capture above, offline, that takes the pauses
from -46.8 to -55.9 dBFS and speech down 2.2 dB.
Against a recording made by the Mac's own Voice Memos a few minutes apart (different words, same room and microphone):
What the plugin reports
Each stop of the input stream now also logs the level of what the host sent,
before the low cut: stream 0: captured level -23.6 / -23.6 dBFS RMS, peak -6.7 / -6.7 dBFS (both channels are the Mac's one microphone). A start that
finds /var/mobile/vpinput.dump also writes the capture, as received, to
/var/mobile/vpinput.raw (interleaved Float32, 48 kHz, 2 channels, at most
16 MB) — the file the tables above compare recordings with. files.read
returns 512 KB unless given a limit.
Not done
- An iPad guest was not run again. Its set pairs no general microphone chain
with a measurement one in the logs taken earlier (
placeholder), so the chain patch should find nothing there; the low cut and the device tree removal apply to it and were not measured on it. - Only the default-mode recording route was exercised. Voice processing
(
mic_voice_*chains: calls, Siri) still gets the Mac's level where it expects the board microphone's, 18 dB lower by the measurement strip's account. - 120 Hz is a judgement between the rumble and a low voice's fundamental (this one's was 147 Hz, where the filter's response is -0.8 dB). It was not tried on other voices or other Macs.
8. Open
- Whether the kernel returns reads still out at RELEASE is not known. The plugin does not depend on it in the normal stop; the one-second fallback does, and was not reached.
ssrcon the input device (§4) is refused. Nothing depended on it.- The 1.9 s pause at the first capture (§6) is the host's. If it matters, the lead queued on the speaker side is what would cover it.
- Only the built-in microphone port is published. Speech detection (
vspd) builds its aggregate on it at launch and does not start I/O; if something in the guest starts listening on its own, the host microphone opens with it, which the Mac's microphone indicator shows. Dictation asks for consent first and was not enabled here.