ci / bun-nix (pull_request) Successful in 36s
ci / web (pull_request) Successful in 1m22s
apple / swift (pull_request) Successful in 1m39s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m40s
ci / rust-arm64 (pull_request) Successful in 2m53s
ci / rust (pull_request) Failing after 9m43s
Field report, macOS client, host-independent: start a stream with AirPods in, take them
out — nothing on the speakers; put them back in — nothing in the AirPods either. Only
restarting the whole stream brought audio back.
An AVAudioEngine does not follow the audio hardware. When the output device changes under
a running engine, its IO unit sees the new hardware, THE ENGINE STOPS ITSELF, and it posts
AVAudioEngineConfigurationChange. It stays stopped until somebody starts it again, and
nothing here ever did — no error, no log line, just a session rendering silence from that
moment on. Putting the AirPods back in is a second stop, not a recovery, which is exactly
why that half of the report looked so strange.
Measured on the client's own playback topology (source node -> main mixer, 48 kHz stereo)
by moving the default output device programmatically: render callbacks go from ~94/s to
zero the instant the device changes, and both restarting the same engine and building a
fresh one resume them.
The fix watches the hardware and rebuilds the topology the session was started with, on
whatever device is there now. Three triggers, because no single one covers the ground:
- the engine's own configuration-change notification, every platform — the direct
signal, but it can only be posted BY an engine, so it cannot report a rebuild that
failed to start;
- a CoreAudio HAL default-output-device listener on macOS — independent of any engine
and of the engine's topology. This is what makes the recovery work for the
voice-processing engine, which is the DEFAULT macOS configuration (mic and echo
cancellation both default on) and whose notification behaviour could not be verified:
no Mac in the fleet can initialize VPIO at all;
- route-change and media-services-reset on iOS/tvOS, where the session rather than the
device is what moves. The route observer is now installed for mic-off (.playback)
sessions and on tvOS too — it used to be iOS-and-mic-only, for the earpiece steer,
but every platform has engines a route change can stop.
They collapse into one debounced rebuild (one switch produces a burst), with a floor
between rebuilds so a device that renegotiates in a loop cannot spin the session, and a
short retry ladder for a device caught mid-transition — a rebuild that fails leaves no
engine to post the next notification, so that path must not simply give up. The ring is
deliberately carried across: the drain thread keeps decoding through the switch, and the
ring's overflow policy has already dropped whatever went stale while the engine was down.
A rebuild is only ever done when it concerns us. A healthy engine that followed the change
on its own is left alone, and somebody changing the system default while this session is
pinned to a named speaker is none of our business — rebuilding for that would cost an
audible gap for nothing.
The trigger wiring is split into AudioDeviceWatcher for one reason: an end-to-end test of
the recovery needs a live session, which needs a host, and punktfunk-host does not build
on macOS — so the part where a silent failure costs the session ALL of its audio would
otherwise ship unverified. On its own the watcher is pointed at the real hardware from a
unit test: a real default-output-device move must reach the owner, our engine's
notification must get through, a foreign engine's must not. Neutralizing the wiring fails
both positive tests and neither negative one.
AudioDeviceSwitchTests drives the real SessionAudio through the out-and-back switch
against the loopback host; it skips wherever that fixture cannot run (which is every Mac,
today) and the open host's frame budget is raised so it outlives the switch.
103 lines
4.8 KiB
Swift
103 lines
4.8 KiB
Swift
// The device-switch regression, end to end against a real session.
|
|
//
|
|
// An AVAudioEngine does not follow the audio hardware: when the output device changes under a
|
|
// running engine it STOPS ITSELF and stays stopped. Nothing restarted it, so a stream whose
|
|
// output moved mid-session — AirPods taken out of an ear, a headset unplugged, the default
|
|
// changed in System Settings — played silence from that moment on: nothing on the speakers the
|
|
// system had just moved to, and nothing in the AirPods when they went back in, since that is a
|
|
// second stop rather than a recovery. Only restarting the whole stream brought audio back.
|
|
//
|
|
// This drives the real `SessionAudio` against the loopback host and moves the system's default
|
|
// output device out from under it, twice — out and back, the exact shape of the field report.
|
|
// Playback-only (mic off): it is the render side that died, and a mic would drag the microphone
|
|
// permission and the voice processor into a test that is about neither.
|
|
//
|
|
// Driven by clients/apple/test-loopback.sh, like its LoopbackIntegrationTests siblings.
|
|
|
|
#if os(macOS)
|
|
import AVFoundation
|
|
import CoreAudio
|
|
import XCTest
|
|
|
|
@testable import PunktfunkKit
|
|
|
|
final class AudioDeviceSwitchTests: XCTestCase {
|
|
/// Set the system default output device. Test-local on purpose: nothing in the app ever
|
|
/// changes the user's device, it only follows it.
|
|
private func setDefaultOutput(_ id: AudioDeviceID) -> OSStatus {
|
|
var address = AudioObjectPropertyAddress(
|
|
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
|
|
mScope: kAudioObjectPropertyScopeGlobal,
|
|
mElement: kAudioObjectPropertyElementMain)
|
|
var dev = id
|
|
return AudioObjectSetPropertyData(
|
|
AudioObjectID(kAudioObjectSystemObject), &address, 0, nil,
|
|
UInt32(MemoryLayout<AudioDeviceID>.size), &dev)
|
|
}
|
|
|
|
/// Pump the MAIN runloop until playback is running on `device`, or the deadline passes. The
|
|
/// recovery lands on the main queue (a debounced hop, then possibly a retry ladder), so a
|
|
/// sleeping test would block the very thing it is waiting for.
|
|
private func waitForPlayback(
|
|
_ audio: SessionAudio, on device: AudioDeviceID, timeout: TimeInterval
|
|
) -> Bool {
|
|
let deadline = Date().addingTimeInterval(timeout)
|
|
while Date() < deadline {
|
|
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
|
let state = audio.playbackState
|
|
if state.running, state.device == device { return true }
|
|
}
|
|
return false
|
|
}
|
|
|
|
func testPlaybackFollowsAnOutputDeviceChange() throws {
|
|
guard let portStr = ProcessInfo.processInfo.environment["PUNKTFUNK_LOOPBACK_PORT"],
|
|
let port = UInt16(portStr)
|
|
else {
|
|
throw XCTSkip("needs a running punktfunk1-host — use clients/apple/test-loopback.sh")
|
|
}
|
|
guard let original = AudioDevices.defaultOutputDevice() else {
|
|
throw XCTSkip("no default output device")
|
|
}
|
|
let others = AudioDevices.outputs()
|
|
.compactMap { AudioDevices.deviceID(forUID: $0.uid) }
|
|
.filter { $0 != original }
|
|
guard let target = others.first else {
|
|
throw XCTSkip("needs a second output device to switch to")
|
|
}
|
|
|
|
let conn = try PunktfunkConnection(
|
|
host: "127.0.0.1", port: port, width: 1280, height: 720, refreshHz: 60,
|
|
bitrateKbps: 50_000)
|
|
let audio = SessionAudio(connection: conn)
|
|
// "" speaker UID = follow the system default, which is what the report was running and
|
|
// the only configuration a default-device change is supposed to move.
|
|
audio.start(
|
|
speakerUID: "", micUID: "", micChannel: 0, micEnabled: false, echoCancel: false)
|
|
defer {
|
|
audio.stop()
|
|
_ = setDefaultOutput(original)
|
|
}
|
|
|
|
XCTAssertTrue(
|
|
waitForPlayback(audio, on: original, timeout: 5),
|
|
"playback never started on the current default output device")
|
|
|
|
// Out: the device the stream was playing to goes away underneath it.
|
|
XCTAssertEqual(setDefaultOutput(target), noErr)
|
|
XCTAssertTrue(
|
|
waitForPlayback(audio, on: target, timeout: 10),
|
|
"playback did not come back after the output device changed — this is the field "
|
|
+ "report: no sound on the device the system moved to, until the stream is "
|
|
+ "restarted")
|
|
|
|
// And back: the second half of the report, where putting the AirPods back in produced a
|
|
// second stop rather than a recovery.
|
|
XCTAssertEqual(setDefaultOutput(original), noErr)
|
|
XCTAssertTrue(
|
|
waitForPlayback(audio, on: original, timeout: 10),
|
|
"playback did not come back after the output device changed back")
|
|
}
|
|
}
|
|
#endif
|