Guest audio through virtio-snd

vphone-vm has always configured a VZVirtioSoundDeviceConfiguration with a host output sink and a host input source (VPhoneExecutable/VPhoneVirtualization/UI/VirtualMachine/VPhoneVirtualMachine.swift), and the guest never played a sound through it. Two separate faults stood in the way. Either one alone is enough to keep the guest silent.

1. Nothing in iOS drives the virtio sound device

The cloudOS kernelcache (kernelcache.research.vphone600) carries the kernel half. com.apple.driver.AppleVirtIO has a personality matching virtio device 0x19 (virtio-snd):

KeyValue
IOClassAppleVirtIOSound
IOProviderClassAppleVirtIOTransport
IOVirtIOPrimaryMatch0x00191af4
IOUserClientClassAppleVirtIOSoundUserClient

The userspace half is a CoreAudio HAL plugin, and only macOS has one: /System/Library/Audio/Plug-Ins/HAL/AppleVirtIOSound.driver (factory AVIOPluginFactory, classes AVIOPlugin : ASDPlugin, AVIODevice : ASDAudioDevice, AVIOStream : ASDStream). The guest's /System/Library/Audio/Plug-Ins/HAL holds BuiltinAudioPlugin, AppleAOPAudioPlugin, Bluetooth, AirPlay and USB plugins and nothing for virtio. The cloudOS 26.4 system image has no HAL plugins at all, and the iPhone 27.0 image has none for virtio either (checked in each IPSW's .dmg.aea.mtree, which is plaintext once the pbze chunks are decompressed).

The user-client protocol

Read out of the macOS plugin's arm64e slice. Every selector forwards one virtio-snd request, so the structures are the virtio specification's:

SelectorCallvirtio-snd request
1IOConnectCallMethod: 1 scalar (stream ID) in, 32-byte struct outPCM_INFO → virtio_snd_pcm_info
3IOConnectCallMethod: 1 scalar in, 24-byte struct inPCM_SET_PARAMS (virtio_snd_pcm_set_params; header left zero, the kernel fills it)
4scalarPCM_PREPARE
5scalarPCM_START
6scalarPCM_STOP
7scalarPCM_RELEASE
8IOConnectCallAsyncMethod: 1 scalar in, the PCM bytes as the input structTX transfer
9IOConnectCallAsyncMethod: 1 scalar in, the period buffer as the output structRX transfer

The stream count is the AVIOSoundStreamCountKey property on the AppleVirtIOSound service. Async references use three slots, with the callout signed with the zero discriminator (paciza) as any C function pointer is on arm64e. The macOS plugin sizes a period as a twelfth of a second rounded up to a page, keeps twelve periods, flushes whole periods every 1/24 s on a dispatch timer, and only lets the I/O thread reuse a region once its transfer has completed. Its device clock is free running from mach_absolute_time, with a timestamp period of rate × 260 / 1000 frames and safety offsets of 100 frames.

VPhoneVirtIOSound.driver

VPhoneGuestComponents/VirtIOSound is the iOS plugin built from that protocol. It is written on AudioServerDriver, the private framework BuiltinAudioPlugin itself uses on iOS (its log shows ASD_Initialize); the classes are declared in VPVirtIOSoundAudioServerDriver.h and bound through AudioServerDriver.tbd. It publishes two HAL devices per AppleVirtIOSound service, a speaker with one output stream per virtio output stream and a microphone with one input stream per virtio input stream, each at the format the device offers (32-bit float, 48 kHz from VZHostAudioOutputStreamSink and VZHostAudioInputStreamSource). A virtio stream is released when CoreAudio stops and the last transfer has come back, so an idle guest does not keep the host audio device running and the host microphone is open only while the guest records.

The speaker's 1/24 s flush timer is armed in startStream and disarmed when stopStream starts the drain; draining needs no flush, since the device's returns end it. Until 2026-10-09 the timer was created at the first start and never stopped, so after the first sound of a boot audiomxd woke 24 times a second with nothing playing: 21,700–22,500 wakeups and 1.8–2.6 s of CPU per 15 idle minutes on an iPhone 27.0 guest, against under 1,000 wakeups and 0.1 s with the timer disarmed (see service_trimming.md).

