Skip to content

Commit

Permalink
Native CoreAudio renderer with spatial audio
Browse files Browse the repository at this point in the history
* Inspired by an example app from Apple. [1]
* Needs an M1 or newer Mac.
* Operates in a standard passthrough mode for stereo or when you have enough real channels (HDMI).
* When headphones or built-in Macbook speakers are used and a 5.1 or 7.1 stream is being sent by
  Sunshine, this will render the surround channels into high quality spatial audio.
* Supports optional head-tracking (enable in settings).
* Supports personalized HRTF if you've scanned your ears with your iPhone. This can greatly improve spatial
  audio for many people.

Known issues:
* System sound menu does not indicate spatial audio is active or that multichannel audio is playing.
* If Moonlight is in Game Mode and you toggle back and forth by say, swiping out of full screen to another app, the audio may stutter a bit.

[1] https://developer.apple.com/documentation/audiotoolbox/generating_spatial_audio_from_a_multichannel_audio_stream
  • Loading branch information
andygrundman committed Sep 30, 2024
1 parent 7e10056 commit 2660e79
Show file tree
Hide file tree
Showing 19 changed files with 2,046 additions and 13 deletions.
2 changes: 1 addition & 1 deletion .gitmodules
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[submodule "moonlight-common-c/moonlight-common-c"]
path = moonlight-common-c/moonlight-common-c
url = https://github.com/moonlight-stream/moonlight-common-c.git
url = https://github.com/andygrundman/moonlight-common-c.git
[submodule "qmdnsengine/qmdnsengine"]
path = qmdnsengine/qmdnsengine
url = https://github.com/cgutman/qmdnsengine.git
Expand Down
2 changes: 2 additions & 0 deletions app/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
</array>
<key>GCSupportsControllerUserInteraction</key>
<true/>
<key>LSApplicationCategoryType</key>
<string>public.app-category.games</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
Expand Down
28 changes: 23 additions & 5 deletions app/app.pro
Original file line number Diff line number Diff line change
Expand Up @@ -160,10 +160,19 @@ macx {
CONFIG += discord-rpc
}

LIBS += -lobjc -framework VideoToolbox -framework AVFoundation -framework CoreVideo -framework CoreGraphics -framework CoreMedia -framework AppKit -framework Metal -framework QuartzCore

# For libsoundio
LIBS += -framework CoreAudio -framework AudioUnit
LIBS += -lobjc \
-framework Accelerate \
-framework AppKit \
-framework AudioToolbox \
-framework AudioUnit \
-framework AVFoundation \
-framework CoreAudio \
-framework CoreVideo \
-framework CoreGraphics \
-framework CoreMedia \
-framework Metal \
-framework QuartzCore \
-framework VideoToolbox

CONFIG += ffmpeg soundio
}
Expand Down Expand Up @@ -392,14 +401,23 @@ win32:!winrt {
streaming/video/ffmpeg-renderers/pacer/dxvsyncsource.h
}
macx {
message(VideoToolbox renderer selected)
message(CoreAudio + VideoToolbox renderers selected)

DEFINES += HAVE_COREAUDIO

SOURCES += \
streaming/audio/renderers/coreaudio/au_spatial_renderer.mm \
streaming/audio/renderers/coreaudio/coreaudio.cpp \
streaming/audio/renderers/coreaudio/TPCircularBuffer.c \
streaming/video/ffmpeg-renderers/vt_base.mm \
streaming/video/ffmpeg-renderers/vt_avsamplelayer.mm \
streaming/video/ffmpeg-renderers/vt_metal.mm

HEADERS += \
streaming/audio/renderers/coreaudio/au_spatial_renderer.h \
streaming/audio/renderers/coreaudio/coreaudio.h \
streaming/audio/renderers/coreaudio/coreaudio_helpers.h \
streaming/audio/renderers/coreaudio/TPCircularBuffer.h \
streaming/video/ffmpeg-renderers/vt.h
}
soundio {
Expand Down
12 changes: 12 additions & 0 deletions app/deploy/macos/spatial-audio.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.developer.spatial-audio.profile-access</key>
<true/>
<key>com.apple.developer.coremotion.head-pose</key>
<true/>
</dict>
</plist>
74 changes: 73 additions & 1 deletion app/gui/SettingsView.qml
Original file line number Diff line number Diff line change
Expand Up @@ -881,6 +881,78 @@ Flickable {
}
}

Label {
width: parent.width
id: resSpatialAudioTitle
text: qsTr("Spatial audio")
font.pointSize: 12
wrapMode: Text.Wrap
visible: Qt.platform.os == "osx"
}

Row {
spacing: 5
width: parent.width
visible: Qt.platform.os == "osx"

AutoResizingComboBox {
// ignore setting the index at first, and actually set it when the component is loaded
Component.onCompleted: {
var saved_sac = StreamingPreferences.spatialAudioConfig
currentIndex = 0
for (var i = 0; i < spatialAudioListModel.count; i++) {
var el_audio = spatialAudioListModel.get(i).val;
if (saved_sac === el_audio) {
currentIndex = i
break
}
}
activated(currentIndex)
}

id: spatialAudioComboBox
enabled: StreamingPreferences.audioConfig != StreamingPreferences.AC_STEREO
textRole: "text"
model: ListModel {
id: spatialAudioListModel
ListElement {
text: qsTr("Enabled")
val: StreamingPreferences.SAC_AUTO
}
ListElement {
text: qsTr("Disabled")
val: StreamingPreferences.SAC_DISABLED
}
}

// ::onActivated must be used, as it only listens for when the index is changed by a human
onActivated : {
StreamingPreferences.spatialAudioConfig = spatialAudioListModel.get(currentIndex).val
}

ToolTip.delay: 1000
ToolTip.timeout: 5000
ToolTip.visible: hovered
ToolTip.text: qsTr("Spatial audio will be used when using any type of headphones, built-in Macbook speakers, and 2-channel USB devices.")
}

CheckBox {
id: spatialHeadTracking
enabled: StreamingPreferences.audioConfig != StreamingPreferences.AC_STEREO && StreamingPreferences.spatialAudioConfig != StreamingPreferences.SAC_DISABLED
width: parent.width
text: qsTr("Enable head-tracking")
font.pointSize: 12
checked: StreamingPreferences.spatialHeadTracking
onCheckedChanged: {
StreamingPreferences.spatialHeadTracking = checked
}

ToolTip.delay: 1000
ToolTip.timeout: 5000
ToolTip.visible: hovered
ToolTip.text: qsTr("Requires supported Apple or Beats headphones")
}
}

