diff --git a/Packages/CocoaSpice/Package.swift b/Packages/CocoaSpice/Package.swift index 5ff5d1f..a192dbc 100644 --- a/Packages/CocoaSpice/Package.swift +++ b/Packages/CocoaSpice/Package.swift @@ -32,7 +32,9 @@ let package = Package( .headerSearchPath("ExternalHeaders/gstreamer-1.0"), .headerSearchPath("ExternalHeaders/libusb-1.0"), .headerSearchPath("ExternalHeaders/spice-1"), - .headerSearchPath("ExternalHeaders/spice-client-glib-2.0")]), + .headerSearchPath("ExternalHeaders/spice-client-glib-2.0")], + linkerSettings: [ + .linkedFramework("AVFoundation")]), .target( name: "CocoaSpiceNoUsb", dependencies: ["CocoaSpiceRenderer"], @@ -45,7 +47,9 @@ let package = Package( .headerSearchPath("ExternalHeaders/glib-2.0"), .headerSearchPath("ExternalHeaders/gstreamer-1.0"), .headerSearchPath("ExternalHeaders/spice-1"), - .headerSearchPath("ExternalHeaders/spice-client-glib-2.0")]), + .headerSearchPath("ExternalHeaders/spice-client-glib-2.0")], + linkerSettings: [ + .linkedFramework("AVFoundation")]), .testTarget( name: "CocoaSpiceTests", dependencies: ["CocoaSpice"], diff --git a/Packages/CocoaSpice/Sources/CocoaSpice/CSAudioPlayback.h b/Packages/CocoaSpice/Sources/CocoaSpice/CSAudioPlayback.h new file mode 100644 index 0000000..565a036 --- /dev/null +++ b/Packages/CocoaSpice/Sources/CocoaSpice/CSAudioPlayback.h @@ -0,0 +1,15 @@ +#import + +typedef struct _SpicePlaybackChannel SpicePlaybackChannel; + +NS_ASSUME_NONNULL_BEGIN + +@interface CSAudioPlayback : NSObject + +- (instancetype)init NS_UNAVAILABLE; +- (instancetype)initWithChannel:(SpicePlaybackChannel *)channel + NS_DESIGNATED_INITIALIZER; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Packages/CocoaSpice/Sources/CocoaSpice/CSAudioPlayback.m b/Packages/CocoaSpice/Sources/CocoaSpice/CSAudioPlayback.m new file mode 100644 index 0000000..ffb435b --- /dev/null +++ b/Packages/CocoaSpice/Sources/CocoaSpice/CSAudioPlayback.m @@ -0,0 +1,273 @@ +#import "CSAudioPlayback.h" +#import "CSMain.h" + +#import +#import +#import + +@interface CSAudioPlayback () + +@property (nonatomic) SpicePlaybackChannel *channel; +@property (nonatomic, strong) AVAudioEngine *engine; +@property (nonatomic, strong) AVAudioPlayerNode *player; +@property (nonatomic, strong, nullable) AVAudioFormat *format; +@property (nonatomic) BOOL isPlaying; +@property (nonatomic) BOOL wantsPlayback; +@property (nonatomic) float playbackVolume; +@property (nonatomic) BOOL isMuted; +@property (nonatomic, strong) id engineConfigurationObserver; +@property (nonatomic) BOOL engineRecoveryScheduled; + +- (void)startWithFormat:(gint)format + channels:(gint)channels + frequency:(gint)frequency; +- (void)startEngine; +- (void)scheduleEngineRecoveryAfter:(dispatch_time_t)delay; +- (void)appendAudio:(gpointer *)audio size:(gint)size; +- (void)stop; + +@end + +@implementation CSAudioPlayback + +static void cs_playback_start(SpicePlaybackChannel *channel, + gint format, + gint channels, + gint frequency, + gpointer data) +{ + CSAudioPlayback *self = (__bridge CSAudioPlayback *)data; + [self startWithFormat:format channels:channels frequency:frequency]; +} + +static void cs_playback_data(SpicePlaybackChannel *channel, + gpointer *audio, + gint size, + gpointer data) +{ + CSAudioPlayback *self = (__bridge CSAudioPlayback *)data; + [self appendAudio:audio size:size]; +} + +static void cs_playback_stop(SpicePlaybackChannel *channel, gpointer data) +{ + CSAudioPlayback *self = (__bridge CSAudioPlayback *)data; + [self stop]; +} + +static void cs_playback_volume_changed(GObject *object, + GParamSpec *pspec, + gpointer data) +{ + CSAudioPlayback *self = (__bridge CSAudioPlayback *)data; + guint16 *volumes = NULL; + guint channels = 0; + g_object_get(object, + "volume", &volumes, + "nchannels", &channels, + NULL); + if (channels > 0 && volumes) { + self.playbackVolume = (float)volumes[0] / UINT16_MAX; + self.player.volume = self.isMuted ? 0.0f : self.playbackVolume; + } +} + +static void cs_playback_mute_changed(GObject *object, + GParamSpec *pspec, + gpointer data) +{ + CSAudioPlayback *self = (__bridge CSAudioPlayback *)data; + gboolean muted = FALSE; + g_object_get(object, "mute", &muted, NULL); + self.isMuted = muted; + self.player.volume = muted ? 0.0f : self.playbackVolume; +} + +- (instancetype)initWithChannel:(SpicePlaybackChannel *)channel +{ + self = [super init]; + if (self) { + _channel = g_object_ref(channel); + _engine = [[AVAudioEngine alloc] init]; + _player = [[AVAudioPlayerNode alloc] init]; + _playbackVolume = 1.0f; + [_engine attachNode:_player]; + __weak CSAudioPlayback *weakSelf = self; + _engineConfigurationObserver = [NSNotificationCenter.defaultCenter + addObserverForName:AVAudioEngineConfigurationChangeNotification + object:_engine + queue:nil + usingBlock:^(NSNotification *notification) { + [CSMain.sharedInstance asyncWith:^{ + CSAudioPlayback *strongSelf = weakSelf; + if (strongSelf.wantsPlayback && + strongSelf.format && + !strongSelf.engine.isRunning) { + [strongSelf scheduleEngineRecoveryAfter: + dispatch_time(DISPATCH_TIME_NOW, + 100 * NSEC_PER_MSEC)]; + } + }]; + }]; + + g_signal_connect(channel, + "playback-start", + G_CALLBACK(cs_playback_start), + (__bridge void *)self); + g_signal_connect(channel, + "playback-data", + G_CALLBACK(cs_playback_data), + (__bridge void *)self); + g_signal_connect(channel, + "playback-stop", + G_CALLBACK(cs_playback_stop), + (__bridge void *)self); + g_signal_connect(channel, + "notify::volume", + G_CALLBACK(cs_playback_volume_changed), + (__bridge void *)self); + g_signal_connect(channel, + "notify::mute", + G_CALLBACK(cs_playback_mute_changed), + (__bridge void *)self); + } + return self; +} + +- (void)startWithFormat:(gint)format + channels:(gint)channels + frequency:(gint)frequency +{ + self.isPlaying = NO; + if (format != SPICE_AUDIO_FMT_S16 || channels <= 0 || frequency <= 0) { + g_warning("Unsupported SPICE audio format: %d", format); + return; + } + + self.format = [[AVAudioFormat alloc] + initWithCommonFormat:AVAudioPCMFormatInt16 + sampleRate:(double)frequency + channels:(AVAudioChannelCount)channels + interleaved:YES]; + if (!self.format) { + g_warning("Could not create the host audio format."); + return; + } + + self.wantsPlayback = YES; + [self startEngine]; +} + +- (void)startEngine +{ + self.isPlaying = NO; + [self.player stop]; + [self.engine stop]; + [self.engine disconnectNodeOutput:self.player]; + [self.engine connect:self.player + to:self.engine.mainMixerNode + format:self.format]; + [self.engine prepare]; + NSError *error = nil; + if (![self.engine startAndReturnError:&error]) { + g_warning("Could not start host audio output: %s", + error.localizedDescription.UTF8String); + [self scheduleEngineRecoveryAfter: + dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC)]; + return; + } + [self.player play]; + self.isPlaying = YES; + spice_playback_channel_set_delay(self.channel, 0); +} + +- (void)scheduleEngineRecoveryAfter:(dispatch_time_t)delay +{ + if (self.engineRecoveryScheduled || !self.wantsPlayback) { + return; + } + self.engineRecoveryScheduled = YES; + __weak CSAudioPlayback *weakSelf = self; + dispatch_after( + delay, + dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), + ^{ + [CSMain.sharedInstance asyncWith:^{ + CSAudioPlayback *strongSelf = weakSelf; + strongSelf.engineRecoveryScheduled = NO; + if (strongSelf.wantsPlayback && + strongSelf.format && + !strongSelf.engine.isRunning) { + [strongSelf startEngine]; + } + }]; + } + ); +} + +- (void)appendAudio:(gpointer *)audio size:(gint)size +{ + if (!self.isPlaying || + !self.engine.isRunning || + !self.player.isPlaying || + !self.format || + !audio || + size <= 0) { + return; + } + + const NSUInteger bytesPerFrame = + self.format.channelCount * sizeof(int16_t); + const AVAudioFrameCount frameCount = + (AVAudioFrameCount)((NSUInteger)size / bytesPerFrame); + if (frameCount == 0) { + return; + } + + AVAudioPCMBuffer *buffer = + [[AVAudioPCMBuffer alloc] initWithPCMFormat:self.format + frameCapacity:frameCount]; + if (!buffer) { + return; + } + buffer.frameLength = frameCount; + AudioBuffer *audioBuffer = &buffer.mutableAudioBufferList->mBuffers[0]; + const NSUInteger byteCount = frameCount * bytesPerFrame; + memcpy(audioBuffer->mData, audio, byteCount); + audioBuffer->mDataByteSize = (UInt32)byteCount; + [self.player scheduleBuffer:buffer completionHandler:nil]; +} + +- (void)stop +{ + self.wantsPlayback = NO; + self.isPlaying = NO; + [self.player stop]; + [self.engine stop]; + [self.engine reset]; +} + +- (void)dealloc +{ + if (_engineConfigurationObserver) { + [NSNotificationCenter.defaultCenter + removeObserver:_engineConfigurationObserver]; + _engineConfigurationObserver = nil; + } + AVAudioPlayerNode *player = _player; + AVAudioEngine *engine = _engine; + SpicePlaybackChannel *channel = _channel; + gpointer data = (__bridge void *)self; + [CSMain.sharedInstance syncWith:^{ + [player stop]; + [engine stop]; + [engine reset]; + if (channel) { + g_signal_handlers_disconnect_by_data(channel, data); + g_object_unref(channel); + } + }]; + _channel = NULL; +} + +@end diff --git a/Packages/CocoaSpice/Sources/CocoaSpice/CSConnection.m b/Packages/CocoaSpice/Sources/CocoaSpice/CSConnection.m index 936842d..229e7d9 100644 --- a/Packages/CocoaSpice/Sources/CocoaSpice/CSConnection.m +++ b/Packages/CocoaSpice/Sources/CocoaSpice/CSConnection.m @@ -15,6 +15,7 @@ // #import "CocoaSpice.h" +#import "CSAudioPlayback.h" #import "CSChannel+Protected.h" #import "CSCursor+Protected.h" #import "CSDisplay+Protected.h" @@ -37,7 +38,7 @@ @interface CSConnection () @property (nonatomic, readwrite) NSMutableArray *mutableChannels; @property (nonatomic, readwrite) SpiceSession *spiceSession; @property (nonatomic, readwrite) SpiceMainChannel *spiceMain; -@property (nonatomic, readwrite) SpiceAudio *spiceAudio; +@property (nonatomic, strong) CSAudioPlayback *audioPlayback; @end @@ -237,7 +238,8 @@ static void cs_channel_new(SpiceSession *s, SpiceChannel *channel, gpointer data if (SPICE_IS_PLAYBACK_CHANNEL(channel)) { SPICE_DEBUG("new audio channel"); if (self.audioEnabled) { - self.spiceAudio = spice_audio_get(s, [CSMain sharedInstance].glibMainContext); + self.audioPlayback = [[CSAudioPlayback alloc] + initWithChannel:SPICE_PLAYBACK_CHANNEL(channel)]; spice_channel_connect(channel); } else { SPICE_DEBUG("audio disabled"); @@ -281,7 +283,7 @@ static void cs_channel_destroy(SpiceSession *s, SpiceChannel *channel, gpointer if (SPICE_IS_PLAYBACK_CHANNEL(channel)) { SPICE_DEBUG("zap audio channel"); - self.spiceAudio = NULL; + self.audioPlayback = nil; } if (SPICE_IS_PORT_CHANNEL(channel)) { diff --git a/Packages/CocoaSpice/Sources/CocoaSpice/include/CSConnection.h b/Packages/CocoaSpice/Sources/CocoaSpice/include/CSConnection.h index 0fee1bd..f1bef0d 100644 --- a/Packages/CocoaSpice/Sources/CocoaSpice/include/CSConnection.h +++ b/Packages/CocoaSpice/Sources/CocoaSpice/include/CSConnection.h @@ -58,7 +58,7 @@ NS_ASSUME_NONNULL_BEGIN /// If set, will attempt a SPICE ticket auth @property (nonatomic, nullable, copy) NSString *password; -/// When enabled, gstreamer is used to provide audio input/output. Defaults to disabled +/// When enabled, SPICE playback is routed to the host audio output. Defaults to disabled. @property (nonatomic, assign) BOOL audioEnabled; - (instancetype)init NS_UNAVAILABLE; diff --git a/Packages/SimpleVMCore/Sources/SimpleVMCore/Virtualization/Apple/AppleVirtualMachineConfigurationFactory.swift b/Packages/SimpleVMCore/Sources/SimpleVMCore/Virtualization/Apple/AppleVirtualMachineConfigurationFactory.swift index be13e75..a374b9a 100644 --- a/Packages/SimpleVMCore/Sources/SimpleVMCore/Virtualization/Apple/AppleVirtualMachineConfigurationFactory.swift +++ b/Packages/SimpleVMCore/Sources/SimpleVMCore/Virtualization/Apple/AppleVirtualMachineConfigurationFactory.swift @@ -78,6 +78,7 @@ public enum AppleVirtualMachineConfigurationFactory { configuration.storageDevices = storageDevices configuration.networkDevices = [makeNetworkDevice()] configuration.graphicsDevices = [makeGraphicsDevice()] + configuration.audioDevices = [makeAudioDevice()] configuration.keyboards = [VZUSBKeyboardConfiguration()] configuration.pointingDevices = [ VZUSBScreenCoordinatePointingDeviceConfiguration() @@ -130,6 +131,16 @@ public enum AppleVirtualMachineConfigurationFactory { return device } + @MainActor + private static func makeAudioDevice() -> VZVirtioSoundDeviceConfiguration { + let output = VZVirtioSoundDeviceOutputStreamConfiguration() + output.sink = VZHostAudioOutputStreamSink() + + let device = VZVirtioSoundDeviceConfiguration() + device.streams = [output] + return device + } + @MainActor private static func makeDirectoryShare( path: String diff --git a/Packages/SimpleVMCore/Sources/SimpleVMCore/Virtualization/QEMU/QEMUConfiguration.swift b/Packages/SimpleVMCore/Sources/SimpleVMCore/Virtualization/QEMU/QEMUConfiguration.swift index 345ac58..dfbfd56 100644 --- a/Packages/SimpleVMCore/Sources/SimpleVMCore/Virtualization/QEMU/QEMUConfiguration.swift +++ b/Packages/SimpleVMCore/Sources/SimpleVMCore/Virtualization/QEMU/QEMUConfiguration.swift @@ -93,13 +93,16 @@ public enum QEMUConfigurationBuilder { } let networkDefinition = (["user", "id=net0"] + forwarding) .joined(separator: ",") + let audioBackend: String let cpuCount: Int let accelerator: String switch runtime.displayBackend { case .vnc: + audioBackend = "coreaudio,id=audio0" cpuCount = min(machine.spec.cpuCount, 2) accelerator = "tcg,tb-size=1024" case .spiceGL: + audioBackend = "spice,id=audio0" cpuCount = machine.spec.cpuCount accelerator = "tcg,thread=multi,tb-size=2048" } @@ -128,7 +131,10 @@ public enum QEMUConfigurationBuilder { "virtio-blk-pci,drive=system-disk,bootindex=0", "-device", "virtio-rng-pci", "-netdev", networkDefinition, - "-device", "virtio-net-pci,netdev=net0" + "-device", "virtio-net-pci,netdev=net0", + "-audiodev", audioBackend, + "-device", "ich9-intel-hda,id=hda", + "-device", "hda-output,bus=hda.0,audiodev=audio0" ] switch runtime.displayBackend { case .vnc: diff --git a/Packages/SimpleVMCore/Tests/SimpleVMCoreTests/QEMUBackendTests.swift b/Packages/SimpleVMCore/Tests/SimpleVMCoreTests/QEMUBackendTests.swift index 0e9df1b..e83ef1c 100644 --- a/Packages/SimpleVMCore/Tests/SimpleVMCoreTests/QEMUBackendTests.swift +++ b/Packages/SimpleVMCore/Tests/SimpleVMCoreTests/QEMUBackendTests.swift @@ -89,6 +89,13 @@ func buildsExplicitQEMUArgumentsAndPersistentFirmware() throws { #expect(configuration.arguments.contains("Compatibility,, Test")) #expect(configuration.arguments.contains("127.0.0.1:1")) #expect(configuration.arguments.contains("virtio-vga,xres=1280,yres=800")) + #expect(configuration.arguments.contains("coreaudio,id=audio0")) + #expect(configuration.arguments.contains("ich9-intel-hda,id=hda")) + #expect( + configuration.arguments.contains( + "hda-output,bus=hda.0,audiodev=audio0" + ) + ) #expect( configuration.arguments.contains( "virtio-blk-pci,drive=system-disk,bootindex=0" @@ -145,6 +152,15 @@ func buildsExplicitQEMUArgumentsAndPersistentFirmware() throws { )) #expect(accelerated.arguments.contains("virtio-keyboard-pci")) #expect(accelerated.arguments.contains("virtio-tablet-pci")) + #expect(accelerated.arguments.contains("spice,id=audio0")) + #expect(accelerated.arguments.contains("ich9-intel-hda,id=hda")) + #expect( + accelerated.arguments.contains( + "hda-output,bus=hda.0,audiodev=audio0" + ) + ) + #expect(!accelerated.arguments.contains { $0.hasPrefix("hda-duplex") }) + #expect(!accelerated.arguments.contains { $0.hasPrefix("hda-micro") }) #expect(accelerated.arguments.contains("tcg,thread=multi,tb-size=2048")) #expect(accelerated.arguments.contains(where: { $0.contains("disable-ticketing=on,gl=on") diff --git a/README.md b/README.md index b12a06d..bcb2382 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ guests and a QEMU/SPICE/Metal path for x86_64 compatibility. - Exportable library media and stopped-machine raw disks for migration - Persistent disks, EFI state, snapshots, restore, and APFS-backed clones - NAT networking, TCP port forwarding, and virtiofs directory sharing +- Automatic guest audio output on Apple Virtualization and QEMU/SPICE - First-party, capability-gated SimpleVM Guest Tools for Linux integration - Rosetta support for Intel Linux binaries in supported ARM64 guests - Preinstalled raw disks, rootfs archives, and OCI image provisioning @@ -40,10 +41,10 @@ release test matrix. ## Architecture -| Guest | Backend | CPU | Display | -| --- | --- | --- | --- | -| ARM64 Linux | Apple Virtualization | Hardware virtualization | `VZVirtualMachineView` | -| x86_64 Linux | QEMU | TCG software emulation | UTM QEMU, SPICE, CocoaSpice, Metal | +| Guest | Backend | CPU | Display | Audio output | +| --- | --- | --- | --- | --- | +| ARM64 Linux | Apple Virtualization | Hardware virtualization | `VZVirtualMachineView` | Virtio sound | +| x86_64 Linux | QEMU | TCG software emulation | UTM QEMU, SPICE, CocoaSpice, Metal | Intel HDA over SPICE or Core Audio | The x86_64 display path is GPU-accelerated, but its CPU remains fully emulated. It is intended for compatibility and will not match native ARM64 performance. @@ -347,6 +348,7 @@ The fixture path and installer image remain local and are never committed. - No prebuilt or notarized release artifact - x86_64 CPU execution uses TCG software emulation - The accelerated x86_64 build currently expects UTM in `/Applications` +- Guest audio input and host microphone forwarding are not supported - Guest Tools require an explicit in-guest install and currently target systemd Debian/Ubuntu and Arch/Omarchy - QEMU does not yet expose configured host directories as virtiofs shares; diff --git a/SimpleVMApp/Virtualization/SPICEConnectionController.swift b/SimpleVMApp/Virtualization/SPICEConnectionController.swift index 979f09a..baae629 100644 --- a/SimpleVMApp/Virtualization/SPICEConnectionController.swift +++ b/SimpleVMApp/Virtualization/SPICEConnectionController.swift @@ -23,22 +23,16 @@ final class SPICEConnectionController: NSObject { private var clipboardSharingAllowed = true func connect(to socketURL: URL) async throws { - guard CSMain.shared.spiceStart() || CSMain.shared.running else { + guard Self.startClient() else { throw SPICEConnectionError.startFailed } var lastError: (any Error)? for _ in 0..<100 { if FileManager.default.fileExists(atPath: socketURL.path) { - let connection = CSConnection( - unixSocketFile: socketURL - ) - pasteboardBridge.noticeHandler = { [weak self] message in - self?.clipboardNoticeHandler?(message) + prepareConnection(to: socketURL) + guard let connection else { + throw SPICEConnectionError.connectionFailed } - connection.session.pasteboardDelegate = pasteboardBridge - connection.session.shareClipboard = false - connection.delegate = self - self.connection = connection do { try await withCheckedThrowingContinuation { continuation in @@ -63,6 +57,36 @@ final class SPICEConnectionController: NSObject { throw lastError ?? SPICEConnectionError.unavailable } + @discardableResult + func prepareConnection(to socketURL: URL) -> AnyObject { + let connection = CSConnection(unixSocketFile: socketURL) + connection.audioEnabled = true + pasteboardBridge.noticeHandler = { [weak self] message in + self?.clipboardNoticeHandler?(message) + } + connection.session.pasteboardDelegate = pasteboardBridge + connection.session.shareClipboard = false + connection.delegate = self + self.connection = connection + return connection + } + + var preparedConnectionAudioEnabled: Bool { + connection?.audioEnabled == true + } + + var preparedConnectionHasPasteboardDelegate: Bool { + connection?.session.pasteboardDelegate != nil + } + + static func startClient() -> Bool { + CSMain.shared.spiceStart() || CSMain.shared.running + } + + static func stopClient() { + CSMain.shared.spiceStop() + } + func disconnect() { clipboardPollingTask?.cancel() clipboardPollingTask = nil diff --git a/SimpleVMAppTests/FoundationTests.swift b/SimpleVMAppTests/FoundationTests.swift index 076950a..2d971b9 100644 --- a/SimpleVMAppTests/FoundationTests.swift +++ b/SimpleVMAppTests/FoundationTests.swift @@ -21,6 +21,29 @@ final class FoundationTests: XCTestCase { XCTAssertEqual(value as? Bool, true) } + @MainActor + func testSPICEControllerRetainsConnectionUntilDisconnect() { + XCTAssertTrue(SPICEConnectionController.startClient()) + defer { SPICEConnectionController.stopClient() } + + let controller = SPICEConnectionController() + weak var retainedConnection: AnyObject? + autoreleasepool { + let connection = controller.prepareConnection( + to: URL(filePath: "/tmp/simplevm-audio-test.sock") + ) + XCTAssertTrue(controller.preparedConnectionAudioEnabled) + XCTAssertTrue( + controller.preparedConnectionHasPasteboardDelegate + ) + retainedConnection = connection + } + XCTAssertNotNil(retainedConnection) + + controller.disconnect() + XCTAssertNil(retainedConnection) + } + @MainActor func testImmersionExitShortcutIsHostOnly() throws { let matching = try XCTUnwrap( @@ -868,6 +891,21 @@ final class FoundationTests: XCTestCase { scanout.heightInPixels, AppleVirtualMachineConfigurationFactory.defaultDisplayHeight ) + let audioDevice = try XCTUnwrap( + first.audioDevices.first as? VZVirtioSoundDeviceConfiguration + ) + XCTAssertEqual(first.audioDevices.count, 1) + XCTAssertEqual(audioDevice.streams.count, 1) + let output = try XCTUnwrap( + audioDevice.streams.first + as? VZVirtioSoundDeviceOutputStreamConfiguration + ) + XCTAssertTrue(output.sink is VZHostAudioOutputStreamSink) + XCTAssertFalse( + audioDevice.streams.contains { + $0 is VZVirtioSoundDeviceInputStreamConfiguration + } + ) XCTAssertEqual(try Data(contentsOf: identifierURL), firstIdentifier) XCTAssertTrue( FileManager.default.fileExists(