The input side (the macOS plugin's receive path, why the microphone is a second device named Digital Mic, what VirtualAudio needed, and what is verified so far) is in virtio_sound_microphone.md.

cfw install and cfw update-environment install it at /System/Library/Audio/Plug-Ins/HAL/VPhoneVirtIOSound.driver (system-virtiosound-cfw-hal_plugin).

Optional values in the com.apple.coreaudio domain exist for routing work and are read when audiomxd loads the plugin: VPhoneVirtIOSoundTransportType (four characters, default usb ), VPhoneVirtIOSoundDeviceUID and VPhoneVirtIOSoundInputDeviceUID.

2. VirtualAudio never finished initializing (iPad guests)

Restarting audiomxd in an iPad guest (ipad-pro-13, iPad17,3 / J820, iPadOS 26.6.2) and reading its log shows the HAL loading BuiltinAudioPlugin fine and then VirtualAudio, the routing layer in /Library/Audio/Plug-Ins/HAL/VirtualAudio.plugin, abort:

audio_dsp_manager  Found device acousticID = 8018
audio_dsp_manager  Device tuning directory not found: /Library/Audio/Tunings/AID8018
RoutingSettings_J98.cpp:804   Creating subport config for spatial recording
RoutingSettings_J98.cpp:805   PRECONDITION FAILURE (std::logic_error).
VirtualAudio_PlugIn.mm:2135  VA Init Status: 1
-CMVAEndptMgr- vaemGetVirtualAudioDeviceIDs: No Audio Device Available.  This is a serious error.

and every later session request logs VirtualAudio PlugIn is not initialized yet. With no VirtualAudio device there is no audio route at all, which is also why YouTube and bilibili in Safari would not start playing.

What the routing code expects

The J98 routing settings (sub_47420c in the 26.6.2 VirtualAudio) build one microphone sub-port configuration per recording mode the device advertises, and each builder (0x4988c0 spatial, 0x498ae0 multicam, 0x498d00 webcam) throws when its mode is advertised but the configuration was not built:

ModeAdvertised by
Spatial (stereo) recordingMGGetBoolAnswer("DeviceSupportsStereoAudioRecording") (0x477698), which libMobileGestalt answers from the presence of IODeviceTree:/product/audio/stereo-sound-recording (a copy-property call tested for non-NULL; 0x186ff4ee8 → 0x186fcfb20 in the host's copy)
Webcam recordingAVGestaltGetBoolAnswer(AVGQ3J3FEVOOCNOKKTK3XQPUQ47DYY) (0x4776bc), byte 0x11 of AVFCapture's per-board capability table, set for J817-J818-J820-J821

For these boards (product IDs 195 and 196) the routing code skips its own input-processing path ("Input processing disabled", RoutingSettings_J98.cpp:400) and builds the configurations from the tuning directory named by the device tree's acoustic ID (0x491738 onwards: AU, VAD, …_mic_peripheral_sender_all_mics). A real J820 has acoustic-id = 2029, and the iPad image ships /Library/Audio/Tunings/AID2029 (and AID2028).

For these boards (product IDs 195 and 196) the routing code skips its own input-processing path ("Input processing disabled", RoutingSettings_J98.cpp:400) and builds the configurations from the tuning directory named by the device tree's acoustic ID (0x491738 onwards: AU, VAD, …_mic_peripheral_sender_all_mics). A real J820 has acoustic-id = 2029, and the iPad image ships /Library/Audio/Tunings/AID2029 (and AID2028).

Why the guest had the wrong acoustic ID

devicetree-cfw-product_audio_node adds the D47 iPhone's /product/audio, whose acoustic-id is 8018. That is right for an iPhone guest — the iPhone image ships AID8018 and D47 tunings — but the iPad identity patches copy /product properties from the iPad's own tree and never touched this child node, so an iPad guest advertised J820's recording modes over tunings it does not have.

The first attempt here removed stereo-sound-recording. That cleared the first failure, and the webcam builder failed next (RoutingSettings_J98.cpp:855/856). The webcam answer comes from the board name itself, so the flags were never the fault; the tuning directory was. That attempt is not in the tree.

The fix

  • devicetree-cfw-ipad_audio: for an iPad guest's installed tree, fw patch replaces /product/audio with the board tree's node (DeviceTreePatcher.presentBoardAudio): every property as the board has it, placeholders included, except the board's AAPL,phandle.

  • fw patch now reads the board tree through the FirmwareOriginals stash, so a copy of DeviceTree.<board>.im4p stays in the VM folder after the restore tree is deleted.

  • preboot-cfw-devicetree_board_audio: cfw install and cfw update-environment apply the same replacement to the restored Preboot devicetree.img4 (vphone-cli cfw patch-dt-board-audio <devicetree.img4> <board.im4p>), taking the board tree from FirmwareOriginals. An iPhone guest has no board tree there and is left alone.

  • An iPad VM patched before fw patch kept the board tree has no copy in FirmwareOriginals, and the update used to skip the repair without a word (ipad-mini-01: acousticID stayed 8018, VA Init Status: 1). Both commands now recover it first (VPhoneBoardDeviceTree in VPhoneArchiveKit):

    • the firmware is read from the FirmwareOriginals/<restore tree> folder name (iPhoneOS_iPad16,1_26.6.2_23G90_Restore), or from restore-info.json when no such folder is there; the board is the guest device's deviceClass (config.plist guestProductType, iPad16,1 → j410ap);
    • every *.ipsw in the shared cache (~/.vphone/ipsws, or $VPHONE_ROOT/ipsws) and in the ipsws folder beside the VM's library is matched by its BuildManifest, never its file name: same ProductVersion and ProductBuildVersion, the device among SupportedProductTypes, and a build identity for the board whose DeviceTree path is DeviceTree.<board>.im4p. That one member is read out without unpacking the IPSW;
    • it is written to FirmwareOriginals/<restore tree>/Firmware/all_flash/, mode 0777, so later runs find it there like a tree fw patch kept. The search and the write run with the invoking user's credentials, through the pinned VM folder; the file is the user's, not root's.

    The output says which it did:

    [*] FirmwareOriginals has no DeviceTree.j410ap.im4p; looking for the iPad16,1 26.6.2 (23G90) IPSW in /Users/<user>/.vphone/ipsws
    [+] Board device tree recovered: Firmware/all_flash/DeviceTree.j410ap.im4p from /Users/<user>/.vphone/ipsws/<file>.ipsw, kept as FirmwareOriginals/iPhoneOS_iPad16,1_26.6.2_23G90_Restore/Firmware/all_flash/DeviceTree.j410ap.im4p
    [*] Board device tree: FirmwareOriginals/iPhoneOS_iPad16,1_26.6.2_23G90_Restore/Firmware/all_flash/DeviceTree.j410ap.im4p

    When no IPSW matches, the run goes on without the repair and says so in one [!] Board audio repair skipped, so this iPad16,1 guest will have no sound: … line naming the build it looked for. The manual fix is still the fallback: put that IPSW in ~/.vphone/ipsws, or copy its Firmware/all_flash/DeviceTree.<board>.im4p into FirmwareOriginals/<restore tree>/Firmware/all_flash/ yourself, and run the environment update again.

3. Inside the precondition failure (follow-up session, 2026-10-02)

The acoustic-ID fix (§2) holds — a fresh audiomxd restart on ipad-pro-13 logs Found device acousticID = 2029 and loads the AID2029 configuration — but RoutingSettings_J98.cpp:805 still throws. ipad-mini-01 (J410, also iOS 26.6.2, its own AID8018 present) fails at the same line, and pcc-research-01 (iPhone17,3, iOS 27) fails at the analogous RoutingSettings_N71.cpp:1167. In all three:

PlatformUtilities_Aspen.mm:154   ProductID to int is: 195|196
RoutingSettings_{N71:1073|J98:400}   Input processing disabled
RoutingSettings_{N71:1166|J98:804}   Creating subport config for spatial recording
RoutingSettings_{N71:1167|J98:805}   PRECONDITION FAILURE (std::logic_error).
VirtualAudio_PlugIn.mm  PlugIn initialized ? 1 / VA Init Status: 1

PlatformUtilities ProductID 195 selects the N71 (iPhone) routing settings, 196 the J98 (iPad) ones — every current iPhone and iPad guest walks the "input processing disabled" path, and on that path the spatial sub-port configuration is never built while the mode is advertised, so the builder throws and VirtualAudio never initializes. Since real hardware takes the same ProductID branch, something the VM lacks — not the branch itself — keeps the configuration from being built there.

Disassembled mechanics (iPadOS 26.6.2 plugin, offsets in the file)

/Library/Audio/Plug-Ins/HAL/VirtualAudio.plugin/VirtualAudio is a standalone arm64e Mach-O with its local symbol table intact — pulled from the running guest with files.read (7,356,608 B; saved with the iOS 27 copy in ~/.vphone/va-analysis/). otool -tV on it gives:

  • The three builders (0x4988c0 spatial, 0x498ae0 multicam, 0x498d00 webcam) all follow one shape: if the mode is not advertised (tbz w0,#0) return NULL quietly; if it is advertised but *(this->subportConfig) is NULL (ldr x8,[x19]; cbz x8), log at J98.cpp:805 and throw std::logic_error("Precondition failure.").
  • The subport-config slots are constructor locals at sp+0x1a70/0x1a78/0x1a80 (webcam/multicam/spatial), zeroed at 0x47452c-0x474548.
  • Advertisement inputs: x24 = MGGetBoolAnswer(DeviceSupportsStereoAudioRecording) captured at 0x47769c, webcam answer stored [sp+0xf8] at 0x4776c0; both feed the guard-dispatcher at 0x477c80… (one-shot flags 0x6fc468…0x6fc4b8) which calls the three builders at 0x4938c8, 0x493914, 0x493960.
  • Config-building blocks for the three modes do exist in the constructor (0x48ee24 spatial → stores the slot at 0x48ef94, 0x48f04c multicam, web-cam variant near 0x48f378, second multicam/webcam variants at 0x4916b0/0x491a78). They look the configuration names up in the graph_configurations.plist database through 0x4d8be0 (misses log DSPGraphConfig_Utilities.cpp:437 Graph collection missing expected key and zero the output — that message never appears in any guest log).
  • What was not found statically: the branch chain on the "input processing disabled" path that is supposed to fill the three slots before the guard-dispatcher runs. The obvious build blocks are reached from 0x4776f8 b.ne 0x48e8c4 — the branch for devices whose webcam is not advertised and whose ProductID is not 195/196. Manual tracing of the 180 KB constructor did not close the gap; instrumenting a patched copy of the plugin (below) is the fast way to see which path runs.

What VirtualAudio does with our HAL device

Nothing, so far. Its HALDeviceManager processed only Null_Device (DeviceFactory.cpp:144 Unhandled UID "Null_Device"); device 37 (VPhoneVirtIOSound:0) is activated by the HAL but never "[Added]" to VirtualAudio. Setting VPhoneVirtIOSoundTransportType to vrtc (kAudioDeviceTransportTypeVirtual) changed nothing. Physical devices reach the factory through a creator registry (unordered_map at 0x6da2f8, lookup 0x1cf0f0, dispatch 0xe0c8c-0xe0dcc) that the routing settings populate (the N71 mic builder at 0x2be594 registers per-mic creators, e.g. bottom_mic2), so until init completes there is no built-in device for a speaker route either — fixing playback needs both the throw fixed and the virtio device registered in that registry.

Ruled out

  • Tuning-directory naming. The AID2029 directory is used where it should be; graph_configurations.plist (CommonData: tuningPath = /Library/Audio/Tunings/AID2029/VAD, tuningFilePrefix = "") maps every mic mode to graphs that exist (stereo_recording → stereo_recording_no_tap, spatial_video_recording, multicam → multicam). The /Library/Audio/Tunings/J820/VAD/v201_speaker_*.dspg lookups that fail (RoutingSettings_Aspen.cpp:3287/1870/1889, loader 0x4283d4, called from 0x4310dc with chainType 'clhs') are non-fatal, and no image ships a J820 directory, so real hardware fails them too — they are not the blocker.
  • Stale MobileGestalt cache. The cache directory is empty (the previous session's deletion stuck); answers are computed live. Writing com.apple.MobileGestalt.plist (the file libMobileGestalt actually opens — it logs Could not open …/com.apple.MobileGestalt.plist when absent) with CacheExtra {DeviceSupportsStereoAudioRecording: 0, AVGQ3J3FEVOOCNOKKTK3XQPUQ47DYY: 0} did not change the outcome: the device-tree-backed answer bypasses CacheExtra (or the full CacheData/CacheUUID/CacheVersion schema is required — those key strings are in libMobileGestalt).
  • Our device's transport type. usb → vrtc made no difference (above).

4. Root cause found and fixed: ProductIDOverride (same session, evening)

The missing input was the ProductID. PlatformUtilities_Aspen.mm derives it in this order (0x2fa638 in the 26.6.2 plugin):

  1. A defaults key, read by RunTimeDefaults.mm: ProductIDOverride in the com.apple.audio.virtualaudio domain (its own debug knob — Defaults key ProductIDOverride was defined to %u, 0x2fa638).
  2. A table lookup over a MobileGestalt class answer — table at 0x50cfd0 = [195, 0, 196, 199, 0, 198]. These five values are simulator/research classes: every one of them makes the routing constructor take its "input processing disabled" path (§3: pid ∈ {195,196} or the DisableInputProcessing default — another RunTimeDefaults key, 0x2735d8 — forces it), which never builds the spatial/multicam/webcam sub-port configurations, so the builders throw.
  3. Real hardware never reaches that table: MGGetProductType (0x9135c) maps the actual model to a real ProductID, and for acoustic-id devices the id is the acoustic id (Product with AcousticID '%d' is handled, chip range 2025–2035 → the J-boards).

The vphone600 research platform lands on 196 ("iPad simulator"), which is in the skip-set — hence every iPad guest threw at RoutingSettings_J98.cpp:805 and every iPhone guest at RoutingSettings_N71.cpp:1167, while real devices never do.

The fix

Superseded (2026-10-03). Do not store this value. The plugin supplies ProductIDOverride = 8010 in-process on an iPad or iPhone guest and vphoned stores the same (§6, "Nothing left to set by hand"). 198 has no ringtone-preview category, so with it tones stay silent; a guest that still carries a stored 198 needs nothing done, the stored value gives way to both. What follows is how the override was found.

vphone-launchpad-cli guest rpc <machine> settings.set \
  '{"domain":"com.apple.audio.virtualaudio","key":"ProductIDOverride","value":198,"type":"int"}'

198 (0xc6) is one of Apple's own class values (the table's sixth entry) and the only one that satisfies both gates found:

  • not 195/196 → the J98 constructor builds the mode configurations (Creating subport config for … at :804/:816/:855 all run, no throw);
  • accepted by ActuatorSettingsFactory_Aspen.cpp:172's ProductID switch (0x36d4f0) — overriding to the acoustic id 2029 fixes routing but throws Invalid Product Type there, killing init a few lines later.

Verified live on both iPad guests (audiomxd restart): PlugIn initialized ? 2, VA Init Status: 0, no exceptions, and the route comes up — VirtualAudio_Device: type vdef; id 65 … agg dev "VAD [vdef] AggDev 1", the ap:ha:nd:of:fd:ev-screen/AP ports publish, and a ringtone preview in Settings ▸ 声效与触感反馈 ▸ 电话铃声 produces real AQMEIO_HAL output activity. One benign exception remains (noct/crng category lookup in the routing database — 198's route set is a simulator's).

5. The route to the virtio device exists — it is called PuffinOutput

With init fixed (§4), VirtualAudio_PlugIn.mm processes every HAL device and its DeviceFactory creates a PhysicalDevice only for UIDs it knows. Two tables in the 26.6.2 binary decide:

  • a device filter allow-list at 0x6b4668 — Null_Device, Actuator, Halogen, Hawking, Flicker, Penrose (the "AllowOnlyNull" filter mode in HALDeviceManager);
  • a per-UID handler table built in the 0xe002c device-state handler — PuffinOutput, Actuator, AOP Audio-1, HP16Mic, Digital Mic, DigitalMic, Mic, Hawking, Flicker, Penrose, Halogen, …

VPhoneVirtIOSoundDeviceUID (the plugin's existing knob) renames our device, so each family was tried live by restarting audiomxd:

UIDResult
VPhoneVirtIOSound:0 (default)Unhandled UID — claimed by nothing
HalogenPhysicalDevice created, publishes plqi/plqo (LDCM) ports
Hawkingpublishes phki input port only (mic family)
AOP Audio-1ASD binds dependencies (IOPAudioLPMicDevice, IOPAudioIOBufferDevice), rtaid Detector node, no output port
PuffinOutputpspk speaker port published, routable, and the VAD aggregate is built with master = PuffinOutput; the vdef's stream then binds actual strm: id 38 — our virtio stream — with associated ports: { pspk; PuffinOutput }

"Puffin" is the Apple-silicon host-audio codec family, and the research platform is exactly a virtual Apple-silicon Mac — which is why its built-in output device name slots straight into the iOS routing tables.

The remaining crash

Playing a ringtone with the pspk route deadlocks audiomxd within seconds: CoreMedia's MXInitialize → FigVAEndpointManagerCreate → vaemCurrentRouteHasVolumeControlListenerGuts → CMSMUtility_GetCurrentOutputPortAtIndex blocks on a mutex held across the ASDTDeviceManager background thread (AudioServerDriver's device manager, the same layer whose ASDTDeviceManager: Started background thread. line appears in every boot), then abort()s — and the poisoned state makes every subsequent audiomxd launch die the same way (17 crash reports, ~/.vphone/va-analysis/crash2.json). Deleting the UID preference alone does not stop the loop; a guest reboot clears it (verified: audiomxd back, VA Init Status: 0, no new crashes).

Likely cause, to confirm next session: a pspk speaker promises CoreMedia a volume-capable output port (VolumeControl.cpp: Device PuffinOutput does not support hardware volume range property is logged and tolerated, but the listener then blocks). Our ASD device must answer the volume/port property queries a Puffin output answers — implementable in the plugin with the same AudioServerDriver API. One instrumented build of VPhoneVirtIOSound.driver logging entry/exit of its property callbacks while the pspk route is up will pinpoint the call that never returns.

The direction suggested earlier in §4 — publishing through the AudioServerDriver layer like BuiltinAudioPlugin — is already what VPhoneVirtIOSound.driver does; the masquerade-by-UID experiments above are the cheap form of it, and implementing the missing volume/port property callbacks is the completion of that path. The iOS 27 binary (VirtualAudio-ios27) is saved for the N71-side port of the same fix.

6. Playing past the first stop, at the right rate, without gaps (2026-10-03, night)

Three faults in the plugin itself survived everything above. Each was found on a new iPad16,1 / 26.6.2 guest (audiotest-ipad) with counters the plugin now writes to /var/mobile/vpquery.log each time the stream stops:

stream 1: 9.08 s, 108 writes, 0 starved, 0 bytes dropped, in 400384 frames (44079/s, hal 44100), out 435792 frames (47977/s); start 2.54, lead 1.46, in flight 2.00-3.00 periods; host 48000.2/s (+5 ppm), returns <= 86 ms apart

writes are periods handed to the kernel, starved the ones that found the device with nothing left in flight (a gap on the host), in what the HAL handed the mix block and out what went to the ring. After the semicolon: start is what the device still held, in periods, when this run began (0 for a fresh start), lead the silence queued ahead of its first write, in flight the range seen at each submission, host the rate the device returned bytes at once the run was five seconds old, with its distance from the wire rate, and the longest gap between two returns. A stream that keeps running writes a shorter line every minute. The counters start again at every start.

Every start after the first failed

Symptom: the first playback after audiomxd launches works; after any stop — the screen turning off, the next ringtone, pausing in Safari — every start ends five seconds later in

HALS_IOContext_Legacy_Impl::IsTimeRunning_Helper: Device PuffinOutput is not running.
HALS_IOContext_Legacy_Impl::IOWorkLoop: could not establish a timeline after waiting 5000000 microseconds

and the plugin's getZeroTimestampBlock is never called during those five seconds. AudioServerDriver does that itself (guest framework, image base 0x249eae000; offsets in the image):

  • -[ASDAudioDevice performStartIO] (0x5b2c) copies each I/O block from its property — whose getter (0x5fd4 for getZeroTimestampBlock) returns the very ivar being assigned — into that ivar and its unretained twin, starts the streams, sets _running and posts 'goin'.
  • -[ASDAudioDevice performStopIO] (0x7ae8) stops the streams, clears _running, then releases every one of those blocks and stores nil in both ivars (0x7bc4…0x7d40), and posts 'goin'.
  • -[ASDStream stopStream] (0x7fb8) does the same to the stream's blocks, writeMixBlock among them.

So a block set once at init survives exactly one start. The plugin now sets them before every start: installIOBlocks at the top of the device's performStartIO, installMixBlock before [super startStream].

This is also what the "frozen seed" reading in virtualaudio_speaker_route_throws.md had actually seen. The boot chime was the first start and played; the ringtone after it was the second and timed out. Moving the seed with every period did not fix that — a later run happened to make its first start the tested one.

The timestamp period must not change with the rate

Symptom, once starts worked: choppy playback, the HAL handing over about 45,700–46,000 frames a second on a 44100 device, and four times a second

HALS_IORawClock::Update: Re-anchoring IO timeline. Sample time is not consecutive,
HostTime is increasing, Ring buffer size: 12480.

12480 is the period the device published at init (48000 × 0.26 s). The rate change to 44100 (set nsrt, answered by the device itself, see item 6 of the validation list below) had also moved timestampPeriod to 11466, but nothing makes the HAL read that property again, so it kept checking that every zero timestamp advanced by 12480 frames, found 11466, and re-anchored. The clock now keeps the period's frame count for the life of the device and changes only how long a period lasts (VPClockSetRate): 12480 frames take 0.26 s at 48000 and 0.283 s at 44100. Measured after: 44,086–44,094 frames/s in, zero re-anchors.

The seed follows the same rule the HAL states in that log family: it names the timeline and moves only at an anchor (performStartIO, a rate change). Moved with every period it produced Re-anchoring IO timeline. Zero timestamp seed changed every 0.26 s.

Nothing was queued ahead of the host

The virtio device plays what it is handed as it arrives, and the mix arrives in real time: a period (4096 frames, 85 ms) is submitted when it fills, by a timer that runs every 42 ms. With nothing queued ahead, any period later than the one before it finished is a gap. Measured with the clock already fixed: 89 of 513 writes starved in one 44 s tone, 138 of 203 in the next.

The stream now queues VPhoneVirtIOSoundLeadPeriods periods of silence (default 2, 171 ms) ahead of each run, and the device reports the lead plus one period as outputLatency so video is presented against when the sound is heard (at least that much; it now follows what the host keeps, see "Picture against sound" below). Measured: 0 of 499 and 0 of 237 writes starved.

Restarts lost the lead. A tone switch restarts the stream while the device is still draining the last one. Queued at startStream, the lead was rounded down to whole periods in flight, counted a nearly played buffer as whole, and the host went on playing until the HAL's first cycle: such restarts starved 25–28 of about 60 writes, and one slow fresh start 18 of 144. The I/O thread now queues the lead itself, before its first write of a run (VPMixQueueLead): it tops what is queued up to the lead, counting what the draining run still holds except the oldest period in flight. startStream only leaves the request, so the ring's written keeps the single writer its contract names. Measured after, on audiotest-ipad: 21 restarts while draining and 7 fresh starts, none starved; on the final build the same on an iPhone guest and on an upgraded guest. A restart queues at most one period (85 ms) more than a fresh start, and that does not build up.

Drift needs nothing. The guest's clock and the host's audio clock are different clocks, so the cushion could in principle erode or grow over a long stream. Measured over a 13 minute Safari stream: the host returned 48000.0 to 48000.1 frames a second, 1–2 ppm, about 0.08 periods an hour. (Short runs print tens to a few hundred ppm; that is the five-second window, not the clocks.) Not measured: other host output devices — USB, Bluetooth — whose clocks are their own. Two things in those runs that drift does not explain, seen once each and not reproduced: a 13 minute run on an earlier build that sat one period low and starved about 5%, and a single 205 ms stall on the host. The first return after a start comes 2–80 ms after the write, so the host keeps a small buffer of its own, and a starved write is not always an audible gap.

Picture against sound. The lead is reported to the HAL as latency so that a player holds its picture back by as much. Measured 2026-10-04 on avtest-ipad (iPad16,1 / 26.6.2): a clip with a white flash and a 1 kHz beep every two seconds, played in the guest's Safari, with the VM's window and the Mac's mixed audio captured on one clock (ScreenCaptureKit, the window at 120 frames a second). The time from each flash to its beep:

PlayerBeep after flashPairs
Safari in the guest, first run-9.3 ms (sd 6.8)22
Safari in the guest, second run-8.0 ms (sd 7.9)20
AVPlayer in a window on the Mac itself-14.5 ms (sd 5.6)12

The guest is within a frame of a player native to the Mac, so the reported latency is what the queue adds. The capture is taken at the Mac's compositor and mixer; the display and the output device after them are the same for both players and are not in the numbers.

Bluetooth output. The same measurement with the Mac playing through AirPods Pro (48 kHz output), 2026-10-04:

PlayerBeep after flashNotes
AVPlayer in a window on the Mac itself-140 msAVFoundation sends the sound early by the output's latency
Safari in the guest+365 to +372 ms (sd 6)in flight 3-5 periods, up to 8; 0 starved; host +18 ppm

At the mixer the guest's sound was 370 ms late where the native player's was 140 ms early, so at the ear about half a second late. Two things the reported latency left out: Virtualization's host sink keeps more in flight for a Bluetooth output (3-5 periods against 1-2 on the built-in speakers, each 85 ms more than the lead assumed), and the output's own latency after the mixer, which a native player compensates and the guest never heard of.

The speaker's output latency now follows both (VPhoneGuestComponents/VirtIOSound/VPVirtIOSoundLatency.h):

  • Queued ahead of the host. The output stream takes the most in flight any write found per five-second window, once a run has been going five seconds, plus the period being filled; never less than the lead plus one period. It moves up after two windows in a row that ask for more (to the lesser of them, so one burst does not count) and down after twelve, a minute, that ask for less.
  • The Mac's output. vphone-vm reads the default output device from CoreAudio, the device Virtualization's VZHostAudioOutputStreamSink plays to ("the same device that AudioQueueNewOutput uses", its header): device latency + its output stream's latency, at its nominal rate (VPhoneHostAudioLatencySync), the sum the headers give for the time after the HAL's output time. The native player's 14.5 ms on the built-in speakers is that part (15.6 ms there). The safety offset and IO buffer, mixed ahead of the output time, are left out: at the mixer the guest's sound was already 8 ms ahead on the built-in speakers, so what lies before the mixer is covered by the in-flight part. Built-in speakers of a MacBook Pro: 60 + 690 frames, 15.6 ms. It sends the figure after every connect and whenever the default output device or its latency, safety offset, buffer size, rate or streams change, with audio.host_latency (Research/vphoned_http_api.md); vphoned stores it as VPhoneVirtIOSoundHostLatency in com.apple.coreaudio for user mobile and posts com.vphone.audio.host-latency. The plugin reads the key at load, on that notification and at each start of the speaker's I/O.

The device's outputLatency is the sum, in frames of its nominal rate. A change goes through requestConfigurationChange:: AudioServerPlugIn.h requires RequestDeviceConfigurationChange for a change of presentation latency, and the host performs it with I/O stopped and restarts I/O after, so a player hears a short break when it moves. ASD's setOutputLatency: (disassembled, guest AudioServerDriver) stores the value and calls changedProperty:forObject: with 'ltnc'/'outp', which the plugin turns into the host's PropertiesChanged. Not yet confirmed on a guest: that the HAL in audiomxd performs the change, and that a player already playing picks the new latency up rather than only the next one.

What to expect, from the measurements above: on the built-in speakers the queued part stays at 3 periods and the host adds 15.6 ms, so the guest's sound moves from 8 ms to about 24 ms ahead of its picture at the mixer (native: 14.5 ms). On AirPods the queued part should settle at 6 periods (512 ms) and the host add about 150 ms, about 400 ms more than before, which would leave the guest roughly 100 ms late at the ear unless Virtualization's sink buffers less than the half second measured. The plugin logs each step to vpquery.log: speaker latency … frames at … Hz at load, stream 0: queued ahead of the host 3 -> N periods, host latency notification: … ms stored, speaker latency A -> B frames … requesting a configuration change, then speaker latency B frames applied once the host performed it.

Nothing left to set by hand

Both settings §4 and §5 asked for are now what the plugin does unasked, on an iPad or an iPhone guest (hw.machine starting "iPad" or "iPhone"; any other guest gets neither, so it has the whole speaker route or none of it):

  • the device UID is PuffinOutput unless VPhoneVirtIOSoundDeviceUID names another. Elsewhere the default stays VPhoneVirtIOSound:0, which VirtualAudio leaves unclaimed;
  • ProductIDOverride in the com.apple.audio.virtualaudio domain is 8010, from two places. The plugin sets it from halInitializeWithPluginHost: on every launch that does not find 8010 there. The plugin loads before VirtualAudio reads its defaults, and both are in audiomxd, so the in-process value is the one it reads; audiomxd's sandbox keeps the write off disk, which is why it is repeated every launch. Each launch logs one line after === plugin load === saying what was done and whether VirtualAudio was already mapped: ProductIDOverride on 'iPhone99,11': unset, set to 8010 for this launch; VirtualAudio not loaded yet. Nothing guarantees that order, so vphoned stores the same value when it starts (VPhoneDaemon/Daemon/GuestVirtualAudioProduct.swift, only on a guest that has the plugin): a stored value needs no order, and every later launch of audiomxd finds it. 8010 rather than §4's 198 because 198's category map has no ringtone-preview entry (virtualaudio_speaker_route_throws.md, layer 1).
  • A value already stored gives way to both, so a guest that kept the 198 of §4 needs nothing deleted. VPhoneVirtIOSoundProductID in the plugin's settings (com.apple.coreaudio) names another ID for the plugin and vphoned alike; 0 there leaves VirtualAudio's key alone.

Measured on a new iPad16,1 / 26.6.2 guest (avtest-ipad, 2026-10-04):

Statevpquery.logaudiomxdsettings.get
first boot, nothing setstored 8010, nothing to do; VirtualAudio not loaded yet (vphoned was ahead of audiomxd)—8010
198 stored by settings.set, audiomxd restartedstored 198, set to 8010 for this launch; VirtualAudio not loaded yetDefaults key ProductIDOverride was defined to 8010, VA Init Status: 0198
the same guest restartedstored 8010, nothing to do; VirtualAudio not loaded yet—8010

What is left of the order: the first launch of audiomxd on a guest where vphoned has not stored the value yet, which is the plugin's in-process set alone, as before.

Verified on an iPad16,1 / 26.6.2 guest with all the keys deleted and rebooted: VirtualAudio initializes, the boot chime and a ringtone preview start I/O on PuffinOutput (VAD [vdef] AggDev N), no not initialized yet.

iPhone guests (2026-10-04)

An iPhone guest had been left out on the assumption that no ProductID initializes there. Measured on a new iPhone99,11 / iOS 27.0 guest (audiotest-iphone, cloudOS 26.4):

StateVirtualAudioSafari (mp3, 91 s)Ringtone preview
nothing setProductID to int is: 195, RoutingSettings_N71.cpp:1167 PRECONDITION FAILURE, VA Init Status: 1silent (not initialized yet)silent
ProductIDOverride 8018, 8010 or 198VA Init Status: 0, pspk port on PuffinOutputplays: 1067 writes, 0 starved, 44099 frames/s in (8018)—
8018, haptics node removedas aboveplayssilent: The routing mutex was left held after handling a route change, mediaplaybackd No audio output is available
8010, haptics node removedas aboveplaysplays: 14.03 s, 165 writes, 0 starved

audiomxd did not crash in any of them, including with PuffinOutput and no override. 8018 is the acoustic ID of the D47 audio node the guest's tree carries, and it fails a tone the way 198 does on an iPad, so both families use 8010. The four VirtualAudio binary patches found their sites in the iOS 27 binary (5 walker sites, the others one each) and the plugin's load order held there too (VirtualAudio not loaded yet).

A long continuous stream for measurements, without a person: apps.open_url with https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3; Safari plays it on load, and ui.tap_element {"text":"Pause"} stops it.

The clock under a rate change

A rate change is answered by the device's own setProperty and can arrive while I/O runs. The clock (VPVirtIOSoundClock.c) stores no period count — it is the whole periods since the anchor at the reader's now — and publishes each anchor as one snapshot versioned by the seed, so the I/O thread never combines an old count with a new anchor. make -C VPhoneGuestComponents test-virtiosound runs a writer re-anchoring against a reader.

7. Tones stayed silent: the guest claimed a Taptic Engine (2026-10-03, night)

With routing and the plugin both working, Safari played and a ringtone preview still did not. audiomxd shows the tone's route built and its AudioQueue created (AudioQueueObject: New output; format 2 ch, 44100 Hz, aac) and deleted six seconds later without ever starting. The reason is in mediaplaybackd, which plays tones for the client:

itemfig_postReadyForInspectionPayload…: Track ID 1 soun … Track ID 2 hapt … Track ID 3 hapt
FigHapticEngineCreate: called, AudioSession:… Locality:…
activating connection: mach=true … name=com.apple.audio.hapticd
XPC timeout
AVHapticClient.mm:1160  Initial XPC call to server timed out. Invalidating connection to prevent hang
   (six times, one second apart)
CHHapticEngine.mm:649   createHapticPlayerWithOptions: ERROR: Server failure: … Code=4099
<<< FigHapticEngine >>> signalled err=4099
playerfig_prepareWorkingItem2: itemfig_rebuildRenderPipelinesAndBoss() failed with err=4099
playerfig_prepareWorkingItem: current item … failed to prepare (4099), advancing to next item

A system tone carries haptic tracks beside its audio. In MediaToolbox (26.6.2 cache), itemfig_rebuildRenderPipelinesAndBossGuts calls FigHapticEngineCreate when the item's PlayHapticTracks is set, and any error from it leaves through the function's failure exit (cbnz w27 at 0x197832dd4): the item fails as a whole, audio included. com.apple.audio.hapticd is a Mach service of audiomxd (com.apple.audiomxd.plist), and on a VM nothing behind it answers.

PlayHapticTracks comes from the client. ToneLibrary sets [playerItem setPlayHapticTracks:YES] when -hasSynchronizedVibrationsCapability is true, and logs how it decided: "MobileGestalt returned %{BOOL}u for the deviceSupportsHaptics capability, and %{BOOL}u for the deviceSupportsClosedLoopHaptics capability". Those answers come from the device tree:

Tree/product children/product/haptics
vphone600 (guest)vphone600-gestalt-variants, maps, haptics, usb-device, utilclosed-loop = 1, supports-3rd-party-haptics = 1
DeviceTree.j410ap (iPad16,1)camera, facetime, maps, audionone
DeviceTree.j820ap (iPad17,3)camera, facetime, maps, audionone

So the guest says it has a Taptic Engine and the iPad it presents does not. The first fix made the node follow the board, which removed it on an iPad.

An iPhone guest fails the same way. On an iPhone99,11 guest running iOS 27.0, a tone preview logged the same six hapticd timeouts, the same FigHapticEngineCreate 4099, and the item failed and was dropped. That guest boots the shared vphone600 tree, has no board tree of its own to follow, and a real iPhone's tree does have /product/haptics; following the board can never remove it there. What decides it is not the device the guest presents but the hardware behind it: no VM has a haptic actuator, and nothing serves com.apple.audio.hapticd. So the removal is now unconditional. devicetree-cfw-product_haptics_node takes the node out of every tree fw patch writes — an iPhone guest's one tree, an iPad guest's installed tree, and its RestoreDeviceTree, which is patched exactly as an iPhone guest's tree is and from which restore reads nothing of the node — and preboot-cfw-devicetree_haptics (vphone-cli cfw patch-dt-haptics <dt>) does the same to a guest that is already installed, with no board tree needed. cfw install and cfw update-environment run it for every guest.

The answers are cached. libMobileGestalt writes /private/var/containers/Shared/SystemGroup/systemgroup.com.apple.mobilegestaltcache/Library/Caches/com.apple.MobileGestalt.plist at first boot. On the test guest the node was gone from IODeviceTree after cfw update-environment, and Settings still showed the Haptics row and mediaplaybackd still timed out on hapticd, until that file was removed and the guest rebooted:

vphone-launchpad-cli guest rpc <machine> files.remove \
  '{"path":"/private/var/containers/Shared/SystemGroup/systemgroup.com.apple.mobilegestaltcache/Library/Caches/com.apple.MobileGestalt.plist"}'
vphone-launchpad-cli guest rpc <machine> system.reboot '{"force":true}'

A guest created with the patch never caches the wrong answers: its tree is written before its first boot. An existing one needs the file removed, and the host cannot do it. The guest's container holds s1 System, s2 Data, s3 xART, s4 Hardware, s5 Preboot and s7 User; Data and User are FileVault volumes whose keys are in the guest's SEP, and diskutil mount on the host answers "This is an encrypted and locked APFS Volume". The volume the installer mounts beside System is xART, where the gigalocker lives. An earlier installer change that removed the cache from that volume could never find it: after a live cfw update-environment the cache still had its first-boot modification time.

So vphoned does what the two commands above do, less the reboot, at startup (VPhoneDaemon/Daemon/GuestMobileGestaltCache.swift). The rule is the age of the cache against the tree the guest booted, /private/preboot/<hash>/usr/standalone/firmware/devicetree.img4, the file the installer patches. The host changes that tree only with the VM stopped, and the installer writes a guest file only when its verb changed it, so a cache older than the tree was worked out from an older tree and is removed:

vphoned: MobileGestalt cache predates the device tree, removed it; a restart makes the new answers take effect

A cache written after the tree was written by a boot of that tree, and is left alone without a line. That covers every Preboot tree repair (the haptics removal, the board audio node) and needs no state, so it works the first time a new vphoned runs on an old guest. Processes that read the cache keep their answers until they exit, so the removal takes full effect at the next boot; vphoned does not restart the guest itself. Until then /v1/health reports mobilegestalt_restart_pending: true.

For an existing guest that is cfw update-environment, one boot (vphoned drops the cache), and one restart. A new guest, and every later boot of an updated one, does nothing: its cache is newer than its tree.

Verified (audiotest-ipad): after the cache was rebuilt the Ringtone page has no Haptics row, mediaplaybackd makes no hapticd connection, and a tone preview plays on the Mac — heard, and in vpquery.log a 20 s stream with 237 writes and none starved.

Verified on an iPhone guest (audiotest-iphone, iPhone99,11 / iOS 27.0): with the node removed and the cache rebuilt the Ringtone page has no Haptics row, mediaplaybackd makes no hapticd connection, and with ProductID 8010 a tone preview plays (§6, "iPhone guests").

Verified as an upgrade (mgtest-ipad, iPad16,1 / 26.6.2, created with a bundle from before the removal, so it booted with the node and cached the old answers, Haptics row showing): after cfw update-environment from this build the node is gone from IODeviceTree; on the first boot vphoned leaves its marker for that boot session in /var/root/Library/Caches/com.vphone.vphoned.mobilegestalt-dropped and the cache file is new; after one restart the Haptics row is gone, the cache keeps its modification time (nothing removed again), and a tone plays — 16.12 s, 190 writes, none starved. /v1/health was not read in that run; the marker file is what its field reports.

8. The volume moved a number and nothing else (2026-10-04)

On an iPhone guest (iOS 27.0) the volume keys moved the guest's volume (active_volume 0.5 → 0.625) and the Mac played at the same level throughout. Every route activation and every volume change logged

OutputVolumeControl_HAL_Common.cpp:1314  Setting hardware volume to -29.000000 dB
Device_HAL_Common.mm:317   Set decibel volume value of -29.000000 on HAL device 43 (selector: kAudioDevicePropertyVolumeDecibels; scope: 'outp'; element: 0).
HALS_UCPlugIn.cpp:1191     HALS_UCPlugIn::ObjectSetPropertyData: failed: … Error: 2003329396
Device_HAL_Common.mm:330   FAIL with status 2003329396 ("what"): mDeviceID 43 (uid "PuffinOutput"); selector "vold"; scope 'outp'; element 0

The pspk route runs in HardwareOnly volume mode: VirtualAudio leaves the loudness to the device and sets vold (and mute) on it. The HAL turns a device-level vold or mute into a set on the device's volume or mute control, and the set reaches the control: ASDLevelControl's setProperty: (0x41f50 in the guest's AudioServerDriver) clamps the value and tail-calls changeDecibelValue: or changeScalarValue:, and ASDBooleanControl's calls changeValue:. The framework's own are

42a20: mov w0, #0x0 ; ret        -[ASDLevelControl changeDecibelValue:]
42a28: mov w0, #0x0 ; ret        -[ASDLevelControl changeScalarValue:]
3b680: mov w0, #0x0 ; ret        -[ASDBooleanControl changeValue:]

— a driver is expected to subclass them, and until it does every change is refused with 'what'. That is also the mute set mute_set_throw quiets: the selector was never withheld from the plugin, it arrived at the mute control and was refused there.

The plugin's controls are now subclasses whose hooks take the change, and the device turns them into a gain its streams apply: silence when muted, otherwise the control's decibels as a factor, ramped across one I/O block, on the mix as the I/O thread hands it over. The microphone device takes mute only.

The speaker's control ranges from -36 dB to 0 dB, not the macOS plugin's -60: VirtualAudio maps the guest's volume onto the range in a straight line (VolumeProperties for Port: pspk is [ Min: …; Max: 0 ]), so the bottom decides how loud half volume is, and -30 dB through a Mac's speakers is close to nothing.

Measured on the iPhone guest, Safari playing: three volume-up and five volume-down presses log device speaker: unmuted, -15.8 dB, gain 0.163 → -9.0 dB, gain 0.355 → -20.2 dB, gain 0.097; audiomxd logs Setting hardware volume to -13.500000 dB with no FAIL for vold or mute; the stream runs 240 s with 0 starved.

Not this: Control Center's volume slider on an iOS 27 guest stays full and does not move. SpringBoard cannot reach the volume service at all —

kernel: Protobox: SpringBoard(36) deny(1) mach-lookup com.apple.mediaexperience.avvolumeclient.xpc
SpringBoard: -AVVolumeClient- -[AVVolumeClient initInternalWithType:]: Failed to create FigVolumeController for type 1: -16155
SpringBoard: [MRAVVolumeClientEndpoint] VolumeController unavailable; will retry on next activation

audiomxd registers the service and SpringBoard holds com.apple.private.mediaexperience.controlcentervolumeclient.allow; the lookup is refused by the kernel's sandbox. Traced — see Research/Guest/ios27_cc_volume_sandbox.md. The profile evaluated for SpringBoard is the one baked into the cloudOS 26.4 Sandbox kext (iOS 27 ships no userland platform profile collection), and it predates the iOS 27 service: the 26.4 collection names the old com.apple.coremedia.volumecontroller.xpc and the sibling mediaexperience services but not avvolumeclient nor the controlcentervolume entitlement. mach-lookup is evaluated by the in-kernel Protobox engine (reached from launchd/libxpc's sandbox_check_by_audit_token), not a mac_policy_ops hook, so kernel-boot-sandbox_ext cannot reach it. The obvious guest-side fix — add the global-name to SpringBoard's own com.apple.security.exception.mach-lookup.global-name array and re-sign, the escape hatch Campo uses — was tried and does not work for SpringBoard: the name is confirmed in SpringBoard's DER entitlements yet the denial is unchanged, because SpringBoard's platform profile does not honour the exception entitlement the way Campo's temporary-sandbox profile does. The remaining options (a scoped sandbox_check short-circuit in the launchd hook, or a kernel Protobox patch) and their blast radius are in the note. The volume keys do not go through that service.

9. Playback sounded like wind: the speaker chain was the board's (2026-10-04)

With recordings on an iPhone guest matching the Mac's own (see virtio_sound_microphone.md §7), playing one back in the guest still sounded noisy. Every speaker_* configuration of the D47 tuning set (AID8018) but two runs speaker_general.dspg: a loudness normalizer (AULDNM), a DC blocker, a virtual bass (AUVirtualBass), rotation shading, crosstalk cancellation, an equalizer, a volume taper, a multiband compressor, a second equalizer, two AUBuzzKills and a limiter — a small speaker's correction. audiomxd's own level report across it, for a system sound: PreDSP … rms:[-52.6], peaks:[-35.4], PostDSP … rms:[-26.5], peaks:[-7.8], 26 dB up. Through a Mac's speakers the quiet low end of a recording comes out as noise.

speaker_raw is the same route with rotation shading, a volume (AUVolume, driven by the vugd graph parameter rather than speaker_general's vtvs) and a limiter. system-virtualaudio-cfw-speaker_raw_chains gives every speaker_* entry in graph_configurations.plist other than speaker_raw and speaker_measurement the raw one's graph, austrip, propstrip and volumeCommands (vphone-cli cfw patch-virtualaudio-speaker-raw). It runs after speaker_graph_chains, which is still what keeps the chain factory off the physical speaker.

Without the normalizer the middle of the volume slider was quiet: the route sets the plugin's control in a straight line of decibels over its -36 dB range, -20.2 dB at 7/16. The plugin now takes the control's position in its range, squared, as the gain (applyGain): -14.4 dB at 7/16, -12 dB at half, unity at the top. The control's decibels are what VirtualAudio reads back and are unchanged.

Heard on mictest-iphone by the person testing: "much better" with the raw chain, then "a little quiet", which the taper answers. Not measured: the level across the raw chain, the volume keys after the change (the plugin's log shows the value still arrives), and an iPad guest, whose tuning set this patch also rewrites if it has a speaker_raw.

Reveal and validation

  1. Kernel side present: strings on the decompressed kernelcache shows the AppleVirtIOSound personality above.
  2. Plugin loaded: logs.syslog for audiomxd shows com.vphone.audio/virtiosound lines stream 0: 48000 Hz, 2 channels… and published 1 virtio sound device(s).
  3. VirtualAudio initialized: audiomxd logs Defaults key ProductIDOverride was defined to 8010, PlugIn initialized ? 2 / VA Init Status: 0, no PRECONDITION FAILURE, and no VirtualAudio PlugIn is not initialized yet when an app opens a session. The plugin and vphoned supply the override (§6); vpquery.log says what the plugin found and did.
  4. Sound on the host: audible, and still audible after a stop. Beyond the plugin's own defaults (§6) the speaker route needs the VirtualAudio patches (virtualaudio_speaker_route_throws.md). In vpquery.log, every stop — the second and later ones included — leaves a counter line with writes in proportion to the seconds played, 0 starved, and in at the nominal rate; logs.syslog for audiomxd shows no Re-anchoring IO timeline and no could not establish a timeline.
  5. Hardware volume: the plugin answers the pspk route's volume queries with a real control set — dsrc selector ('ispk' "Speakers"), mute, volume — added from halInitializeWithPluginHost: after device init, before addAudioDevice:. Init-time addControl: fails the whole device activation (HALS_PlugIn.cpp:162); this lifecycle moment does not. Validated by staged isolation on ipad-pro-13 (2026-10-03): with endpointTypeInfo deleted, audiomxd restart, RingtonePreview playback, and a tone tap preview all left it absent — no more Unspecified rewrite — and [volm/outp/0] writes on the VAD succeed. Two constraints the controls themselves carry on iOS: construct them through the explicit-class initializers (initWithValue:…andObjectClassID: and the decibel twin), never the factories — the factories are state-dependent and returned the raw FourCharCode 'togl' as an id, crash-looping audiomxd — and never call booleanValue on the result (the getter is value; the selector does not exist and faults inside audiomxd). And the controls alone still do not give the device a mute property: iOS ASDAudioDevice's device-level dispatch has no 'mute' case at all, so VPVirtIOSoundDevice overrides the five ASD property methods and answers kAudioDevicePropertyMute from a shadow ivar, forwarding sets to the registered mute control (see the handoff in virtualaudio_speaker_route_throws.md for the disassembly).
  6. Dual-rate (44100 ringtone previews): the device advertises @[@44100, @48000] and the 48 kHz stream carries a 44100 physical format; setSamplingRate:/deviceChangedToSamplingRate: move an atomic HAL rate under the mix block, which linearly resamples 44100→48000 onto the wire while the clock re-derives how long its period lasts (§6). Verified booting and answering at both nominal rates — and disproven as the ringtone gate: a 44100-nominal boot fails the tone tap exactly like a 48000 one. The gate is the RingtonePreview category itself; see the session-5 section of virtualaudio_speaker_route_throws.md.