CheckBox {
id: audioPcCheck
Expand Down Expand Up @@ -1176,7 +1248,7 @@ Flickable {
ListElement {
text: qsTr("Maximized")
val: StreamingPreferences.UI_MAXIMIZED
}
}
ListElement {
text: qsTr("Fullscreen")
val: StreamingPreferences.UI_FULLSCREEN
Expand Down
7 changes: 7 additions & 0 deletions app/settings/streamingpreferences.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@
#define SER_FULLSCREEN "fullscreen"
#define SER_VSYNC "vsync"
#define SER_GAMEOPTS "gameopts"
#define SER_HEADTRACKING "headtracking"
#define SER_HOSTAUDIO "hostaudio"
#define SER_MULTICONT "multicontroller"
#define SER_AUDIOCFG "audiocfg"
#define SER_SPATIALAUDIOCFG "spatialaudiocfg"
#define SER_VIDEOCFG "videocfg"
#define SER_HDR "hdr"
#define SER_YUV444 "yuv444"
Expand Down Expand Up @@ -124,6 +126,7 @@ void StreamingPreferences::reload()
unlockBitrate = settings.value(SER_UNLOCK_BITRATE, false).toBool();
enableVsync = settings.value(SER_VSYNC, true).toBool();
gameOptimizations = settings.value(SER_GAMEOPTS, true).toBool();
spatialHeadTracking = settings.value(SER_HEADTRACKING, false).toBool();
playAudioOnHost = settings.value(SER_HOSTAUDIO, false).toBool();
multiController = settings.value(SER_MULTICONT, true).toBool();
enableMdns = settings.value(SER_MDNS, true).toBool();
Expand All @@ -148,6 +151,8 @@ void StreamingPreferences::reload()
static_cast<int>(CaptureSysKeysMode::CSK_OFF)).toInt());
audioConfig = static_cast<AudioConfig>(settings.value(SER_AUDIOCFG,
static_cast<int>(AudioConfig::AC_STEREO)).toInt());
spatialAudioConfig = static_cast<SpatialAudioConfig>(settings.value(SER_SPATIALAUDIOCFG,
static_cast<int>(SpatialAudioConfig::SAC_AUTO)).toInt());
videoCodecConfig = static_cast<VideoCodecConfig>(settings.value(SER_VIDEOCFG,
static_cast<int>(VideoCodecConfig::VCC_AUTO)).toInt());
videoDecoderSelection = static_cast<VideoDecoderSelection>(settings.value(SER_VIDEODEC,
Expand Down Expand Up @@ -314,6 +319,7 @@ void StreamingPreferences::save()
settings.setValue(SER_UNLOCK_BITRATE, unlockBitrate);
settings.setValue(SER_VSYNC, enableVsync);
settings.setValue(SER_GAMEOPTS, gameOptimizations);
settings.setValue(SER_HEADTRACKING, spatialHeadTracking);
settings.setValue(SER_HOSTAUDIO, playAudioOnHost);
settings.setValue(SER_MULTICONT, multiController);
settings.setValue(SER_MDNS, enableMdns);
Expand All @@ -328,6 +334,7 @@ void StreamingPreferences::save()
settings.setValue(SER_DETECTNETBLOCKING, detectNetworkBlocking);
settings.setValue(SER_SHOWPERFOVERLAY, showPerformanceOverlay);
settings.setValue(SER_AUDIOCFG, static_cast<int>(audioConfig));
settings.setValue(SER_SPATIALAUDIOCFG, static_cast<int>(spatialAudioConfig));
settings.setValue(SER_HDR, enableHdr);
settings.setValue(SER_YUV444, enableYUV444);
settings.setValue(SER_VIDEOCFG, static_cast<int>(videoCodecConfig));
Expand Down
13 changes: 13 additions & 0 deletions app/settings/streamingpreferences.h
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ class StreamingPreferences : public QObject
};
Q_ENUM(AudioConfig)

enum SpatialAudioConfig
{
SAC_AUTO,
SAC_DISABLED
};
Q_ENUM(SpatialAudioConfig)

enum VideoCodecConfig
{
VCC_AUTO,
Expand Down Expand Up @@ -112,6 +119,7 @@ class StreamingPreferences : public QObject
Q_PROPERTY(bool unlockBitrate MEMBER unlockBitrate NOTIFY unlockBitrateChanged)
Q_PROPERTY(bool enableVsync MEMBER enableVsync NOTIFY enableVsyncChanged)
Q_PROPERTY(bool gameOptimizations MEMBER gameOptimizations NOTIFY gameOptimizationsChanged)
Q_PROPERTY(bool spatialHeadTracking MEMBER spatialHeadTracking NOTIFY spatialHeadTrackingChanged)
Q_PROPERTY(bool playAudioOnHost MEMBER playAudioOnHost NOTIFY playAudioOnHostChanged)
Q_PROPERTY(bool multiController MEMBER multiController NOTIFY multiControllerChanged)
Q_PROPERTY(bool enableMdns MEMBER enableMdns NOTIFY enableMdnsChanged)
Expand All @@ -125,6 +133,7 @@ class StreamingPreferences : public QObject
Q_PROPERTY(bool detectNetworkBlocking MEMBER detectNetworkBlocking NOTIFY detectNetworkBlockingChanged)
Q_PROPERTY(bool showPerformanceOverlay MEMBER showPerformanceOverlay NOTIFY showPerformanceOverlayChanged)
Q_PROPERTY(AudioConfig audioConfig MEMBER audioConfig NOTIFY audioConfigChanged)
Q_PROPERTY(SpatialAudioConfig spatialAudioConfig MEMBER spatialAudioConfig NOTIFY spatialAudioConfigChanged)
Q_PROPERTY(VideoCodecConfig videoCodecConfig MEMBER videoCodecConfig NOTIFY videoCodecConfigChanged)
Q_PROPERTY(bool enableHdr MEMBER enableHdr NOTIFY enableHdrChanged)
Q_PROPERTY(bool enableYUV444 MEMBER enableYUV444 NOTIFY enableYUV444Changed)
Expand All @@ -151,6 +160,7 @@ class StreamingPreferences : public QObject
bool unlockBitrate;
bool enableVsync;
bool gameOptimizations;
bool spatialHeadTracking;
bool playAudioOnHost;
bool multiController;
bool enableMdns;
Expand All @@ -171,6 +181,7 @@ class StreamingPreferences : public QObject
bool keepAwake;
int packetSize;
AudioConfig audioConfig;
SpatialAudioConfig spatialAudioConfig;
VideoCodecConfig videoCodecConfig;
bool enableHdr;
bool enableYUV444;
Expand All @@ -187,6 +198,7 @@ class StreamingPreferences : public QObject
void unlockBitrateChanged();
void enableVsyncChanged();
void gameOptimizationsChanged();
void spatialHeadTrackingChanged();
void playAudioOnHostChanged();
void multiControllerChanged();
void unsupportedFpsChanged();
Expand All @@ -195,6 +207,7 @@ class StreamingPreferences : public QObject
void absoluteMouseModeChanged();
void absoluteTouchModeChanged();
void audioConfigChanged();
void spatialAudioConfigChanged();
void videoCodecConfigChanged();
void enableHdrChanged();
void enableYUV444Changed();
Expand Down
24 changes: 20 additions & 4 deletions app/streaming/audio/audio.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
#include "renderers/slaud.h"
#endif

#ifdef HAVE_COREAUDIO
#include "renderers/coreaudio/coreaudio.h"
#endif

#include "renderers/sdl.h"

#include <Limelight.h>
Expand All @@ -29,6 +33,12 @@ IAudioRenderer* Session::createAudioRenderer(const POPUS_MULTISTREAM_CONFIGURATI
TRY_INIT_RENDERER(SdlAudioRenderer, opusConfig)
return nullptr;
}
#ifdef HAVE_COREAUDIO
else if (mlAudio == "coreaudio") {
TRY_INIT_RENDERER(CoreAudioRenderer, opusConfig)
return nullptr;
}
#endif
#ifdef HAVE_SOUNDIO
else if (mlAudio == "libsoundio") {
TRY_INIT_RENDERER(SoundIoAudioRenderer, opusConfig)
Expand All @@ -55,6 +65,11 @@ IAudioRenderer* Session::createAudioRenderer(const POPUS_MULTISTREAM_CONFIGURATI
TRY_INIT_RENDERER(SLAudioRenderer, opusConfig)
#endif

#ifdef HAVE_COREAUDIO
// Native renderer for macOS/iOS/tvOS, suports spatial audio
TRY_INIT_RENDERER(CoreAudioRenderer, opusConfig)
#endif

// Default to SDL and use libsoundio as a fallback
TRY_INIT_RENDERER(SdlAudioRenderer, opusConfig)
#ifdef HAVE_SOUNDIO
Expand Down Expand Up @@ -261,7 +276,11 @@ void Session::arDecodeAndPlaySample(char* sampleData, int sampleLength)
s_ActiveSession->m_AudioRenderer->flipAudioStatsWindows();
}

if (!s_ActiveSession->m_AudioRenderer->submitAudio(desiredBufferSize)) {
if (s_ActiveSession->m_AudioRenderer->submitAudio(desiredBufferSize)) {
// keep stats on how long the audio pipline took to execute
s_ActiveSession->m_AudioRenderer->statsTrackDecodeTime(startTimeUs);
}
else {
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION,
"Reinitializing audio renderer after failure");

Expand All @@ -271,9 +290,6 @@ void Session::arDecodeAndPlaySample(char* sampleData, int sampleLength)
delete s_ActiveSession->m_AudioRenderer;
s_ActiveSession->m_AudioRenderer = nullptr;
}

// keep stats on how long the audio pipline took to execute
s_ActiveSession->m_AudioRenderer->statsTrackDecodeTime(startTimeUs);
}

// Only try to recreate the audio renderer every 200 samples (1 second)
Expand Down
57 changes: 57 additions & 0 deletions app/streaming/audio/renderers/coreaudio/AllocatedAudioBufferList.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
Copyright © 2024 Apple Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
#pragma once

#include <AudioToolbox/AudioToolbox.h>

class AllocatedAudioBufferList
{
public:
AllocatedAudioBufferList(UInt32 channelCount, uint16_t bufferSize)
{

mBufferList = static_cast<AudioBufferList *>(malloc(sizeof(AudioBufferList) + (sizeof(AudioBuffer) * channelCount)));
mBufferList->mNumberBuffers = channelCount;
for (UInt32 c = 0; c < channelCount; ++c) {
mBufferList->mBuffers[c].mNumberChannels = 1;
mBufferList->mBuffers[c].mDataByteSize = bufferSize * sizeof(float);
mBufferList->mBuffers[c].mData = malloc(sizeof(float) * bufferSize);
}
}

AllocatedAudioBufferList(const AllocatedAudioBufferList&) = delete;

AllocatedAudioBufferList& operator=(const AllocatedAudioBufferList&) = delete;

~AllocatedAudioBufferList()
{
if (mBufferList == nullptr) { return; }

for (UInt32 i = 0; i < mBufferList->mNumberBuffers; ++i) {
free(mBufferList->mBuffers[i].mData);
}
free(mBufferList);
mBufferList = nullptr;
}

AudioBufferList * _Nonnull get()
{
return mBufferList;
}

private:
AudioBufferList * _Nonnull mBufferList = { nullptr };
};
Loading

0 comments on commit 2660e79

Please sign in to comment.