Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions Packages/CocoaSpice/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand All @@ -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"],
Expand Down
15 changes: 15 additions & 0 deletions Packages/CocoaSpice/Sources/CocoaSpice/CSAudioPlayback.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#import <Foundation/Foundation.h>

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
273 changes: 273 additions & 0 deletions Packages/CocoaSpice/Sources/CocoaSpice/CSAudioPlayback.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
#import "CSAudioPlayback.h"
#import "CSMain.h"

#import <AVFoundation/AVFoundation.h>
#import <spice-client.h>
#import <spice/enums.h>

@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
8 changes: 5 additions & 3 deletions Packages/CocoaSpice/Sources/CocoaSpice/CSConnection.m
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
//

#import "CocoaSpice.h"
#import "CSAudioPlayback.h"
#import "CSChannel+Protected.h"
#import "CSCursor+Protected.h"
#import "CSDisplay+Protected.h"
Expand All @@ -37,7 +38,7 @@ @interface CSConnection ()
@property (nonatomic, readwrite) NSMutableArray<CSChannel *> *mutableChannels;
@property (nonatomic, readwrite) SpiceSession *spiceSession;
@property (nonatomic, readwrite) SpiceMainChannel *spiceMain;
@property (nonatomic, readwrite) SpiceAudio *spiceAudio;
@property (nonatomic, strong) CSAudioPlayback *audioPlayback;

@end

Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading