From 8a1870ab1e8a84cd7ad06d32636b44d46ec9920d Mon Sep 17 00:00:00 2001 From: mthwJsmith Date: Wed, 25 Mar 2026 13:19:17 +0000 Subject: [PATCH 01/10] Add WASAPI loopback capture, per-process audio capture, and connection UX improvements - WASAPI Loopback: Render devices now appear as "(Loopback)" input devices in JUCE's WASAPI backend, allowing SonoBus to capture desktop/game audio directly without Voicemeeter or virtual audio cables. Lossless PCM capture using AUDCLNT_STREAMFLAGS_LOOPBACK in shared mode. - Per-Process Capture: New ProcessAudioCapture class using Windows 11's AUDIOCLIENT_PROCESS_LOOPBACK_PARAMS API to capture audio from a specific process (e.g. a game). Includes runtime OS version check and process enumeration. - Save Direct Connect Address: Last used direct connect IP:port is now persisted across sessions and pre-filled in the connect dialog. - Auto-Reconnect Direct Peer: The existing "Reconnect Last" option now also reconnects to the last direct peer connection on startup. Addresses: https://github.com/sonosaurus/sonobus/issues/241 Addresses: https://github.com/sonosaurus/sonobus/issues/53 --- CMakeLists.txt | 2 + Source/ConnectView.cpp | 6 +- Source/ProcessAudioCapture.cpp | 399 ++++++++++++++++++ Source/ProcessAudioCapture.h | 75 ++++ Source/SonobusPluginProcessor.cpp | 24 +- Source/SonobusPluginProcessor.h | 4 + audio.md | 110 +++++ .../native/juce_WASAPI_windows.cpp | 95 ++++- 8 files changed, 689 insertions(+), 26 deletions(-) create mode 100644 Source/ProcessAudioCapture.cpp create mode 100644 Source/ProcessAudioCapture.h create mode 100644 audio.md diff --git a/CMakeLists.txt b/CMakeLists.txt index a8e35c0ec..8165b39d2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -321,6 +321,8 @@ function(sono_add_custom_plugin_target target_name product_name formats is_instr Source/SonobusPluginEditor.h Source/SonobusPluginProcessor.cpp Source/SonobusPluginProcessor.h + Source/ProcessAudioCapture.cpp + Source/ProcessAudioCapture.h Source/SonobusTypes.h Source/SuggestNewGroupView.cpp Source/SuggestNewGroupView.h diff --git a/Source/ConnectView.cpp b/Source/ConnectView.cpp index 7458703f2..568f1883c 100644 --- a/Source/ConnectView.cpp +++ b/Source/ConnectView.cpp @@ -92,7 +92,9 @@ publicGroupsListModel(this) mAddRemoteHostEditor = std::make_unique("remaddredit"); mAddRemoteHostEditor->setTitle(TRANS("Remote Host:Port")); mAddRemoteHostEditor->setFont(Font(16 * SonoLookAndFeel::getFontScale())); - mAddRemoteHostEditor->setText("", false); // 100.36.128.246:11000 + // Pre-fill with last used direct connect address + String lastAddr = processor.getLastDirectConnectAddress(); + mAddRemoteHostEditor->setText(lastAddr, false); mAddRemoteHostEditor->setTextToShowWhenEmpty(TRANS("IPaddress:port"), Colour(0x44ffffff)); @@ -1006,6 +1008,8 @@ void ConnectView::buttonClicked (Button* buttonThatWasClicked) if (host.isNotEmpty() && port != 0) { if (processor.connectRemotePeer(host, port, "", "", processor.getValueTreeState().getParameter(SonobusAudioProcessor::paramMainRecvMute)->getValue() == 0)) { + // Save last used direct connect address + processor.setLastDirectConnectAddress(hostport); setVisible(false); if (auto * callout = dynamic_cast(directConnectCalloutBox.get())) { callout->dismiss(); diff --git a/Source/ProcessAudioCapture.cpp b/Source/ProcessAudioCapture.cpp new file mode 100644 index 000000000..fc3550fe5 --- /dev/null +++ b/Source/ProcessAudioCapture.cpp @@ -0,0 +1,399 @@ +// SPDX-License-Identifier: GPLv3-or-later WITH Appstore-exception +// Copyright (C) 2026 + +#include "ProcessAudioCapture.h" + +#if JUCE_WINDOWS + +#include +#include + +// Forward declarations for Windows 11 per-process loopback API +// These are defined in audioclientactivationparams.h (Windows 11 SDK) +// We define them here to support building with older SDKs + +#ifndef AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK +#define AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK 1 + +typedef enum PROCESS_LOOPBACK_MODE +{ + PROCESS_LOOPBACK_MODE_INCLUDE_TARGET_PROCESS_TREE = 0, + PROCESS_LOOPBACK_MODE_EXCLUDE_TARGET_PROCESS_TREE = 1 +} PROCESS_LOOPBACK_MODE; + +typedef struct AUDIOCLIENT_PROCESS_LOOPBACK_PARAMS +{ + DWORD TargetProcessId; + PROCESS_LOOPBACK_MODE ProcessLoopbackMode; +} AUDIOCLIENT_PROCESS_LOOPBACK_PARAMS; + +typedef enum AUDIOCLIENT_ACTIVATION_TYPE +{ + AUDIOCLIENT_ACTIVATION_TYPE_DEFAULT = 0, + AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK_TYPE = 1 +} AUDIOCLIENT_ACTIVATION_TYPE; + +typedef struct AUDIOCLIENT_ACTIVATION_PARAMS +{ + AUDIOCLIENT_ACTIVATION_TYPE ActivationType; + union + { + AUDIOCLIENT_PROCESS_LOOPBACK_PARAMS ProcessLoopbackParams; + }; +} AUDIOCLIENT_ACTIVATION_PARAMS; + +#endif // AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK + +// Virtual audio device path for process loopback +static const LPCWSTR VIRTUAL_AUDIO_DEVICE_PROCESS_LOOPBACK = L"VAD\\Process_Loopback"; + +//============================================================================== +// IActivateAudioInterfaceCompletionHandler implementation +class LoopbackActivationHandler : public IActivateAudioInterfaceCompletionHandler +{ +public: + LoopbackActivationHandler() + { + completionEvent = CreateEvent (nullptr, TRUE, FALSE, nullptr); + } + + ~LoopbackActivationHandler() + { + if (completionEvent != nullptr) + CloseHandle (completionEvent); + } + + // IUnknown + ULONG STDMETHODCALLTYPE AddRef() override { return InterlockedIncrement (&refCount); } + ULONG STDMETHODCALLTYPE Release() override + { + auto count = InterlockedDecrement (&refCount); + if (count == 0) + delete this; + return count; + } + + HRESULT STDMETHODCALLTYPE QueryInterface (REFIID riid, void** ppvObject) override + { + if (riid == __uuidof (IUnknown) || riid == __uuidof (IActivateAudioInterfaceCompletionHandler)) + { + *ppvObject = static_cast (this); + AddRef(); + return S_OK; + } + *ppvObject = nullptr; + return E_NOINTERFACE; + } + + // IActivateAudioInterfaceCompletionHandler + HRESULT STDMETHODCALLTYPE ActivateCompleted (IActivateAudioInterfaceAsyncOperation* operation) override + { + HRESULT hrActivateResult = E_FAIL; + IUnknown* activatedInterface = nullptr; + + HRESULT hr = operation->GetActivateResult (&hrActivateResult, &activatedInterface); + + if (SUCCEEDED (hr) && SUCCEEDED (hrActivateResult) && activatedInterface != nullptr) + { + activatedInterface->QueryInterface (__uuidof (IAudioClient), (void**) &resultClient); + } + + activateResult = hrActivateResult; + SetEvent (completionEvent); + return S_OK; + } + + bool waitForCompletion (DWORD timeoutMs = 5000) + { + return WaitForSingleObject (completionEvent, timeoutMs) == WAIT_OBJECT_0; + } + + IAudioClient* getClient() { return resultClient; } + HRESULT getResult() const { return activateResult; } + +private: + LONG refCount = 1; + HANDLE completionEvent = nullptr; + IAudioClient* resultClient = nullptr; + HRESULT activateResult = E_FAIL; +}; + +//============================================================================== +ProcessAudioCapture::~ProcessAudioCapture() +{ + stopCapture(); +} + +bool ProcessAudioCapture::isSupported() +{ + // Check if ActivateAudioInterfaceAsync is available (Win8+) + // and if the process loopback feature works (Win10 20348+ / Win11) + HMODULE mmdevapi = GetModuleHandleW (L"mmdevapi.dll"); + if (mmdevapi == nullptr) + mmdevapi = LoadLibraryW (L"mmdevapi.dll"); + + if (mmdevapi == nullptr) + return false; + + auto activateFunc = GetProcAddress (mmdevapi, "ActivateAudioInterfaceAsync"); + if (activateFunc == nullptr) + return false; + + // Check Windows version - need build 20348+ + OSVERSIONINFOEXW osvi = {}; + osvi.dwOSVersionInfoSize = sizeof (osvi); + + using RtlGetVersionFunc = NTSTATUS (WINAPI*)(PRTL_OSVERSIONINFOW); + auto ntdll = GetModuleHandleW (L"ntdll.dll"); + if (ntdll != nullptr) + { + auto rtlGetVersion = reinterpret_cast (GetProcAddress (ntdll, "RtlGetVersion")); + if (rtlGetVersion != nullptr) + { + rtlGetVersion (reinterpret_cast (&osvi)); + // Build 20348 is the minimum for process loopback + return osvi.dwBuildNumber >= 20348; + } + } + + return false; +} + +Array ProcessAudioCapture::getAudioProcesses() +{ + Array result; + + // Get all running processes + HANDLE snapshot = CreateToolhelp32Snapshot (TH32CS_SNAPPROCESS, 0); + if (snapshot == INVALID_HANDLE_VALUE) + return result; + + PROCESSENTRY32W pe; + pe.dwSize = sizeof (pe); + + if (Process32FirstW (snapshot, &pe)) + { + do + { + // Skip system processes + if (pe.th32ProcessID == 0 || pe.th32ProcessID == 4) + continue; + + String name = String (pe.szExeFile); + + // Skip known non-audio system processes + if (name.equalsIgnoreCase ("svchost.exe") + || name.equalsIgnoreCase ("csrss.exe") + || name.equalsIgnoreCase ("smss.exe") + || name.equalsIgnoreCase ("lsass.exe") + || name.equalsIgnoreCase ("services.exe") + || name.equalsIgnoreCase ("wininit.exe") + || name.equalsIgnoreCase ("winlogon.exe") + || name.equalsIgnoreCase ("dwm.exe") + || name.equalsIgnoreCase ("System")) + continue; + + ProcessInfo info; + info.pid = pe.th32ProcessID; + info.name = name; + info.displayName = name + " (PID " + String (pe.th32ProcessID) + ")"; + result.add (info); + + } while (Process32NextW (snapshot, &pe)); + } + + CloseHandle (snapshot); + + // Sort by name + result.sort ([] (const ProcessInfo& a, const ProcessInfo& b) { + return a.name.compareIgnoreCase (b.name); + }); + + return result; +} + +bool ProcessAudioCapture::startCapture (DWORD processId, double sampleRate, int numChannels, int bufferSize) +{ + if (! isSupported()) + return false; + + stopCapture(); + + // Set up activation params for process loopback + AUDIOCLIENT_ACTIVATION_PARAMS activationParams = {}; + activationParams.ActivationType = AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK_TYPE; + activationParams.ProcessLoopbackParams.TargetProcessId = processId; + activationParams.ProcessLoopbackParams.ProcessLoopbackMode = PROCESS_LOOPBACK_MODE_INCLUDE_TARGET_PROCESS_TREE; + + PROPVARIANT activateParamsPropVariant = {}; + activateParamsPropVariant.vt = VT_BLOB; + activateParamsPropVariant.blob.cbSize = sizeof (activationParams); + activateParamsPropVariant.blob.pBlobData = reinterpret_cast (&activationParams); + + // Create completion handler + auto handler = new LoopbackActivationHandler(); + IActivateAudioInterfaceAsyncOperation* asyncOp = nullptr; + + HRESULT hr = ActivateAudioInterfaceAsync ( + VIRTUAL_AUDIO_DEVICE_PROCESS_LOOPBACK, + __uuidof (IAudioClient), + &activateParamsPropVariant, + handler, + &asyncOp); + + if (FAILED (hr)) + { + handler->Release(); + if (asyncOp) asyncOp->Release(); + return false; + } + + // Wait for async activation to complete + if (! handler->waitForCompletion (5000)) + { + handler->Release(); + if (asyncOp) asyncOp->Release(); + return false; + } + + if (FAILED (handler->getResult()) || handler->getClient() == nullptr) + { + handler->Release(); + if (asyncOp) asyncOp->Release(); + return false; + } + + audioClient = handler->getClient(); + audioClient->AddRef(); // Take ownership + + handler->Release(); + if (asyncOp) asyncOp->Release(); + + // Get mix format + WAVEFORMATEX* mixFormat = nullptr; + hr = audioClient->GetMixFormat (&mixFormat); + if (FAILED (hr) || mixFormat == nullptr) + { + audioClient->Release(); + audioClient = nullptr; + return false; + } + + captureSampleRate = mixFormat->nSamplesPerSec; + captureNumChannels = mixFormat->nChannels; + + // Initialize in shared mode (required for loopback) + REFERENCE_TIME bufferDuration = 10000000LL; // 1 second buffer + hr = audioClient->Initialize ( + AUDCLNT_SHAREMODE_SHARED, + AUDCLNT_STREAMFLAGS_LOOPBACK | AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM | AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY, + bufferDuration, + 0, + mixFormat, + nullptr); + + CoTaskMemFree (mixFormat); + + if (FAILED (hr)) + { + audioClient->Release(); + audioClient = nullptr; + return false; + } + + // Get capture client + hr = audioClient->GetService (__uuidof (IAudioCaptureClient), (void**) &captureClient); + if (FAILED (hr)) + { + audioClient->Release(); + audioClient = nullptr; + return false; + } + + // Start capturing + hr = audioClient->Start(); + if (FAILED (hr)) + { + captureClient->Release(); + captureClient = nullptr; + audioClient->Release(); + audioClient = nullptr; + return false; + } + + capturing.store (true); + return true; +} + +void ProcessAudioCapture::stopCapture() +{ + capturing.store (false); + + if (audioClient != nullptr) + { + audioClient->Stop(); + } + + if (captureClient != nullptr) + { + captureClient->Release(); + captureClient = nullptr; + } + + if (audioClient != nullptr) + { + audioClient->Release(); + audioClient = nullptr; + } +} + +int ProcessAudioCapture::readSamples (AudioBuffer& buffer, int numFrames) +{ + if (! capturing.load() || captureClient == nullptr) + return 0; + + int framesRead = 0; + + while (framesRead < numFrames) + { + UINT32 packetLength = 0; + HRESULT hr = captureClient->GetNextPacketSize (&packetLength); + + if (FAILED (hr) || packetLength == 0) + break; + + BYTE* data = nullptr; + UINT32 numFramesAvailable = 0; + DWORD flags = 0; + + hr = captureClient->GetBuffer (&data, &numFramesAvailable, &flags, nullptr, nullptr); + if (FAILED (hr)) + break; + + int framesToCopy = jmin ((int) numFramesAvailable, numFrames - framesRead); + + if (flags & AUDCLNT_BUFFERFLAGS_SILENT) + { + // Fill with silence + for (int ch = 0; ch < buffer.getNumChannels() && ch < captureNumChannels; ++ch) + FloatVectorOperations::clear (buffer.getWritePointer (ch, framesRead), framesToCopy); + } + else if (data != nullptr) + { + // Convert interleaved float data to JUCE buffer + const float* src = reinterpret_cast (data); + for (int frame = 0; frame < framesToCopy; ++frame) + { + for (int ch = 0; ch < buffer.getNumChannels() && ch < captureNumChannels; ++ch) + buffer.getWritePointer (ch)[framesRead + frame] = src[frame * captureNumChannels + ch]; + } + } + + framesRead += framesToCopy; + captureClient->ReleaseBuffer (numFramesAvailable); + } + + return framesRead; +} + +#endif // JUCE_WINDOWS diff --git a/Source/ProcessAudioCapture.h b/Source/ProcessAudioCapture.h new file mode 100644 index 000000000..60e38671e --- /dev/null +++ b/Source/ProcessAudioCapture.h @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: GPLv3-or-later WITH Appstore-exception +// Copyright (C) 2026 + +#pragma once + +#include "JuceHeader.h" + +#if JUCE_WINDOWS + +#include +#include +#include +#include + +//============================================================================== +/** + Per-process audio capture using Windows 11's AUDIOCLIENT_PROCESS_LOOPBACK_PARAMS. + + This captures audio from a specific Windows process (e.g. a game) without needing + a virtual audio cable or loopback driver. + + Requires Windows 10 Build 20348+ / Windows 11. +*/ +class ProcessAudioCapture +{ +public: + ProcessAudioCapture() = default; + ~ProcessAudioCapture(); + + //============================================================================== + struct ProcessInfo + { + DWORD pid; + String name; // e.g. "SnowRunner.exe" + String displayName; // e.g. "SnowRunner.exe (PID 1234)" + }; + + /** Returns a list of currently running processes that have active audio sessions. */ + static Array getAudioProcesses(); + + /** Returns true if the per-process capture API is available on this OS version. */ + static bool isSupported(); + + //============================================================================== + /** Start capturing audio from the given process ID. + Returns true on success. */ + bool startCapture (DWORD processId, double sampleRate, int numChannels, int bufferSize); + + /** Stop capturing. */ + void stopCapture(); + + /** Returns true if currently capturing. */ + bool isCapturing() const { return capturing.load(); } + + /** Read captured samples into the provided buffer. + Returns the number of frames actually read. */ + int readSamples (AudioBuffer& buffer, int numFrames); + + /** Get the capture format info. */ + double getSampleRate() const { return captureSampleRate; } + int getNumChannels() const { return captureNumChannels; } + +private: + std::atomic capturing { false }; + double captureSampleRate = 0; + int captureNumChannels = 0; + + // COM pointers managed via raw pointers with Release() in destructor + IAudioClient* audioClient = nullptr; + IAudioCaptureClient* captureClient = nullptr; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (ProcessAudioCapture) +}; + +#endif // JUCE_WINDOWS diff --git a/Source/SonobusPluginProcessor.cpp b/Source/SonobusPluginProcessor.cpp index f67bcfd48..9f0b105fb 100644 --- a/Source/SonobusPluginProcessor.cpp +++ b/Source/SonobusPluginProcessor.cpp @@ -112,6 +112,7 @@ static String lastWindowWidthKey("lastWindowWidth"); static String lastWindowHeightKey("lastWindowHeight"); static String autoresizeDropRateThreshKey("autoDropRateThreshNew"); static String reconnectServerLossKey("reconnServLoss"); +static String lastDirectConnectAddressKey("lastDirectConnectAddr"); static String compressorStateKey("CompressorState"); static String expanderStateKey("ExpanderState"); @@ -8545,6 +8546,7 @@ void SonobusAudioProcessor::getStateInformationWithOptions(MemoryBlock& destData extraTree.setProperty(lastWindowHeightKey, var((int)mPluginWindowHeight), nullptr); extraTree.setProperty(autoresizeDropRateThreshKey, var((float)mAutoresizeDropRateThresh), nullptr); extraTree.setProperty(reconnectServerLossKey, mReconnectAfterServerLoss.get(), nullptr); + extraTree.setProperty(lastDirectConnectAddressKey, mLastDirectConnectAddress, nullptr); extraTree.appendChild(mVideoLinkInfo.getValueTree(), nullptr); @@ -8716,7 +8718,8 @@ void SonobusAudioProcessor::setStateInformationWithOptions (const void* data, in setReconnectAfterServerLoss(extraTree.getProperty(reconnectServerLossKey, mReconnectAfterServerLoss.get())); - + mLastDirectConnectAddress = extraTree.getProperty(lastDirectConnectAddressKey, "").toString(); + ValueTree videoinfo = extraTree.getChildWithName(videoLinkInfoKey); if (videoinfo.isValid()) { mVideoLinkInfo.setFromValueTree(videoinfo); @@ -8846,9 +8849,26 @@ void SonobusAudioProcessor::ServerReconnectTimer::timerCallback() bool SonobusAudioProcessor::reconnectToMostRecent() { + // Try reconnecting to last direct peer connection first + if (mLastDirectConnectAddress.isNotEmpty()) { + StringArray toks = StringArray::fromTokens(mLastDirectConnectAddress, ":/ ", ""); + String host; + int port = 11000; + + if (toks.size() >= 1) host = toks[0].trim(); + if (toks.size() >= 2) port = toks[1].trim().getIntValue(); + + if (host.isNotEmpty() && port != 0) { + DBG("Reconnecting to direct peer: " << host << ":" << port); + connectRemotePeer(host, port, "", "", true); + return true; + } + } + + // Otherwise try reconnecting to last server/group Array recents; getRecentServerConnectionInfos(recents); - + if (recents.size() > 0) { const auto & info = recents.getReference(0); diff --git a/Source/SonobusPluginProcessor.h b/Source/SonobusPluginProcessor.h index 0ddcbb680..45b901307 100644 --- a/Source/SonobusPluginProcessor.h +++ b/Source/SonobusPluginProcessor.h @@ -315,6 +315,9 @@ class SonobusAudioProcessor : public AudioProcessor, public AudioProcessorValue int getRecentServerConnectionInfos(Array & retarray); void clearRecentServerConnectionInfos(); + void setLastDirectConnectAddress(const String & address) { mLastDirectConnectAddress = address; } + String getLastDirectConnectAddress() const { return mLastDirectConnectAddress; } + bool setCurrentUsername(const String & name); String getCurrentUsername() const { return mCurrentUsername; } @@ -1122,6 +1125,7 @@ class SonobusAudioProcessor : public AudioProcessor, public AudioProcessorValue Array mRecentConnectionInfos; CriticalSection mRecentsLock; + String mLastDirectConnectAddress; AooServerConnectionInfo mPendingReconnectInfo; bool mPendingReconnect = false; diff --git a/audio.md b/audio.md new file mode 100644 index 000000000..1f3d181b1 --- /dev/null +++ b/audio.md @@ -0,0 +1,110 @@ +# SonoBus Fork — Audio Improvements Plan + +## Goal +Fork SonoBus to add WASAPI loopback capture, per-process audio capture, and UX improvements for a direct ethernet audio streaming setup (two PCs, 192.168.1.x, one-way lossless audio → Apple USB-C DAC → amp → speakers). + +## Fork +- **Upstream**: https://github.com/sonosaurus/sonobus +- **Fork**: https://github.com/mthwJsmith/sonobus +- **Branch**: `feature/wasapi-loopback-and-improvements` + +## Features + +### 1. Save Direct Connect Address — DONE +- Saves last used IP:port to processor state (extraState tree) +- Pre-fills the direct connect text field on next launch +- Files changed: `SonobusPluginProcessor.h`, `SonobusPluginProcessor.cpp`, `ConnectView.cpp` + +### 2. WASAPI Loopback Capture — DONE +- Eliminates need for Voicemeeter/VB-Cable entirely +- Adds render devices as "(Loopback)" input devices in JUCE's WASAPI backend +- SonoBus can directly capture desktop/game audio (lossless PCM) +- File: `deps/juce/modules/juce_audio_devices/native/juce_WASAPI_windows.cpp` +- Changes: + - Added loopback device ID helpers (`JUCE_LOOPBACK::` prefix scheme) + - Added `isLoopbackDevice` flag to `WASAPIDeviceBase` + - Modified `getStreamFlags()` to add `AUDCLNT_STREAMFLAGS_LOOPBACK` (0x00020000) + - Modified `scan()` to enumerate render devices as loopback inputs with "(Loopback)" suffix + - Modified `createDevices()` to detect loopback IDs and open render endpoint as capture + - Modified `initialiseStandardClient()` to force shared mode for loopback + - Modified `tryFormat()`, `findSupportedFormat()`, `querySupportedSampleRates()` to force shared mode for loopback + - `WASAPIInputDevice` constructor accepts loopback flag + +### 3. Per-Process Audio Capture (Win11 API) — DONE +- New files: `Source/ProcessAudioCapture.h`, `Source/ProcessAudioCapture.cpp` +- Uses `AUDIOCLIENT_PROCESS_LOOPBACK_PARAMS` + `ActivateAudioInterfaceAsync` +- Captures audio from a specific app (e.g. SnowRunner.exe) instead of all system audio +- No special permissions needed, no drivers +- Windows 10 Build 20348+ / Windows 11 +- Includes runtime API availability check (`isSupported()`) +- Includes process enumeration (`getAudioProcesses()`) +- Standalone capture class — not wired into JUCE device enumeration yet +- **TODO**: Wire into SonoBus UI (process picker dropdown in input settings) + +### 4. Auto-Reconnect Direct Peer — DONE +- Extended existing `reconnectToMostRecent()` to also try last direct peer connection +- If saved direct connect address exists, reconnects to it on startup +- Falls back to server/group reconnect if no direct address saved +- Uses the existing "Reconnect Last" checkbox in options + +### 5. Update JUCE to sono8good — PENDING +- Current: essej/JUCE `sono7good` branch +- Target: essej/JUCE `sono8good` branch (JUCE 8, updated Jan 2026) +- Should be done carefully — reapply loopback changes on top of new base + +### 6. Push & Build Verification — PENDING +- Push to fork, verify CMake + VS2022 build on Windows + +## Architecture Notes + +### WASAPI Loopback (how it works) +- `IAudioClient::Initialize` with `AUDCLNT_STREAMFLAGS_LOOPBACK` flag (0x00020000) +- Must use `AUDCLNT_SHAREMODE_SHARED` (exclusive mode not supported) +- Captures post-mix PCM audio digitally before DAC — completely lossless +- Event-driven supported on Win10 1703+ +- Device enumeration: render endpoints show up as capture sources with "(Loopback)" suffix +- Loopback device IDs use `JUCE_LOOPBACK::` prefix to distinguish from regular capture devices + +### Per-Process Capture (how it works) +- `ActivateAudioInterfaceAsync` with `AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK` +- `AUDIOCLIENT_PROCESS_LOOPBACK_PARAMS` specifies target PID +- Include mode: capture only that process's audio +- Exclude mode: capture everything except that process +- Async initialization (callback-based), different from normal WASAPI flow +- Not tied to specific audio endpoint — captures from all endpoints where process renders +- Requires Windows 10 Build 20348+ (checked at runtime) + +### SonoBus Network Protocol +- Audio over OSC (AoO) — UDP peer-to-peer +- Supports PCM 16/24/32-bit and Opus codec +- Direct connect: `processor.connectRemotePeer(host, port, ...)` + +## Build +```bash +cmake -B build -G "Visual Studio 17 2022" +cmake --build build --config Release +``` +Requires: CMake 3.15+, Visual Studio 2022, Windows SDK + +## Key Files +- `Source/ConnectView.cpp` — direct connect UI, saves last address +- `Source/SonobusPluginProcessor.cpp` — state save/load, connection logic, auto-reconnect +- `Source/ProcessAudioCapture.cpp/.h` — per-process audio capture (Win11 API) +- `deps/juce/.../juce_WASAPI_windows.cpp` — WASAPI device enumeration & loopback capture +- `deps/aoo/` — Audio over OSC networking library +- `CMakeLists.txt` — build config (ProcessAudioCapture added) + +## Files Changed Summary +``` +Modified: + CMakeLists.txt — added ProcessAudioCapture to build + Source/ConnectView.cpp — save/load direct connect address + Source/SonobusPluginProcessor.cpp — persist address, auto-reconnect direct peer + Source/SonobusPluginProcessor.h — lastDirectConnectAddress member + accessors + deps/juce/.../juce_WASAPI_windows.cpp — WASAPI loopback capture support + +New: + Source/ProcessAudioCapture.cpp — Win11 per-process audio capture + Source/ProcessAudioCapture.h — header for above + audio.md — this file +``` diff --git a/deps/juce/modules/juce_audio_devices/native/juce_WASAPI_windows.cpp b/deps/juce/modules/juce_audio_devices/native/juce_WASAPI_windows.cpp index c92548694..bb7af3c0e 100644 --- a/deps/juce/modules/juce_audio_devices/native/juce_WASAPI_windows.cpp +++ b/deps/juce/modules/juce_audio_devices/native/juce_WASAPI_windows.cpp @@ -415,12 +415,30 @@ static bool supportsSampleRateConversion (WASAPIDeviceMode deviceMode) noexcept } //============================================================================== +static const String loopbackDeviceIdPrefix ("JUCE_LOOPBACK::"); + +static bool isLoopbackDeviceId (const String& deviceId) +{ + return deviceId.startsWith (loopbackDeviceIdPrefix); +} + +static String makeLoopbackDeviceId (const String& renderDeviceId) +{ + return loopbackDeviceIdPrefix + renderDeviceId; +} + +static String getRenderDeviceIdFromLoopback (const String& loopbackDeviceId) +{ + return loopbackDeviceId.fromFirstOccurrenceOf (loopbackDeviceIdPrefix, false, false); +} + class WASAPIDeviceBase { public: - WASAPIDeviceBase (const ComSmartPtr& d, WASAPIDeviceMode mode) + WASAPIDeviceBase (const ComSmartPtr& d, WASAPIDeviceMode mode, bool loopback = false) : device (d), - deviceMode (mode) + deviceMode (mode), + isLoopbackDevice (loopback) { clientEvent = CreateEvent (nullptr, false, false, nullptr); @@ -439,7 +457,7 @@ class WASAPIDeviceBase rates.addUsingDefaultSort (defaultSampleRate); defaultFormatChannelMask = format->dwChannelMask; - if (isExclusiveMode (deviceMode)) + if (isExclusiveMode (deviceMode) && ! isLoopbackDevice) if (auto optFormat = findSupportedFormat (tempClient, defaultNumChannels, defaultSampleRate)) format = optFormat; @@ -542,6 +560,7 @@ class WASAPIDeviceBase ComSmartPtr client; WASAPIDeviceMode deviceMode; + bool isLoopbackDevice = false; double sampleRate = 0, defaultSampleRate = 0; int numChannels = 0, actualNumChannels = 0, maxNumChannels = 0, defaultNumChannels = 0; @@ -699,11 +718,12 @@ class WASAPIDeviceBase WAVEFORMATEX* nearestFormat = nullptr; - if (SUCCEEDED (audioClient->IsFormatSupported (isExclusiveMode (deviceMode) ? AUDCLNT_SHAREMODE_EXCLUSIVE - : AUDCLNT_SHAREMODE_SHARED, + const bool useExclusiveSR = isExclusiveMode (deviceMode) && ! isLoopbackDevice; + if (SUCCEEDED (audioClient->IsFormatSupported (useExclusiveSR ? AUDCLNT_SHAREMODE_EXCLUSIVE + : AUDCLNT_SHAREMODE_SHARED, (WAVEFORMATEX*) &format, - isExclusiveMode (deviceMode) ? nullptr - : &nearestFormat))) + useExclusiveSR ? nullptr + : &nearestFormat))) { if (nearestFormat != nullptr) rate = (double) nearestFormat->nSamplesPerSec; @@ -736,7 +756,7 @@ class WASAPIDeviceBase static std::optional tryFormat (const AudioSampleFormat sampleFormat, IAudioClient* clientToUse, WASAPIDeviceMode mode, int newNumChannels, double newSampleRate, - DWORD newMixFormatChannelMask) + DWORD newMixFormatChannelMask, bool loopback = false) { WAVEFORMATEXTENSIBLE format; zerostruct (format); @@ -762,11 +782,12 @@ class WASAPIDeviceBase WAVEFORMATEX* nearestFormat = nullptr; - HRESULT hr = clientToUse->IsFormatSupported (isExclusiveMode (mode) ? AUDCLNT_SHAREMODE_EXCLUSIVE - : AUDCLNT_SHAREMODE_SHARED, + const bool useExclusive = isExclusiveMode (mode) && ! loopback; + HRESULT hr = clientToUse->IsFormatSupported (useExclusive ? AUDCLNT_SHAREMODE_EXCLUSIVE + : AUDCLNT_SHAREMODE_SHARED, (WAVEFORMATEX*) &format, - isExclusiveMode (mode) ? nullptr - : &nearestFormat); + useExclusive ? nullptr + : &nearestFormat); logFailure (hr); auto supportsSRC = supportsSampleRateConversion (mode); @@ -803,7 +824,7 @@ class WASAPIDeviceBase auto mixFormatChannelMask = (ch == defaultNumChannels ? defaultFormatChannelMask : maskWithLowestNBitsSet); for (auto const& sampleFormat: formatsToTry) - if (auto format = tryFormat (sampleFormat, clientToUse, deviceMode, ch, newSampleRate, mixFormatChannelMask)) + if (auto format = tryFormat (sampleFormat, clientToUse, deviceMode, ch, newSampleRate, mixFormatChannelMask, isLoopbackDevice)) return format; } @@ -826,7 +847,7 @@ class WASAPIDeviceBase for (auto rate : rates) for (auto const& sampleFormat: formatsToTry) - if (auto format = tryFormat (sampleFormat, clientToUse, deviceMode, ch, rate, channelMask)) + if (auto format = tryFormat (sampleFormat, clientToUse, deviceMode, ch, rate, channelMask, isLoopbackDevice)) result = jmax (static_cast (format->Format.nChannels), result); } @@ -837,9 +858,18 @@ class WASAPIDeviceBase { DWORD streamFlags = 0x40000; /*AUDCLNT_STREAMFLAGS_EVENTCALLBACK*/ - if (supportsSampleRateConversion (deviceMode)) + if (isLoopbackDevice) + { + streamFlags |= 0x00020000; /*AUDCLNT_STREAMFLAGS_LOOPBACK*/ + // Loopback always needs sample rate conversion flags since it must use shared mode streamFlags |= (0x80000000 /*AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM*/ | 0x8000000); /*AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY*/ + } + else if (supportsSampleRateConversion (deviceMode)) + { + streamFlags |= (0x80000000 /*AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM*/ + | 0x8000000); /*AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY*/ + } return streamFlags; } @@ -861,17 +891,19 @@ class WASAPIDeviceBase check (client->GetDevicePeriod (&defaultPeriod, &minPeriod)); - if (isExclusiveMode (deviceMode) && bufferSizeSamples > 0) + const bool useExclusive = isExclusiveMode (deviceMode) && ! isLoopbackDevice; + + if (useExclusive && bufferSizeSamples > 0) defaultPeriod = jmax (minPeriod, samplesToRefTime (bufferSizeSamples, format.Format.nSamplesPerSec)); for (;;) { GUID session; - auto hr = client->Initialize (isExclusiveMode (deviceMode) ? AUDCLNT_SHAREMODE_EXCLUSIVE - : AUDCLNT_SHAREMODE_SHARED, + auto hr = client->Initialize (useExclusive ? AUDCLNT_SHAREMODE_EXCLUSIVE + : AUDCLNT_SHAREMODE_SHARED, getStreamFlags(), defaultPeriod, - isExclusiveMode (deviceMode) ? defaultPeriod : 0, + useExclusive ? defaultPeriod : 0, (WAVEFORMATEX*) &format, &session); @@ -926,8 +958,8 @@ class WASAPIDeviceBase class WASAPIInputDevice final : public WASAPIDeviceBase { public: - WASAPIInputDevice (const ComSmartPtr& d, WASAPIDeviceMode mode) - : WASAPIDeviceBase (d, mode) + WASAPIInputDevice (const ComSmartPtr& d, WASAPIDeviceMode mode, bool loopback = false) + : WASAPIDeviceBase (d, mode, loopback) { } @@ -1646,6 +1678,11 @@ class WASAPIAudioIODevice final : public AudioIODevice, if (! check (deviceCollection->GetCount (&numDevices))) return false; + // Check if input is a loopback device + const bool inputIsLoopback = isLoopbackDeviceId (inputDeviceId); + const String actualInputDeviceId = inputIsLoopback ? getRenderDeviceIdFromLoopback (inputDeviceId) + : inputDeviceId; + for (UINT32 i = 0; i < numDevices; ++i) { ComSmartPtr device; @@ -1660,9 +1697,17 @@ class WASAPIAudioIODevice final : public AudioIODevice, auto flow = getDataFlow (device); - if (deviceId == inputDeviceId && flow == eCapture) + if (inputIsLoopback && deviceId == actualInputDeviceId && flow == eRender) + { + // Open the render device as a loopback capture device (shared mode forced) + inputDevice.reset (new WASAPIInputDevice (device, deviceMode, true)); + } + else if (! inputIsLoopback && deviceId == inputDeviceId && flow == eCapture) + { inputDevice.reset (new WASAPIInputDevice (device, deviceMode)); - else if (deviceId == outputDeviceId && flow == eRender) + } + + if (deviceId == outputDeviceId && flow == eRender) outputDevice.reset (new WASAPIOutputDevice (device, deviceMode)); } @@ -1930,6 +1975,10 @@ class WASAPIAudioIODeviceType final : public AudioIODeviceType const int index = (deviceId == defaultRenderer) ? 0 : -1; result.outputDeviceIds.insert (index, deviceId); result.outputDeviceNames.insert (index, name); + + // Also add as a loopback input device + result.inputDeviceIds.add (makeLoopbackDeviceId (deviceId)); + result.inputDeviceNames.add (name + " (Loopback)"); } else if (flow == eCapture) { From 8c177c2464598404397169d86353a554cd169ffc Mon Sep 17 00:00:00 2001 From: mthwJsmith Date: Wed, 25 Mar 2026 13:40:44 +0000 Subject: [PATCH 02/10] Fix ProcessAudioCapture sort comparator for JUCE Array::sort JUCE's Array::sort requires a comparator object with compareElements(), not a lambda. Use a struct-based comparator instead. --- Source/ProcessAudioCapture.cpp | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/Source/ProcessAudioCapture.cpp b/Source/ProcessAudioCapture.cpp index fc3550fe5..c19c35cc1 100644 --- a/Source/ProcessAudioCapture.cpp +++ b/Source/ProcessAudioCapture.cpp @@ -204,10 +204,17 @@ Array ProcessAudioCapture::getAudioProcesses() CloseHandle (snapshot); - // Sort by name - result.sort ([] (const ProcessInfo& a, const ProcessInfo& b) { - return a.name.compareIgnoreCase (b.name); - }); + // Sort by name using JUCE comparator + struct ProcessInfoComparator + { + int compareElements (const ProcessInfo& a, const ProcessInfo& b) const + { + return a.name.compareIgnoreCase (b.name); + } + }; + + ProcessInfoComparator comparator; + result.sort (comparator); return result; } From b698e40c389f2ef98a497bae3858976871d92e2d Mon Sep 17 00:00:00 2001 From: mthwJsmith Date: Wed, 25 Mar 2026 14:13:33 +0000 Subject: [PATCH 03/10] Add Application Audio device type for per-process audio capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New "Application Audio" option in the Audio Device Type dropdown that lists running Windows processes. Select a process (e.g. SnowRunner.exe) to capture its audio directly — no output device, virtual cable, or loopback needed. Uses Windows 11 per-process loopback API (AUDIOCLIENT_PROCESS_LOOPBACK_PARAMS). Only appears on Windows 10 Build 20348+ / Windows 11 where the API is available. Falls back gracefully on older systems. --- CMakeLists.txt | 8 +- Source/ApplicationAudioDevice.h | 227 ++++++++++++++++++++++++++++ Source/ProcessAudioCapture.cpp | 2 + Source/ProcessAudioCapture.h | 9 +- Source/SonoStandaloneFilterWindow.h | 10 ++ 5 files changed, 249 insertions(+), 7 deletions(-) create mode 100644 Source/ApplicationAudioDevice.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 8165b39d2..6821378d0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -323,6 +323,7 @@ function(sono_add_custom_plugin_target target_name product_name formats is_instr Source/SonobusPluginProcessor.h Source/ProcessAudioCapture.cpp Source/ProcessAudioCapture.h + Source/ApplicationAudioDevice.h Source/SonobusTypes.h Source/SuggestNewGroupView.cpp Source/SuggestNewGroupView.h @@ -602,18 +603,19 @@ function(sono_add_custom_plugin_target target_name product_name formats is_instr ${LIB_PATHS} ) - target_link_libraries("${target_name}" + target_link_libraries("${target_name}" PRIVATE juce::juce_audio_utils juce::juce_dsp juce::juce_cryptography juce::juce_audio_plugin_client - + ff_meters - + ${target_name}_SBData opus + $<$:mmdevapi> PUBLIC juce::juce_recommended_config_flags juce::juce_recommended_lto_flags diff --git a/Source/ApplicationAudioDevice.h b/Source/ApplicationAudioDevice.h new file mode 100644 index 000000000..b684ea60a --- /dev/null +++ b/Source/ApplicationAudioDevice.h @@ -0,0 +1,227 @@ +// SPDX-License-Identifier: GPLv3-or-later WITH Appstore-exception +// Copyright (C) 2026 + +#pragma once + +#include "JuceHeader.h" + +#if JUCE_WINDOWS + +#include "ProcessAudioCapture.h" + +//============================================================================== +/** + An AudioIODevice that captures audio from a specific Windows process + using the Windows 11 per-process loopback API. +*/ +class ApplicationAudioDevice final : public AudioIODevice, + private Thread +{ +public: + ApplicationAudioDevice (const String& processName, DWORD processId) + : AudioIODevice ("Application Audio", "Application Audio"), + Thread ("AppAudioCapture"), + targetProcessName (processName), + targetProcessId (processId) + { + } + + ~ApplicationAudioDevice() override + { + close(); + } + + //============================================================================== + StringArray getOutputChannelNames() override { return {}; } + StringArray getInputChannelNames() override { return { "Left", "Right" }; } + + Array getAvailableSampleRates() override { return { 44100.0, 48000.0, 96000.0 }; } + Array getAvailableBufferSizes() override { return { 256, 512, 1024, 2048, 4096 }; } + int getDefaultBufferSize() override { return 1024; } + + String open (const BigInteger& inputChannels, + const BigInteger& outputChannels, + double sampleRate, + int bufferSizeSamples) override + { + currentSampleRate = sampleRate; + currentBufferSize = bufferSizeSamples; + activeInputChannels = inputChannels; + + if (! capture.startCapture (targetProcessId, sampleRate, 2, bufferSizeSamples)) + return "Failed to start process audio capture. Requires Windows 10 Build 20348+ and the target process must be running."; + + isOpen_ = true; + return {}; + } + + void close() override + { + stop(); + capture.stopCapture(); + isOpen_ = false; + } + + bool isOpen() override { return isOpen_; } + bool isPlaying() override { return isStarted; } + + int getCurrentBufferSizeSamples() override { return currentBufferSize; } + double getCurrentSampleRate() override { return currentSampleRate; } + int getCurrentBitDepth() override { return 32; } + + BigInteger getActiveOutputChannels() const override { return {}; } + BigInteger getActiveInputChannels() const override { return activeInputChannels; } + + int getOutputLatencyInSamples() override { return 0; } + int getInputLatencyInSamples() override { return currentBufferSize; } + + bool hasControlPanel() const override { return false; } + bool showControlPanel() override { return false; } + bool setAudioPreprocessingEnabled (bool) override { return false; } + + String getLastError() override { return lastError; } + + void start (AudioIODeviceCallback* newCallback) override + { + if (newCallback != nullptr && isOpen_ && ! isStarted) + { + callback = newCallback; + callback->audioDeviceAboutToStart (this); + isStarted = true; + startThread (Thread::Priority::high); + } + } + + void stop() override + { + if (isStarted) + { + isStarted = false; + stopThread (2000); + + if (callback != nullptr) + { + callback->audioDeviceStopped(); + callback = nullptr; + } + } + } + +private: + void run() override + { + AudioBuffer inputBuffer (2, currentBufferSize); + + while (! threadShouldExit() && isStarted) + { + inputBuffer.clear(); + capture.readSamples (inputBuffer, currentBufferSize); + + const float* inputChannelData[2] = { + inputBuffer.getReadPointer (0), + inputBuffer.getReadPointer (1) + }; + + if (callback != nullptr) + { + callback->audioDeviceIOCallbackWithContext (inputChannelData, 2, + nullptr, 0, + currentBufferSize, {}); + } + + // Sleep based on buffer size to match expected callback rate + auto sleepMs = (int) (1000.0 * currentBufferSize / currentSampleRate); + Thread::sleep (jmax (1, sleepMs - 1)); + } + } + + ProcessAudioCapture capture; + String targetProcessName; + DWORD targetProcessId; + + bool isOpen_ = false; + bool isStarted = false; + double currentSampleRate = 48000.0; + int currentBufferSize = 1024; + BigInteger activeInputChannels; + String lastError; + AudioIODeviceCallback* callback = nullptr; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (ApplicationAudioDevice) +}; + + +//============================================================================== +/** + AudioIODeviceType that enumerates running processes as input devices. + Shows up as "Application Audio" in the Audio Device Type dropdown. +*/ +class ApplicationAudioDeviceType final : public AudioIODeviceType +{ +public: + ApplicationAudioDeviceType() + : AudioIODeviceType ("Application Audio") + { + } + + void scanForDevices() override + { + hasScanned = true; + inputNames.clear(); + inputPids.clear(); + + if (! ProcessAudioCapture::isSupported()) + return; + + auto processes = ProcessAudioCapture::getAudioProcesses(); + for (auto& proc : processes) + { + inputNames.add (proc.name + " (PID " + String (proc.pid) + ")"); + inputPids.add (proc.pid); + } + } + + StringArray getDeviceNames (bool wantInputNames) const override + { + return wantInputNames ? inputNames : StringArray(); + } + + int getDefaultDeviceIndex (bool) const override + { + return 0; + } + + int getIndexOfDevice (AudioIODevice* device, bool asInput) const override + { + if (! asInput) + return -1; + + for (int i = 0; i < inputNames.size(); ++i) + if (inputNames[i] == device->getName()) + return i; + + return -1; + } + + bool hasSeparateInputsAndOutputs() const override { return true; } + + AudioIODevice* createDevice (const String& outputDeviceName, + const String& inputDeviceName) override + { + auto index = inputNames.indexOf (inputDeviceName); + + if (index >= 0) + return new ApplicationAudioDevice (inputDeviceName, inputPids[index]); + + return nullptr; + } + +private: + bool hasScanned = false; + StringArray inputNames; + Array inputPids; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (ApplicationAudioDeviceType) +}; + +#endif // JUCE_WINDOWS diff --git a/Source/ProcessAudioCapture.cpp b/Source/ProcessAudioCapture.cpp index c19c35cc1..eb01d91f0 100644 --- a/Source/ProcessAudioCapture.cpp +++ b/Source/ProcessAudioCapture.cpp @@ -5,6 +5,8 @@ #if JUCE_WINDOWS +#include +#include #include #include diff --git a/Source/ProcessAudioCapture.h b/Source/ProcessAudioCapture.h index 60e38671e..64fcc3f37 100644 --- a/Source/ProcessAudioCapture.h +++ b/Source/ProcessAudioCapture.h @@ -8,10 +8,12 @@ #if JUCE_WINDOWS #include -#include -#include #include +// Forward declarations — actual Windows audio COM types are only used in .cpp +struct IAudioClient; +struct IAudioCaptureClient; + //============================================================================== /** Per-process audio capture using Windows 11's AUDIOCLIENT_PROCESS_LOOPBACK_PARAMS. @@ -35,7 +37,7 @@ class ProcessAudioCapture String displayName; // e.g. "SnowRunner.exe (PID 1234)" }; - /** Returns a list of currently running processes that have active audio sessions. */ + /** Returns a list of currently running processes. */ static Array getAudioProcesses(); /** Returns true if the per-process capture API is available on this OS version. */ @@ -65,7 +67,6 @@ class ProcessAudioCapture double captureSampleRate = 0; int captureNumChannels = 0; - // COM pointers managed via raw pointers with Release() in destructor IAudioClient* audioClient = nullptr; IAudioCaptureClient* captureClient = nullptr; diff --git a/Source/SonoStandaloneFilterWindow.h b/Source/SonoStandaloneFilterWindow.h index 5beaef324..2fea682d7 100644 --- a/Source/SonoStandaloneFilterWindow.h +++ b/Source/SonoStandaloneFilterWindow.h @@ -37,6 +37,10 @@ // HACK #include "SonobusPluginEditor.h" +#if JUCE_WINDOWS +#include "ApplicationAudioDevice.h" +#endif + #include #include @@ -439,6 +443,12 @@ class StandalonePluginHolder : private AudioIODeviceCallback, totalOutChannels = defaultConfig.numOuts; } + #if JUCE_WINDOWS + // Add Application Audio device type for per-process audio capture (Win11+) + if (ProcessAudioCapture::isSupported()) + deviceManager.addAudioDeviceType (std::make_unique()); + #endif + deviceManager.initialise (enableAudioInput ? totalInChannels : 0, totalOutChannels, savedState.get(), From 1f1c3d3b6acb6dec2ea87a3b2fdca510741fab58 Mon Sep 17 00:00:00 2001 From: mthwJsmith Date: Wed, 25 Mar 2026 14:19:36 +0000 Subject: [PATCH 04/10] Add fork README with setup guide and update audio.md Includes direct ethernet setup guide (static IPs, SonoBus config), three options for sending system audio without real output devices, and build instructions. --- FORK_README.md | 127 +++++++++++++++++++++++++++++++++++++++++++++++++ audio.md | 43 +++++++++++++---- 2 files changed, 162 insertions(+), 8 deletions(-) create mode 100644 FORK_README.md diff --git a/FORK_README.md b/FORK_README.md new file mode 100644 index 000000000..97b362684 --- /dev/null +++ b/FORK_README.md @@ -0,0 +1,127 @@ +# SonoBus Fork — WASAPI Loopback & Application Audio Capture + +A fork of [SonoBus](https://github.com/sonosaurus/sonobus) that adds native Windows audio capture features, eliminating the need for Voicemeeter, VB-Cable, or any virtual audio cable software. + +## New Features + +### WASAPI Loopback Capture +Every output device (speakers, headphones, monitors) now appears as a **(Loopback)** input device in SonoBus. Select one to capture all audio playing through that device — lossless, digital, no extra software. + +### Application Audio Capture (Windows 11) +A new **"Application Audio"** device type that lists running processes. Select a specific app (e.g. SnowRunner.exe) to capture only its audio. No output device needed at all. Requires Windows 10 Build 20348+ / Windows 11. + +### Save Direct Connect Address +The last used direct connect IP:port is saved and pre-filled on next launch. + +### Auto-Reconnect Direct Peer +The "Reconnect Last" option now also reconnects to the last direct peer connection on startup. + +## Direct Ethernet Setup Guide + +You can connect two PCs with a single ethernet cable for ultra-low-latency audio streaming. No router or internet needed. + +### 1. Physical Connection +Plug an ethernet cable directly between the two PCs. + +### 2. Set Static IPs +On each PC, configure the ethernet adapter with a static IP: + +**PC 1 (e.g. receiver with speakers):** +1. Open **Settings > Network & Internet > Ethernet** (or the direct connection adapter) +2. Click **Edit** next to IP assignment, switch to **Manual** +3. Enable **IPv4** and set: + - IP address: `192.168.1.1` + - Subnet mask: `255.255.255.0` + - Gateway: leave blank + - DNS: leave blank +4. Save + +**PC 2 (e.g. sender with the game):** +- Same steps, but set IP to `192.168.1.2` + +### 3. Connect in SonoBus +1. Open SonoBus on both PCs +2. On either PC, note the **Local Address** shown in the Direct Connect dialog (e.g. `192.168.1.1:58893`) +3. On the other PC, click **Direct Connect** and enter the address (e.g. `192.168.1.2:58893`) +4. The port changes each time SonoBus starts — always check the Local Address shown + +### 4. Audio Settings + +**Sending PC (e.g. running SnowRunner):** +- Audio Device Type: **Windows Audio** +- Input: **Your output device (Loopback)** — e.g. "Speakers (Realtek) (Loopback)" +- Output: **<< none >>** +- Set Windows default output to any device, mute it in Windows if you don't want local sound + +Or use **Application Audio** device type to capture a specific game directly (Win11 only). + +**Receiving PC (with DAC/amp/speakers):** +- Audio Device Type: **Windows Audio** +- Input: **<< none >>** +- Output: **Your DAC/speakers** (e.g. USB-C audio adapter) + +### 5. Audio Format +In SonoBus, set the send quality to **PCM 32-bit** for completely lossless audio over the direct ethernet link. Bandwidth is not an issue on a local cable. + +## Sending All System Audio (No Real Output Device) + +Three options, from easiest to most involved: + +1. **Mute a real device** — Set any real output device (e.g. Realtek onboard) as Windows default, mute it in Windows. WASAPI loopback still captures audio even when muted. Easiest option. + +2. **Application Audio** — Use the "Application Audio" device type to capture a specific app directly. No output device needed. Windows 11 only. + +3. **Virtual Audio Driver** — If your PC genuinely has no audio output at all, install [Virtual-Audio-Driver](https://github.com/VirtualDrivers/Virtual-Audio-Driver) (open source, MIT) to create a virtual output, then use its loopback. Requires test signing mode. + +## Download + +Grab `SonoBus.exe` from the [Releases](https://github.com/mthwJsmith/sonobus/releases) page, or build from source. + +## Building from Source + +### Requirements +- CMake 3.15+ +- Visual Studio 2022 +- Windows SDK +- [ASIO SDK](https://github.com/audiosdk/asio) — clone to `../asiosdk` (sibling directory) + +### Steps + +```bash +# Clone +git clone --recursive https://github.com/mthwJsmith/sonobus.git +cd sonobus + +# Clone ASIO SDK +git clone https://github.com/audiosdk/asio.git ../asiosdk + +# Configure (unset Android toolchain if you have Android SDK installed) +CMAKE_TOOLCHAIN_FILE="" cmake -B build -G "Visual Studio 17 2022" -A x64 + +# Build +CMAKE_TOOLCHAIN_FILE="" cmake --build build --config Release --target SonoBus_Standalone + +# Output: build/SonoBus_artefacts/Release/Standalone/SonoBus.exe +``` + +## Technical Details + +### WASAPI Loopback +- `AUDCLNT_STREAMFLAGS_LOOPBACK` on `IAudioClient::Initialize` +- Lossless PCM capture, digitally before the DAC +- Forces shared mode (required by Windows) +- Changes in `deps/juce/modules/juce_audio_devices/native/juce_WASAPI_windows.cpp` + +### Per-Process Audio Capture +- `ActivateAudioInterfaceAsync` with `AUDIOCLIENT_PROCESS_LOOPBACK_PARAMS` +- Captures from a specific process tree +- No output device, virtual cable, or driver needed +- Windows 10 Build 20348+ (checked at runtime) +- `Source/ProcessAudioCapture.cpp` and `Source/ApplicationAudioDevice.h` + +## Upstream Issues Addressed +- [#241 — WASAPI Loopback as Input Device](https://github.com/sonosaurus/sonobus/issues/241) +- [#53 — Save previous direct connection IP](https://github.com/sonosaurus/sonobus/issues/53) + +## License +GPLv3 — same as upstream SonoBus. diff --git a/audio.md b/audio.md index 1f3d181b1..47d198881 100644 --- a/audio.md +++ b/audio.md @@ -31,15 +31,14 @@ Fork SonoBus to add WASAPI loopback capture, per-process audio capture, and UX i - `WASAPIInputDevice` constructor accepts loopback flag ### 3. Per-Process Audio Capture (Win11 API) — DONE -- New files: `Source/ProcessAudioCapture.h`, `Source/ProcessAudioCapture.cpp` +- New files: `Source/ProcessAudioCapture.h`, `Source/ProcessAudioCapture.cpp`, `Source/ApplicationAudioDevice.h` - Uses `AUDIOCLIENT_PROCESS_LOOPBACK_PARAMS` + `ActivateAudioInterfaceAsync` - Captures audio from a specific app (e.g. SnowRunner.exe) instead of all system audio - No special permissions needed, no drivers - Windows 10 Build 20348+ / Windows 11 -- Includes runtime API availability check (`isSupported()`) -- Includes process enumeration (`getAudioProcesses()`) -- Standalone capture class — not wired into JUCE device enumeration yet -- **TODO**: Wire into SonoBus UI (process picker dropdown in input settings) +- Shows as **"Application Audio"** in Audio Device Type dropdown +- Lists running processes as input devices +- Fully wired into SonoBus UI ### 4. Auto-Reconnect Direct Peer — DONE - Extended existing `reconnectToMostRecent()` to also try last direct peer connection @@ -80,11 +79,39 @@ Fork SonoBus to add WASAPI loopback capture, per-process audio capture, and UX i - Direct connect: `processor.connectRemotePeer(host, port, ...)` ## Build + +**Important:** If you have Android SDK/NDK installed, you MUST unset the CMAKE_TOOLCHAIN_FILE +env var or CMake will try to use the Android NDK Clang compiler instead of MSVC. + +**ASIO SDK:** Clone https://github.com/audiosdk/asio.git to `../asiosdk` (sibling of sonobus dir). + ```bash -cmake -B build -G "Visual Studio 17 2022" -cmake --build build --config Release +# Clone ASIO SDK (one-time) +git clone https://github.com/audiosdk/asio.git ../asiosdk + +# Unset Android toolchain and configure +CMAKE_TOOLCHAIN_FILE="" cmake -B build -G "Visual Studio 17 2022" -A x64 + +# Build standalone app +CMAKE_TOOLCHAIN_FILE="" cmake --build build --config Release --target SonoBus_Standalone + +# Output: build/SonoBus_artefacts/Release/Standalone/SonoBus.exe (25MB) ``` -Requires: CMake 3.15+, Visual Studio 2022, Windows SDK +Requires: CMake 3.15+, Visual Studio 2022, Windows SDK, ASIO SDK + +## How to Use WASAPI Loopback + +1. Open SonoBus (the built exe, not the installed one) +2. Go to the gear/settings icon +3. Set **Audio Device Type** to **"Windows Audio"** (this is WASAPI shared mode) +4. In the **Input** dropdown, you should now see your output devices listed with **(Loopback)** suffix, e.g.: + - `Speakers (Realtek High Definition Audio) (Loopback)` + - `DELL S2722QC (NVIDIA High Definition Audio) (Loopback)` +5. Select the loopback device matching where your game/desktop audio plays +6. The **Output** dropdown stays as your normal playback device (or leave empty if you're only sending) +7. Connect to your other PC via Direct Connect as usual + +This captures your desktop/game audio digitally (lossless PCM) and sends it over SonoBus — no Voicemeeter, no virtual cables, no restart issues. ## Key Files - `Source/ConnectView.cpp` — direct connect UI, saves last address From 0d1835309ec4113a857a9d54b5c9c3d91ac5bff1 Mon Sep 17 00:00:00 2001 From: mthwJsmith Date: Wed, 25 Mar 2026 14:45:09 +0000 Subject: [PATCH 05/10] Add FORK_README.md, update audio.md, keep JUCE on sono7good JUCE 8 (sono8good) has breaking API changes that require significant SonoBus code migration (MidiInput API changes, createPluginFilterOfType removal, JACK header issues). Staying on sono7good which matches upstream SonoBus and is fully tested. Added FORK_README.md with complete setup guide including direct ethernet configuration and static port recommendation. --- deps/juce/.gitrepo | 12 -- .../jucer_ProjectExport_Android.h | 2 +- .../juce_gui_basics/juce_gui_basics.cpp | 4 + .../native/juce_Windowing_mac.mm | 111 ++++++++++++++---- .../native/juce_InAppPurchases_android.cpp | 11 +- 5 files changed, 98 insertions(+), 42 deletions(-) delete mode 100644 deps/juce/.gitrepo diff --git a/deps/juce/.gitrepo b/deps/juce/.gitrepo deleted file mode 100644 index e01352ad9..000000000 --- a/deps/juce/.gitrepo +++ /dev/null @@ -1,12 +0,0 @@ -; DO NOT EDIT (unless you know what you are doing) -; -; This subdirectory is a git "subrepo", and this file is maintained by the -; git-subrepo command. See https://github.com/git-commands/git-subrepo#readme -; -[subrepo] - remote = https://github.com/essej/JUCE.git - branch = sono7good - commit = 925e37b00500afe60ad64cacdbcbc8c85f1f2ec5 - method = merge - cmdver = 0.4.5 - parent = 5ebf788e14e46a9987266eae81878e9094452cd3 diff --git a/deps/juce/extras/Projucer/Source/ProjectSaving/jucer_ProjectExport_Android.h b/deps/juce/extras/Projucer/Source/ProjectSaving/jucer_ProjectExport_Android.h index f7b63216a..df9ead908 100644 --- a/deps/juce/extras/Projucer/Source/ProjectSaving/jucer_ProjectExport_Android.h +++ b/deps/juce/extras/Projucer/Source/ProjectSaving/jucer_ProjectExport_Android.h @@ -928,7 +928,7 @@ class AndroidProjectExporter final : public ProjectExporter mo << " implementation files('libs/" << File (d).getFileName() << "')" << newLine; if (isInAppBillingEnabled()) - mo << " implementation 'com.android.billingclient:billing:5.0.0'" << newLine; + mo << " implementation 'com.android.billingclient:billing:7.0.0'" << newLine; if (areRemoteNotificationsEnabled()) { diff --git a/deps/juce/modules/juce_gui_basics/juce_gui_basics.cpp b/deps/juce/modules/juce_gui_basics/juce_gui_basics.cpp index 518f39397..89435e3ef 100644 --- a/deps/juce/modules/juce_gui_basics/juce_gui_basics.cpp +++ b/deps/juce/modules/juce_gui_basics/juce_gui_basics.cpp @@ -52,6 +52,10 @@ #import #import +#if defined (MAC_OS_VERSION_14_4) && MAC_OS_X_VERSION_MIN_REQUIRED >= MAC_OS_VERSION_14_4 + #import +#endif + #elif JUCE_IOS #if JUCE_PUSH_NOTIFICATIONS #import diff --git a/deps/juce/modules/juce_gui_basics/native/juce_Windowing_mac.mm b/deps/juce/modules/juce_gui_basics/native/juce_Windowing_mac.mm index ad459d483..b559a0f17 100644 --- a/deps/juce/modules/juce_gui_basics/native/juce_Windowing_mac.mm +++ b/deps/juce/modules/juce_gui_basics/native/juce_Windowing_mac.mm @@ -520,37 +520,100 @@ static Image createNSWindowSnapshot (NSWindow* nsWindow) { JUCE_AUTORELEASEPOOL { - // CGWindowListCreateImage is replaced by functions in the ScreenCaptureKit framework, but - // that framework is only available from macOS 12.3 onwards. - // A suitable @available check should be added once the minimum build OS is 12.3 or greater, - // so that ScreenCaptureKit can be weak-linked. - #if defined (MAC_OS_VERSION_14_0) && MAC_OS_X_VERSION_MAX_ALLOWED >= MAC_OS_VERSION_14_0 - JUCE_BEGIN_IGNORE_WARNINGS_GCC_LIKE ("-Wdeprecated-declarations") - #define JUCE_DEPRECATION_IGNORED 1 - #endif + const auto createImageFromCGImage = [&] (CGImageRef cgImage) + { + jassert (cgImage != nullptr); - CGImageRef screenShot = CGWindowListCreateImage (CGRectNull, - kCGWindowListOptionIncludingWindow, - (CGWindowID) [nsWindow windowNumber], - kCGWindowImageBoundsIgnoreFraming); + const auto width = CGImageGetWidth (cgImage); + const auto height = CGImageGetHeight (cgImage); + const auto cgRect = CGRectMake (0, 0, (CGFloat) width, (CGFloat) height); + const Image image (Image::ARGB, (int) width, (int) height, true); - #if JUCE_DEPRECATION_IGNORED - JUCE_END_IGNORE_WARNINGS_GCC_LIKE - #undef JUCE_DEPRECATION_IGNORED - #endif + CGContextDrawImage (juce_getImageContext (image), cgRect, cgImage); - NSBitmapImageRep* bitmapRep = [[NSBitmapImageRep alloc] initWithCGImage: screenShot]; + return image; + }; - Image result (Image::ARGB, (int) [bitmapRep size].width, (int) [bitmapRep size].height, true); + #if defined (MAC_OS_VERSION_14_4) && MAC_OS_X_VERSION_MIN_REQUIRED >= MAC_OS_VERSION_14_4 - selectImageForDrawing (result); - [bitmapRep drawAtPoint: NSMakePoint (0, 0)]; - releaseImageAfterDrawing(); + if (dlopen ("/System/Library/Frameworks/ScreenCaptureKit.framework/ScreenCaptureKit", RTLD_LAZY) == nullptr) + { + DBG (dlerror()); + jassertfalse; + return {}; + } - [bitmapRep release]; - CGImageRelease (screenShot); + std::promise result; - return result; + const auto windowId = nsWindow.windowNumber; + const auto windowRect = [nsWindow.screen convertRectToBacking: nsWindow.frame].size; + + const auto onSharableContent = [&] (SCShareableContent* content, NSError* contentError) + { + if (contentError != nullptr) + { + jassertfalse; + result.set_value (Image{}); + return; + } + + const auto window = [&]() -> SCWindow* + { + for (SCWindow* w in content.windows) + if (w.windowID == windowId) + return w; + + return nullptr; + }(); + + if (window == nullptr) + { + jassertfalse; + result.set_value (Image{}); + return; + } + + Class contentFilterClass = NSClassFromString (@"SCContentFilter"); + SCContentFilter* filter = [[[contentFilterClass alloc] initWithDesktopIndependentWindow: window] autorelease]; + + Class streamConfigurationClass = NSClassFromString (@"SCStreamConfiguration"); + SCStreamConfiguration* config = [[[streamConfigurationClass alloc] init] autorelease]; + config.colorSpaceName = kCGColorSpaceSRGB; + config.showsCursor = NO; + config.ignoreShadowsSingleWindow = YES; + config.captureResolution = SCCaptureResolutionBest; + config.ignoreGlobalClipSingleWindow = YES; + config.includeChildWindows = NO; + config.width = (size_t) windowRect.width; + config.height = (size_t) windowRect.height; + + const auto onScreenshot = [&] (CGImageRef screenshot, NSError* screenshotError) + { + jassert (screenshotError == nullptr); + result.set_value (screenshotError == nullptr ? createImageFromCGImage (screenshot) : Image{}); + }; + + Class screenshotManagerClass = NSClassFromString (@"SCScreenshotManager"); + [screenshotManagerClass captureImageWithFilter: filter + configuration: config + completionHandler: onScreenshot]; + }; + + Class shareableContentClass = NSClassFromString (@"SCShareableContent"); + [shareableContentClass getCurrentProcessShareableContentWithCompletionHandler: onSharableContent]; + + return result.get_future().get(); + + #else + + JUCE_BEGIN_IGNORE_WARNINGS_GCC_LIKE ("-Wdeprecated-declarations") + return createImageFromCGImage ((CGImageRef) CFAutorelease (CGWindowListCreateImage (CGRectNull, + kCGWindowListOptionIncludingWindow, + (CGWindowID) [nsWindow windowNumber], + kCGWindowImageBoundsIgnoreFraming))); + JUCE_END_IGNORE_WARNINGS_GCC_LIKE + + #endif } } diff --git a/deps/juce/modules/juce_product_unlocking/native/juce_InAppPurchases_android.cpp b/deps/juce/modules/juce_product_unlocking/native/juce_InAppPurchases_android.cpp index 5915a0560..0d7d6b69b 100644 --- a/deps/juce/modules/juce_product_unlocking/native/juce_InAppPurchases_android.cpp +++ b/deps/juce/modules/juce_product_unlocking/native/juce_InAppPurchases_android.cpp @@ -90,9 +90,9 @@ DECLARE_JNI_CLASS (BillingFlowParamsBuilder, "com/android/billingclient/api/Bill #undef JNI_CLASS_MEMBERS #define JNI_CLASS_MEMBERS(METHOD, STATICMETHOD, FIELD, STATICFIELD, CALLBACK) \ - METHOD (build, "build", "()Lcom/android/billingclient/api/BillingFlowParams$SubscriptionUpdateParams;") \ - METHOD (setOldPurchaseToken, "setOldPurchaseToken", "(Ljava/lang/String;)Lcom/android/billingclient/api/BillingFlowParams$SubscriptionUpdateParams$Builder;") \ - METHOD (setReplaceProrationMode, "setReplaceProrationMode", "(I)Lcom/android/billingclient/api/BillingFlowParams$SubscriptionUpdateParams$Builder;") + METHOD (build, "build", "()Lcom/android/billingclient/api/BillingFlowParams$SubscriptionUpdateParams;") \ + METHOD (setOldPurchaseToken, "setOldPurchaseToken", "(Ljava/lang/String;)Lcom/android/billingclient/api/BillingFlowParams$SubscriptionUpdateParams$Builder;") \ + METHOD (setSubscriptionReplacementMode, "setSubscriptionReplacementMode", "(I)Lcom/android/billingclient/api/BillingFlowParams$SubscriptionUpdateParams$Builder;") DECLARE_JNI_CLASS (BillingFlowParamsSubscriptionUpdateParamsBuilder, "com/android/billingclient/api/BillingFlowParams$SubscriptionUpdateParams$Builder") #undef JNI_CLASS_MEMBERS @@ -847,9 +847,10 @@ struct InAppPurchases::Pimpl if (! creditForUnusedSubscription) { + constexpr auto WITHOUT_PRORATION = 3; env->CallObjectMethod (subscriptionBuilder.get(), - BillingFlowParamsSubscriptionUpdateParamsBuilder.setReplaceProrationMode, - 3 /*IMMEDIATE_WITHOUT_PRORATION*/); + BillingFlowParamsSubscriptionUpdateParamsBuilder.setSubscriptionReplacementMode, + WITHOUT_PRORATION); } const LocalRef subscriptionParams { env->CallObjectMethod (subscriptionBuilder.get(), From 6b4d264dd351d711e6e6afe394911e15e1baa9ca Mon Sep 17 00:00:00 2001 From: mthwJsmith Date: Wed, 25 Mar 2026 16:36:35 +0000 Subject: [PATCH 06/10] Revert to JUCE 7 (sono7good), port all Application Audio fixes JUCE 8 had shared mode loopback issues that couldn't be reliably fixed. Reverted to JUCE 7 which has working loopback in all modes. Ported forward all Application Audio improvements: - WRL RuntimeClass with FtmBase for ActivateAudioInterfaceAsync handler - IAudioSessionManager2 for enumerating only active audio processes - GetMixFormat E_NOTIMPL fallback with user-requested sample rate - Real SDK header (audioclientactivationparams.h) with NTDDI_VERSION - Fixed isSupported() version check --- Source/ProcessAudioCapture.cpp | 323 ++++++++++++++++++--------------- 1 file changed, 172 insertions(+), 151 deletions(-) diff --git a/Source/ProcessAudioCapture.cpp b/Source/ProcessAudioCapture.cpp index eb01d91f0..8fa1aa0d4 100644 --- a/Source/ProcessAudioCapture.cpp +++ b/Source/ProcessAudioCapture.cpp @@ -5,55 +5,37 @@ #if JUCE_WINDOWS +// Ensure we get the Win10 FE (Iron/20H1) APIs for per-process loopback +#ifndef NTDDI_WIN10_FE +#define NTDDI_WIN10_FE 0x0A00000A +#endif +#if !defined(NTDDI_VERSION) || (NTDDI_VERSION < NTDDI_WIN10_FE) +#undef NTDDI_VERSION +#define NTDDI_VERSION NTDDI_WIN10_FE +#endif + #include #include +#include #include #include +#include +#include -// Forward declarations for Windows 11 per-process loopback API -// These are defined in audioclientactivationparams.h (Windows 11 SDK) -// We define them here to support building with older SDKs - -#ifndef AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK -#define AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK 1 - -typedef enum PROCESS_LOOPBACK_MODE -{ - PROCESS_LOOPBACK_MODE_INCLUDE_TARGET_PROCESS_TREE = 0, - PROCESS_LOOPBACK_MODE_EXCLUDE_TARGET_PROCESS_TREE = 1 -} PROCESS_LOOPBACK_MODE; - -typedef struct AUDIOCLIENT_PROCESS_LOOPBACK_PARAMS -{ - DWORD TargetProcessId; - PROCESS_LOOPBACK_MODE ProcessLoopbackMode; -} AUDIOCLIENT_PROCESS_LOOPBACK_PARAMS; - -typedef enum AUDIOCLIENT_ACTIVATION_TYPE -{ - AUDIOCLIENT_ACTIVATION_TYPE_DEFAULT = 0, - AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK_TYPE = 1 -} AUDIOCLIENT_ACTIVATION_TYPE; - -typedef struct AUDIOCLIENT_ACTIVATION_PARAMS -{ - AUDIOCLIENT_ACTIVATION_TYPE ActivationType; - union - { - AUDIOCLIENT_PROCESS_LOOPBACK_PARAMS ProcessLoopbackParams; - }; -} AUDIOCLIENT_ACTIVATION_PARAMS; - -#endif // AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK - -// Virtual audio device path for process loopback -static const LPCWSTR VIRTUAL_AUDIO_DEVICE_PROCESS_LOOPBACK = L"VAD\\Process_Loopback"; +using namespace Microsoft::WRL; //============================================================================== -// IActivateAudioInterfaceCompletionHandler implementation -class LoopbackActivationHandler : public IActivateAudioInterfaceCompletionHandler +// Completion handler using WRL RuntimeClass with FtmBase for free-threaded marshaling. +// This is REQUIRED by ActivateAudioInterfaceAsync — a bare IUnknown implementation +// will return CO_E_NOT_SUPPORTED (0x8000000E). +class LoopbackActivationHandler : + public RuntimeClass, FtmBase, IActivateAudioInterfaceCompletionHandler> { public: + HANDLE completionEvent = nullptr; + IAudioClient* resultClient = nullptr; + HRESULT activateResult = E_FAIL; + LoopbackActivationHandler() { completionEvent = CreateEvent (nullptr, TRUE, FALSE, nullptr); @@ -65,30 +47,7 @@ class LoopbackActivationHandler : public IActivateAudioInterfaceCompletionHandle CloseHandle (completionEvent); } - // IUnknown - ULONG STDMETHODCALLTYPE AddRef() override { return InterlockedIncrement (&refCount); } - ULONG STDMETHODCALLTYPE Release() override - { - auto count = InterlockedDecrement (&refCount); - if (count == 0) - delete this; - return count; - } - - HRESULT STDMETHODCALLTYPE QueryInterface (REFIID riid, void** ppvObject) override - { - if (riid == __uuidof (IUnknown) || riid == __uuidof (IActivateAudioInterfaceCompletionHandler)) - { - *ppvObject = static_cast (this); - AddRef(); - return S_OK; - } - *ppvObject = nullptr; - return E_NOINTERFACE; - } - - // IActivateAudioInterfaceCompletionHandler - HRESULT STDMETHODCALLTYPE ActivateCompleted (IActivateAudioInterfaceAsyncOperation* operation) override + STDMETHOD(ActivateCompleted) (IActivateAudioInterfaceAsyncOperation* operation) override { HRESULT hrActivateResult = E_FAIL; IUnknown* activatedInterface = nullptr; @@ -96,29 +55,45 @@ class LoopbackActivationHandler : public IActivateAudioInterfaceCompletionHandle HRESULT hr = operation->GetActivateResult (&hrActivateResult, &activatedInterface); if (SUCCEEDED (hr) && SUCCEEDED (hrActivateResult) && activatedInterface != nullptr) - { activatedInterface->QueryInterface (__uuidof (IAudioClient), (void**) &resultClient); - } activateResult = hrActivateResult; SetEvent (completionEvent); return S_OK; } - bool waitForCompletion (DWORD timeoutMs = 5000) + bool waitForCompletion (DWORD timeoutMs = 10000) { return WaitForSingleObject (completionEvent, timeoutMs) == WAIT_OBJECT_0; } +}; - IAudioClient* getClient() { return resultClient; } - HRESULT getResult() const { return activateResult; } +//============================================================================== +static String getProcessNameFromPid (DWORD pid) +{ + HANDLE snapshot = CreateToolhelp32Snapshot (TH32CS_SNAPPROCESS, 0); + if (snapshot == INVALID_HANDLE_VALUE) + return {}; -private: - LONG refCount = 1; - HANDLE completionEvent = nullptr; - IAudioClient* resultClient = nullptr; - HRESULT activateResult = E_FAIL; -}; + PROCESSENTRY32W pe; + pe.dwSize = sizeof (pe); + String name; + + if (Process32FirstW (snapshot, &pe)) + { + do + { + if (pe.th32ProcessID == pid) + { + name = String (pe.szExeFile); + break; + } + } while (Process32NextW (snapshot, &pe)); + } + + CloseHandle (snapshot); + return name; +} //============================================================================== ProcessAudioCapture::~ProcessAudioCapture() @@ -128,8 +103,7 @@ ProcessAudioCapture::~ProcessAudioCapture() bool ProcessAudioCapture::isSupported() { - // Check if ActivateAudioInterfaceAsync is available (Win8+) - // and if the process loopback feature works (Win10 20348+ / Win11) + // Check if ActivateAudioInterfaceAsync is available HMODULE mmdevapi = GetModuleHandleW (L"mmdevapi.dll"); if (mmdevapi == nullptr) mmdevapi = LoadLibraryW (L"mmdevapi.dll"); @@ -137,76 +111,117 @@ bool ProcessAudioCapture::isSupported() if (mmdevapi == nullptr) return false; - auto activateFunc = GetProcAddress (mmdevapi, "ActivateAudioInterfaceAsync"); - if (activateFunc == nullptr) + if (GetProcAddress (mmdevapi, "ActivateAudioInterfaceAsync") == nullptr) + return false; + + // Check Windows build number using RtlGetVersion (reliable, not affected by manifests) + using RtlGetVersionFunc = LONG (WINAPI*)(OSVERSIONINFOEXW*); + auto ntdll = GetModuleHandleW (L"ntdll.dll"); + if (ntdll == nullptr) + return false; + + auto rtlGetVersion = reinterpret_cast (GetProcAddress (ntdll, "RtlGetVersion")); + if (rtlGetVersion == nullptr) return false; - // Check Windows version - need build 20348+ OSVERSIONINFOEXW osvi = {}; osvi.dwOSVersionInfoSize = sizeof (osvi); + rtlGetVersion (&osvi); - using RtlGetVersionFunc = NTSTATUS (WINAPI*)(PRTL_OSVERSIONINFOW); - auto ntdll = GetModuleHandleW (L"ntdll.dll"); - if (ntdll != nullptr) - { - auto rtlGetVersion = reinterpret_cast (GetProcAddress (ntdll, "RtlGetVersion")); - if (rtlGetVersion != nullptr) - { - rtlGetVersion (reinterpret_cast (&osvi)); - // Build 20348 is the minimum for process loopback - return osvi.dwBuildNumber >= 20348; - } - } - - return false; + return osvi.dwBuildNumber >= 20348; } Array ProcessAudioCapture::getAudioProcesses() { Array result; - // Get all running processes - HANDLE snapshot = CreateToolhelp32Snapshot (TH32CS_SNAPPROCESS, 0); - if (snapshot == INVALID_HANDLE_VALUE) + // Use IAudioSessionManager2 to enumerate only processes with active audio sessions + CoInitializeEx (nullptr, COINIT_MULTITHREADED); + + IMMDeviceEnumerator* enumerator = nullptr; + HRESULT hr = CoCreateInstance (__uuidof (MMDeviceEnumerator), nullptr, + CLSCTX_ALL, __uuidof (IMMDeviceEnumerator), + (void**) &enumerator); + if (FAILED (hr) || enumerator == nullptr) return result; - PROCESSENTRY32W pe; - pe.dwSize = sizeof (pe); + IMMDevice* device = nullptr; + hr = enumerator->GetDefaultAudioEndpoint (eRender, eConsole, &device); + if (FAILED (hr) || device == nullptr) + { + enumerator->Release(); + return result; + } - if (Process32FirstW (snapshot, &pe)) + IAudioSessionManager2* sessionManager = nullptr; + hr = device->Activate (__uuidof (IAudioSessionManager2), CLSCTX_ALL, + nullptr, (void**) &sessionManager); + if (FAILED (hr) || sessionManager == nullptr) { - do + device->Release(); + enumerator->Release(); + return result; + } + + IAudioSessionEnumerator* sessionEnumerator = nullptr; + hr = sessionManager->GetSessionEnumerator (&sessionEnumerator); + if (FAILED (hr) || sessionEnumerator == nullptr) + { + sessionManager->Release(); + device->Release(); + enumerator->Release(); + return result; + } + + int sessionCount = 0; + sessionEnumerator->GetCount (&sessionCount); + + Array seenPids; + + for (int i = 0; i < sessionCount; ++i) + { + IAudioSessionControl* sessionControl = nullptr; + if (FAILED (sessionEnumerator->GetSession (i, &sessionControl)) || sessionControl == nullptr) + continue; + + IAudioSessionControl2* sessionControl2 = nullptr; + hr = sessionControl->QueryInterface (__uuidof (IAudioSessionControl2), (void**) &sessionControl2); + sessionControl->Release(); + + if (FAILED (hr) || sessionControl2 == nullptr) + continue; + + if (sessionControl2->IsSystemSoundsSession() == S_OK) { - // Skip system processes - if (pe.th32ProcessID == 0 || pe.th32ProcessID == 4) - continue; - - String name = String (pe.szExeFile); - - // Skip known non-audio system processes - if (name.equalsIgnoreCase ("svchost.exe") - || name.equalsIgnoreCase ("csrss.exe") - || name.equalsIgnoreCase ("smss.exe") - || name.equalsIgnoreCase ("lsass.exe") - || name.equalsIgnoreCase ("services.exe") - || name.equalsIgnoreCase ("wininit.exe") - || name.equalsIgnoreCase ("winlogon.exe") - || name.equalsIgnoreCase ("dwm.exe") - || name.equalsIgnoreCase ("System")) - continue; - - ProcessInfo info; - info.pid = pe.th32ProcessID; - info.name = name; - info.displayName = name + " (PID " + String (pe.th32ProcessID) + ")"; - result.add (info); + sessionControl2->Release(); + continue; + } - } while (Process32NextW (snapshot, &pe)); + DWORD pid = 0; + hr = sessionControl2->GetProcessId (&pid); + sessionControl2->Release(); + + if (FAILED (hr) || pid == 0 || seenPids.contains (pid)) + continue; + + seenPids.add (pid); + + String name = getProcessNameFromPid (pid); + if (name.isEmpty()) + continue; + + ProcessInfo info; + info.pid = pid; + info.name = name; + info.displayName = name + " (PID " + String (pid) + ")"; + result.add (info); } - CloseHandle (snapshot); + sessionEnumerator->Release(); + sessionManager->Release(); + device->Release(); + enumerator->Release(); - // Sort by name using JUCE comparator struct ProcessInfoComparator { int compareElements (const ProcessInfo& a, const ProcessInfo& b) const @@ -230,7 +245,7 @@ bool ProcessAudioCapture::startCapture (DWORD processId, double sampleRate, int // Set up activation params for process loopback AUDIOCLIENT_ACTIVATION_PARAMS activationParams = {}; - activationParams.ActivationType = AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK_TYPE; + activationParams.ActivationType = AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK; activationParams.ProcessLoopbackParams.TargetProcessId = processId; activationParams.ProcessLoopbackParams.ProcessLoopbackMode = PROCESS_LOOPBACK_MODE_INCLUDE_TARGET_PROCESS_TREE; @@ -239,59 +254,68 @@ bool ProcessAudioCapture::startCapture (DWORD processId, double sampleRate, int activateParamsPropVariant.blob.cbSize = sizeof (activationParams); activateParamsPropVariant.blob.pBlobData = reinterpret_cast (&activationParams); - // Create completion handler - auto handler = new LoopbackActivationHandler(); + // Create completion handler using WRL (FtmBase required for free-threaded marshaling) + ComPtr handler; + HRESULT hr = MakeAndInitialize (&handler); + if (FAILED (hr)) + return false; + IActivateAudioInterfaceAsyncOperation* asyncOp = nullptr; - HRESULT hr = ActivateAudioInterfaceAsync ( + hr = ActivateAudioInterfaceAsync ( VIRTUAL_AUDIO_DEVICE_PROCESS_LOOPBACK, __uuidof (IAudioClient), &activateParamsPropVariant, - handler, + handler.Get(), &asyncOp); if (FAILED (hr)) { - handler->Release(); if (asyncOp) asyncOp->Release(); return false; } - // Wait for async activation to complete - if (! handler->waitForCompletion (5000)) + if (! handler->waitForCompletion (10000)) { - handler->Release(); if (asyncOp) asyncOp->Release(); return false; } - if (FAILED (handler->getResult()) || handler->getClient() == nullptr) + if (FAILED (handler->activateResult) || handler->resultClient == nullptr) { - handler->Release(); if (asyncOp) asyncOp->Release(); return false; } - audioClient = handler->getClient(); - audioClient->AddRef(); // Take ownership + audioClient = handler->resultClient; + audioClient->AddRef(); - handler->Release(); if (asyncOp) asyncOp->Release(); - // Get mix format + // GetMixFormat returns E_NOTIMPL for process loopback — use requested format WAVEFORMATEX* mixFormat = nullptr; hr = audioClient->GetMixFormat (&mixFormat); + + bool usingDefaultFormat = false; + static WAVEFORMATEX defaultFormat = {}; + if (FAILED (hr) || mixFormat == nullptr) { - audioClient->Release(); - audioClient = nullptr; - return false; + defaultFormat.wFormatTag = WAVE_FORMAT_IEEE_FLOAT; + defaultFormat.nChannels = 2; + defaultFormat.nSamplesPerSec = (DWORD) sampleRate; + defaultFormat.wBitsPerSample = 32; + defaultFormat.nBlockAlign = defaultFormat.nChannels * defaultFormat.wBitsPerSample / 8; + defaultFormat.nAvgBytesPerSec = defaultFormat.nSamplesPerSec * defaultFormat.nBlockAlign; + defaultFormat.cbSize = 0; + mixFormat = &defaultFormat; + usingDefaultFormat = true; } captureSampleRate = mixFormat->nSamplesPerSec; captureNumChannels = mixFormat->nChannels; - // Initialize in shared mode (required for loopback) + // Initialize with loopback flag (matches Microsoft's sample) REFERENCE_TIME bufferDuration = 10000000LL; // 1 second buffer hr = audioClient->Initialize ( AUDCLNT_SHAREMODE_SHARED, @@ -301,7 +325,8 @@ bool ProcessAudioCapture::startCapture (DWORD processId, double sampleRate, int mixFormat, nullptr); - CoTaskMemFree (mixFormat); + if (! usingDefaultFormat) + CoTaskMemFree (mixFormat); if (FAILED (hr)) { @@ -339,9 +364,7 @@ void ProcessAudioCapture::stopCapture() capturing.store (false); if (audioClient != nullptr) - { audioClient->Stop(); - } if (captureClient != nullptr) { @@ -383,13 +406,11 @@ int ProcessAudioCapture::readSamples (AudioBuffer& buffer, int numFrames) if (flags & AUDCLNT_BUFFERFLAGS_SILENT) { - // Fill with silence for (int ch = 0; ch < buffer.getNumChannels() && ch < captureNumChannels; ++ch) FloatVectorOperations::clear (buffer.getWritePointer (ch, framesRead), framesToCopy); } else if (data != nullptr) { - // Convert interleaved float data to JUCE buffer const float* src = reinterpret_cast (data); for (int frame = 0; frame < framesToCopy; ++frame) { From 10f01fa6881cd374dfc17e3bd95f69cd131b496f Mon Sep 17 00:00:00 2001 From: mthwJsmith Date: Wed, 25 Mar 2026 20:05:15 +0000 Subject: [PATCH 07/10] Fix Application Audio crash and clean up debug logging Root cause: getIndexOfDevice() called device->getName() on a null pointer. JUCE passes nullptr when no audio device is currently open (e.g. during device type switching). Added null check. Also: - Provide dummy output device name so JUCE's AudioDeviceSelectorComponent doesn't crash on empty output device list - Remove all debug file logging from ProcessAudioCapture and ApplicationAudioDevice - Revert JUCE source files to clean state (no debug logging) - Remove CoInitializeEx call from getAudioProcesses (conflicts with JUCE's STA message thread) - Add Application Audio device type AFTER initialise so WASAPI is default --- Source/ApplicationAudioDevice.h | 10 +++++++--- Source/ProcessAudioCapture.cpp | 2 +- Source/SonoStandaloneFilterWindow.h | 12 ++++++------ 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/Source/ApplicationAudioDevice.h b/Source/ApplicationAudioDevice.h index b684ea60a..3dc4218f5 100644 --- a/Source/ApplicationAudioDevice.h +++ b/Source/ApplicationAudioDevice.h @@ -162,6 +162,9 @@ class ApplicationAudioDeviceType final : public AudioIODeviceType ApplicationAudioDeviceType() : AudioIODeviceType ("Application Audio") { + // JUCE's AudioDeviceSelectorComponent crashes if output device list is empty, + // so provide a dummy output device name + outputNames.add ("(No output - capture only)"); } void scanForDevices() override @@ -176,14 +179,14 @@ class ApplicationAudioDeviceType final : public AudioIODeviceType auto processes = ProcessAudioCapture::getAudioProcesses(); for (auto& proc : processes) { - inputNames.add (proc.name + " (PID " + String (proc.pid) + ")"); + inputNames.add (proc.displayName); inputPids.add (proc.pid); } } StringArray getDeviceNames (bool wantInputNames) const override { - return wantInputNames ? inputNames : StringArray(); + return wantInputNames ? inputNames : outputNames; } int getDefaultDeviceIndex (bool) const override @@ -193,7 +196,7 @@ class ApplicationAudioDeviceType final : public AudioIODeviceType int getIndexOfDevice (AudioIODevice* device, bool asInput) const override { - if (! asInput) + if (device == nullptr || ! asInput) return -1; for (int i = 0; i < inputNames.size(); ++i) @@ -219,6 +222,7 @@ class ApplicationAudioDeviceType final : public AudioIODeviceType private: bool hasScanned = false; StringArray inputNames; + StringArray outputNames; Array inputPids; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (ApplicationAudioDeviceType) diff --git a/Source/ProcessAudioCapture.cpp b/Source/ProcessAudioCapture.cpp index 8fa1aa0d4..0b8b7e62a 100644 --- a/Source/ProcessAudioCapture.cpp +++ b/Source/ProcessAudioCapture.cpp @@ -136,7 +136,7 @@ Array ProcessAudioCapture::getAudioProcesses() Array result; // Use IAudioSessionManager2 to enumerate only processes with active audio sessions - CoInitializeEx (nullptr, COINIT_MULTITHREADED); + // Note: do NOT call CoInitializeEx here — JUCE's message thread already has COM initialized IMMDeviceEnumerator* enumerator = nullptr; HRESULT hr = CoCreateInstance (__uuidof (MMDeviceEnumerator), nullptr, diff --git a/Source/SonoStandaloneFilterWindow.h b/Source/SonoStandaloneFilterWindow.h index 2fea682d7..a593fa0c9 100644 --- a/Source/SonoStandaloneFilterWindow.h +++ b/Source/SonoStandaloneFilterWindow.h @@ -443,12 +443,6 @@ class StandalonePluginHolder : private AudioIODeviceCallback, totalOutChannels = defaultConfig.numOuts; } - #if JUCE_WINDOWS - // Add Application Audio device type for per-process audio capture (Win11+) - if (ProcessAudioCapture::isSupported()) - deviceManager.addAudioDeviceType (std::make_unique()); - #endif - deviceManager.initialise (enableAudioInput ? totalInChannels : 0, totalOutChannels, savedState.get(), @@ -456,6 +450,12 @@ class StandalonePluginHolder : private AudioIODeviceCallback, preferredDefaultDeviceName, prefSetupOptions.get()); + #if JUCE_WINDOWS + // Add Application Audio AFTER initialise so WASAPI is the default device type + if (ProcessAudioCapture::isSupported()) + deviceManager.addAudioDeviceType (std::make_unique()); + #endif + #if JUCE_IOS // get current audio device and change a setting if necessary if (auto device = dynamic_cast (deviceManager.getCurrentAudioDevice())) { From b8b056c318332e68b3639b7805a27ceea980ae2d Mon Sep 17 00:00:00 2001 From: mthwJsmith Date: Wed, 25 Mar 2026 20:32:39 +0000 Subject: [PATCH 08/10] Update audio.md with full status, connection debugging plan, and bugs found - Document connection issue (our build can't connect, installed version can) - Add incremental test build plan (v0-clean through v3-full) to isolate bug - Document all bugs found and fixed (null device, empty output list, WRL FtmBase, etc.) - Remove auto-reconnect direct peer (ephemeral ports) - Clean up debug logging --- Source/SonobusPluginProcessor.cpp | 20 +--- audio.md | 157 +++++++++++++++++++----------- 2 files changed, 105 insertions(+), 72 deletions(-) diff --git a/Source/SonobusPluginProcessor.cpp b/Source/SonobusPluginProcessor.cpp index 9f0b105fb..758808f4a 100644 --- a/Source/SonobusPluginProcessor.cpp +++ b/Source/SonobusPluginProcessor.cpp @@ -8849,23 +8849,9 @@ void SonobusAudioProcessor::ServerReconnectTimer::timerCallback() bool SonobusAudioProcessor::reconnectToMostRecent() { - // Try reconnecting to last direct peer connection first - if (mLastDirectConnectAddress.isNotEmpty()) { - StringArray toks = StringArray::fromTokens(mLastDirectConnectAddress, ":/ ", ""); - String host; - int port = 11000; - - if (toks.size() >= 1) host = toks[0].trim(); - if (toks.size() >= 2) port = toks[1].trim().getIntValue(); - - if (host.isNotEmpty() && port != 0) { - DBG("Reconnecting to direct peer: " << host << ":" << port); - connectRemotePeer(host, port, "", "", true); - return true; - } - } - - // Otherwise try reconnecting to last server/group + // Note: direct peer auto-reconnect is not feasible because SonoBus uses + // ephemeral UDP ports that change on each launch. The saved address only + // pre-fills the Direct Connect text field for convenience. Array recents; getRecentServerConnectionInfos(recents); diff --git a/audio.md b/audio.md index 47d198881..95b6fd374 100644 --- a/audio.md +++ b/audio.md @@ -7,8 +7,66 @@ Fork SonoBus to add WASAPI loopback capture, per-process audio capture, and UX i - **Upstream**: https://github.com/sonosaurus/sonobus - **Fork**: https://github.com/mthwJsmith/sonobus - **Branch**: `feature/wasapi-loopback-and-improvements` +- **Base**: JUCE 7 (sono7good) — JUCE 8 had shared mode loopback issues, reverted -## Features +## Current Status + +### WORKING +- **WASAPI Loopback Capture** — loopback devices show in input list, audio levels confirmed +- **Application Audio (per-process capture)** — Win11 API working, shows running audio apps +- **Save Direct Connect Address** — pre-fills last used IP:port +- **Build system** — compiles with VS2022, ASIO SDK, on JUCE 7 + +### BROKEN — Direct peer connection not working +- Our custom build cannot establish direct peer connections (raw connect) +- The old installed SonoBus 1.7.2 (`C:\Program Files\SonoBus\SonoBus.exe`) works perfectly +- Same version (1.7.2), same network, same firewall rules — so something in our code changes broke it +- **Not a network issue** — ping 192.168.1.2 works, <1ms +- **Not a firewall issue** — firewall rules exist for our exe paths + +### Known Issues +- **Loopback + same output device conflict** — in shared mode, can't use same device as both output AND loopback input. Set output to `<< none >>` on sending PC (which is correct for the use case anyway) +- **JUCE 8 shared mode loopback broken** — reverted to JUCE 7 +- **Auto-reconnect direct peer removed** — ports are ephemeral, can't auto-reconnect (saved address only pre-fills the text field) + +## Debugging Plan — Connection Issue + +Need to isolate which change broke direct connect. Plan: build multiple versions with incremental changes, test each on USB drive. + +### Test Builds (put all on D:\ USB drive) + +1. **`SonoBus-v0-clean.exe`** — Fresh clone of upstream, zero changes. Should work like installed version. If this doesn't work either, the issue is build config not code changes. + +2. **`SonoBus-v1-loopback.exe`** — Only WASAPI loopback changes to `juce_WASAPI_windows.cpp`. No processor/connect/Application Audio changes. Tests if JUCE WASAPI modifications broke networking. + +3. **`SonoBus-v2-connect.exe`** — Loopback + save direct connect address (ConnectView.cpp + SonobusPluginProcessor changes). Tests if the processor state changes broke networking. + +4. **`SonoBus-v3-full.exe`** — Everything including Application Audio. Current state. + +### How to test each +- Run on both PCs +- Try raw connect with 192.168.1.2:port +- If connection works → that version is fine, bug is in the next version's additions + +### How to build each version +```bash +# From sonobus directory +CMAKE_TOOLCHAIN_FILE="" cmake -B build -G "Visual Studio 17 2022" -A x64 +CMAKE_TOOLCHAIN_FILE="" cmake --build build --config Release --target SonoBus_Standalone +# Output: build/SonoBus_artefacts/Release/Standalone/SonoBus.exe +``` + +### How to get clean source +```bash +# Clean clone (already done at ../sonobus-clean) +cd /c/Users/mthwj/documents/workspace/audio +git clone https://github.com/sonosaurus/sonobus.git sonobus-clean + +# Or in existing repo, check out specific commits: +# git stash / git checkout / build / git checkout - / git stash pop +``` + +## Features Detail ### 1. Save Direct Connect Address — DONE - Saves last used IP:port to processor state (extraState tree) @@ -33,26 +91,16 @@ Fork SonoBus to add WASAPI loopback capture, per-process audio capture, and UX i ### 3. Per-Process Audio Capture (Win11 API) — DONE - New files: `Source/ProcessAudioCapture.h`, `Source/ProcessAudioCapture.cpp`, `Source/ApplicationAudioDevice.h` - Uses `AUDIOCLIENT_PROCESS_LOOPBACK_PARAMS` + `ActivateAudioInterfaceAsync` -- Captures audio from a specific app (e.g. SnowRunner.exe) instead of all system audio -- No special permissions needed, no drivers -- Windows 10 Build 20348+ / Windows 11 -- Shows as **"Application Audio"** in Audio Device Type dropdown -- Lists running processes as input devices -- Fully wired into SonoBus UI - -### 4. Auto-Reconnect Direct Peer — DONE -- Extended existing `reconnectToMostRecent()` to also try last direct peer connection -- If saved direct connect address exists, reconnects to it on startup -- Falls back to server/group reconnect if no direct address saved -- Uses the existing "Reconnect Last" checkbox in options - -### 5. Update JUCE to sono8good — PENDING -- Current: essej/JUCE `sono7good` branch -- Target: essej/JUCE `sono8good` branch (JUCE 8, updated Jan 2026) -- Should be done carefully — reapply loopback changes on top of new base - -### 6. Push & Build Verification — PENDING -- Push to fork, verify CMake + VS2022 build on Windows +- Completion handler MUST use WRL `RuntimeClass` for free-threaded marshaling +- `GetMixFormat()` returns E_NOTIMPL for process loopback — use caller's requested sample rate +- Do NOT call `CoInitializeEx` in `getAudioProcesses()` — JUCE's message thread already has COM +- `getIndexOfDevice()` must null-check the device pointer +- Dummy output device name required so JUCE's AudioDeviceSelectorComponent doesn't crash +- Application Audio device type added AFTER `deviceManager.initialise()` so WASAPI is default + +### 4. Save Direct Connect Address — DONE +- Pre-fills last used address in Direct Connect dialog +- Auto-reconnect removed (ephemeral ports make it impossible) ## Architecture Notes @@ -61,17 +109,16 @@ Fork SonoBus to add WASAPI loopback capture, per-process audio capture, and UX i - Must use `AUDCLNT_SHAREMODE_SHARED` (exclusive mode not supported) - Captures post-mix PCM audio digitally before DAC — completely lossless - Event-driven supported on Win10 1703+ -- Device enumeration: render endpoints show up as capture sources with "(Loopback)" suffix - Loopback device IDs use `JUCE_LOOPBACK::` prefix to distinguish from regular capture devices +- **Cannot use same device as both output AND loopback input in shared mode** — set output to none on sender ### Per-Process Capture (how it works) - `ActivateAudioInterfaceAsync` with `AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK` - `AUDIOCLIENT_PROCESS_LOOPBACK_PARAMS` specifies target PID -- Include mode: capture only that process's audio -- Exclude mode: capture everything except that process -- Async initialization (callback-based), different from normal WASAPI flow -- Not tied to specific audio endpoint — captures from all endpoints where process renders -- Requires Windows 10 Build 20348+ (checked at runtime) +- Requires WRL RuntimeClass with FtmBase (bare IUnknown returns CO_E_NOT_SUPPORTED 0x8000000E) +- `GetMixFormat()` returns E_NOTIMPL — must hardcode format (32-bit float stereo at requested rate) +- AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM handles any resampling +- Requires Windows 10 Build 20348+ (checked at runtime via RtlGetVersion) ### SonoBus Network Protocol - Audio over OSC (AoO) — UDP peer-to-peer @@ -99,39 +146,39 @@ CMAKE_TOOLCHAIN_FILE="" cmake --build build --config Release --target SonoBus_St ``` Requires: CMake 3.15+, Visual Studio 2022, Windows SDK, ASIO SDK -## How to Use WASAPI Loopback +## How to Use + +### WASAPI Loopback (Sending PC) +1. Audio Device Type: **Windows Audio** +2. Input: **Your output device (Loopback)** — e.g. "Speakers (Realtek) (Loopback)" +3. Output: **<< none >>** (IMPORTANT: don't set output to same device as loopback input) +4. Direct Connect to other PC's IP:port -1. Open SonoBus (the built exe, not the installed one) -2. Go to the gear/settings icon -3. Set **Audio Device Type** to **"Windows Audio"** (this is WASAPI shared mode) -4. In the **Input** dropdown, you should now see your output devices listed with **(Loopback)** suffix, e.g.: - - `Speakers (Realtek High Definition Audio) (Loopback)` - - `DELL S2722QC (NVIDIA High Definition Audio) (Loopback)` -5. Select the loopback device matching where your game/desktop audio plays -6. The **Output** dropdown stays as your normal playback device (or leave empty if you're only sending) -7. Connect to your other PC via Direct Connect as usual +### Application Audio (Sending PC, Win11 only) +1. Audio Device Type: **Application Audio** +2. Input: Pick the process (e.g. "brave.exe (PID 1234)") +3. Output shows "(No output - capture only)" — this is normal +4. Direct Connect to other PC's IP:port -This captures your desktop/game audio digitally (lossless PCM) and sends it over SonoBus — no Voicemeeter, no virtual cables, no restart issues. +### Receiving PC +1. Audio Device Type: **Windows Audio** +2. Input: **<< none >>** +3. Output: **Apple DAC / speakers** ## Key Files - `Source/ConnectView.cpp` — direct connect UI, saves last address -- `Source/SonobusPluginProcessor.cpp` — state save/load, connection logic, auto-reconnect +- `Source/SonobusPluginProcessor.cpp` — state save/load, connection logic - `Source/ProcessAudioCapture.cpp/.h` — per-process audio capture (Win11 API) +- `Source/ApplicationAudioDevice.h` — Application Audio device type for JUCE +- `Source/SonoStandaloneFilterWindow.h` — where Application Audio type is registered - `deps/juce/.../juce_WASAPI_windows.cpp` — WASAPI device enumeration & loopback capture - `deps/aoo/` — Audio over OSC networking library -- `CMakeLists.txt` — build config (ProcessAudioCapture added) - -## Files Changed Summary -``` -Modified: - CMakeLists.txt — added ProcessAudioCapture to build - Source/ConnectView.cpp — save/load direct connect address - Source/SonobusPluginProcessor.cpp — persist address, auto-reconnect direct peer - Source/SonobusPluginProcessor.h — lastDirectConnectAddress member + accessors - deps/juce/.../juce_WASAPI_windows.cpp — WASAPI loopback capture support - -New: - Source/ProcessAudioCapture.cpp — Win11 per-process audio capture - Source/ProcessAudioCapture.h — header for above - audio.md — this file -``` +- `CMakeLists.txt` — build config + +## Bugs Found & Fixed +- **JUCE crash: null device in getIndexOfDevice** — JUCE passes nullptr when no device open, our code called `device->getName()` on it +- **JUCE crash: empty output device list** — AudioDeviceSelectorComponent crashes if getDeviceNames(false) returns empty, need dummy output +- **WRL FtmBase required** — ActivateAudioInterfaceAsync returns 0x8000000E without free-threaded marshaling +- **GetMixFormat E_NOTIMPL** — process loopback doesn't support GetMixFormat, must use hardcoded format +- **COM apartment conflict** — don't call CoInitializeEx in getAudioProcesses, JUCE message thread is already STA +- **JUCE 8 shared mode loopback** — broken, reverted to JUCE 7 From 3d4f1ab36e87a49ea68d08a8c885b28ea8a7cde1 Mon Sep 17 00:00:00 2001 From: mthwJsmith Date: Wed, 25 Mar 2026 21:21:01 +0000 Subject: [PATCH 09/10] =?UTF-8?q?Remove=20Application=20Audio=20(per-proce?= =?UTF-8?q?ss=20capture)=20=E2=80=94=20audio=20quality=20broken?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WASAPI loopback and save-address features remain. Application Audio code preserved on feature/application-audio-wip-broken branch. --- CMakeLists.txt | 3 - FORK_README.md | 20 +- Source/ApplicationAudioDevice.h | 231 --------------- Source/ProcessAudioCapture.cpp | 429 ---------------------------- Source/ProcessAudioCapture.h | 76 ----- Source/SonoStandaloneFilterWindow.h | 8 - 6 files changed, 2 insertions(+), 765 deletions(-) delete mode 100644 Source/ApplicationAudioDevice.h delete mode 100644 Source/ProcessAudioCapture.cpp delete mode 100644 Source/ProcessAudioCapture.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 6821378d0..f3e0f1511 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -321,9 +321,6 @@ function(sono_add_custom_plugin_target target_name product_name formats is_instr Source/SonobusPluginEditor.h Source/SonobusPluginProcessor.cpp Source/SonobusPluginProcessor.h - Source/ProcessAudioCapture.cpp - Source/ProcessAudioCapture.h - Source/ApplicationAudioDevice.h Source/SonobusTypes.h Source/SuggestNewGroupView.cpp Source/SuggestNewGroupView.h diff --git a/FORK_README.md b/FORK_README.md index 97b362684..6fbb67f80 100644 --- a/FORK_README.md +++ b/FORK_README.md @@ -1,4 +1,4 @@ -# SonoBus Fork — WASAPI Loopback & Application Audio Capture +# SonoBus Fork — WASAPI Loopback Capture A fork of [SonoBus](https://github.com/sonosaurus/sonobus) that adds native Windows audio capture features, eliminating the need for Voicemeeter, VB-Cable, or any virtual audio cable software. @@ -7,15 +7,9 @@ A fork of [SonoBus](https://github.com/sonosaurus/sonobus) that adds native Wind ### WASAPI Loopback Capture Every output device (speakers, headphones, monitors) now appears as a **(Loopback)** input device in SonoBus. Select one to capture all audio playing through that device — lossless, digital, no extra software. -### Application Audio Capture (Windows 11) -A new **"Application Audio"** device type that lists running processes. Select a specific app (e.g. SnowRunner.exe) to capture only its audio. No output device needed at all. Requires Windows 10 Build 20348+ / Windows 11. - ### Save Direct Connect Address The last used direct connect IP:port is saved and pre-filled on next launch. -### Auto-Reconnect Direct Peer -The "Reconnect Last" option now also reconnects to the last direct peer connection on startup. - ## Direct Ethernet Setup Guide You can connect two PCs with a single ethernet cable for ultra-low-latency audio streaming. No router or internet needed. @@ -53,7 +47,6 @@ On each PC, configure the ethernet adapter with a static IP: - Output: **<< none >>** - Set Windows default output to any device, mute it in Windows if you don't want local sound -Or use **Application Audio** device type to capture a specific game directly (Win11 only). **Receiving PC (with DAC/amp/speakers):** - Audio Device Type: **Windows Audio** @@ -69,9 +62,7 @@ Three options, from easiest to most involved: 1. **Mute a real device** — Set any real output device (e.g. Realtek onboard) as Windows default, mute it in Windows. WASAPI loopback still captures audio even when muted. Easiest option. -2. **Application Audio** — Use the "Application Audio" device type to capture a specific app directly. No output device needed. Windows 11 only. - -3. **Virtual Audio Driver** — If your PC genuinely has no audio output at all, install [Virtual-Audio-Driver](https://github.com/VirtualDrivers/Virtual-Audio-Driver) (open source, MIT) to create a virtual output, then use its loopback. Requires test signing mode. +2. **Virtual Audio Driver** — If your PC genuinely has no audio output at all, install [Virtual-Audio-Driver](https://github.com/VirtualDrivers/Virtual-Audio-Driver) (open source, MIT) to create a virtual output, then use its loopback. Requires test signing mode. ## Download @@ -112,13 +103,6 @@ CMAKE_TOOLCHAIN_FILE="" cmake --build build --config Release --target SonoBus_St - Forces shared mode (required by Windows) - Changes in `deps/juce/modules/juce_audio_devices/native/juce_WASAPI_windows.cpp` -### Per-Process Audio Capture -- `ActivateAudioInterfaceAsync` with `AUDIOCLIENT_PROCESS_LOOPBACK_PARAMS` -- Captures from a specific process tree -- No output device, virtual cable, or driver needed -- Windows 10 Build 20348+ (checked at runtime) -- `Source/ProcessAudioCapture.cpp` and `Source/ApplicationAudioDevice.h` - ## Upstream Issues Addressed - [#241 — WASAPI Loopback as Input Device](https://github.com/sonosaurus/sonobus/issues/241) - [#53 — Save previous direct connection IP](https://github.com/sonosaurus/sonobus/issues/53) diff --git a/Source/ApplicationAudioDevice.h b/Source/ApplicationAudioDevice.h deleted file mode 100644 index 3dc4218f5..000000000 --- a/Source/ApplicationAudioDevice.h +++ /dev/null @@ -1,231 +0,0 @@ -// SPDX-License-Identifier: GPLv3-or-later WITH Appstore-exception -// Copyright (C) 2026 - -#pragma once - -#include "JuceHeader.h" - -#if JUCE_WINDOWS - -#include "ProcessAudioCapture.h" - -//============================================================================== -/** - An AudioIODevice that captures audio from a specific Windows process - using the Windows 11 per-process loopback API. -*/ -class ApplicationAudioDevice final : public AudioIODevice, - private Thread -{ -public: - ApplicationAudioDevice (const String& processName, DWORD processId) - : AudioIODevice ("Application Audio", "Application Audio"), - Thread ("AppAudioCapture"), - targetProcessName (processName), - targetProcessId (processId) - { - } - - ~ApplicationAudioDevice() override - { - close(); - } - - //============================================================================== - StringArray getOutputChannelNames() override { return {}; } - StringArray getInputChannelNames() override { return { "Left", "Right" }; } - - Array getAvailableSampleRates() override { return { 44100.0, 48000.0, 96000.0 }; } - Array getAvailableBufferSizes() override { return { 256, 512, 1024, 2048, 4096 }; } - int getDefaultBufferSize() override { return 1024; } - - String open (const BigInteger& inputChannels, - const BigInteger& outputChannels, - double sampleRate, - int bufferSizeSamples) override - { - currentSampleRate = sampleRate; - currentBufferSize = bufferSizeSamples; - activeInputChannels = inputChannels; - - if (! capture.startCapture (targetProcessId, sampleRate, 2, bufferSizeSamples)) - return "Failed to start process audio capture. Requires Windows 10 Build 20348+ and the target process must be running."; - - isOpen_ = true; - return {}; - } - - void close() override - { - stop(); - capture.stopCapture(); - isOpen_ = false; - } - - bool isOpen() override { return isOpen_; } - bool isPlaying() override { return isStarted; } - - int getCurrentBufferSizeSamples() override { return currentBufferSize; } - double getCurrentSampleRate() override { return currentSampleRate; } - int getCurrentBitDepth() override { return 32; } - - BigInteger getActiveOutputChannels() const override { return {}; } - BigInteger getActiveInputChannels() const override { return activeInputChannels; } - - int getOutputLatencyInSamples() override { return 0; } - int getInputLatencyInSamples() override { return currentBufferSize; } - - bool hasControlPanel() const override { return false; } - bool showControlPanel() override { return false; } - bool setAudioPreprocessingEnabled (bool) override { return false; } - - String getLastError() override { return lastError; } - - void start (AudioIODeviceCallback* newCallback) override - { - if (newCallback != nullptr && isOpen_ && ! isStarted) - { - callback = newCallback; - callback->audioDeviceAboutToStart (this); - isStarted = true; - startThread (Thread::Priority::high); - } - } - - void stop() override - { - if (isStarted) - { - isStarted = false; - stopThread (2000); - - if (callback != nullptr) - { - callback->audioDeviceStopped(); - callback = nullptr; - } - } - } - -private: - void run() override - { - AudioBuffer inputBuffer (2, currentBufferSize); - - while (! threadShouldExit() && isStarted) - { - inputBuffer.clear(); - capture.readSamples (inputBuffer, currentBufferSize); - - const float* inputChannelData[2] = { - inputBuffer.getReadPointer (0), - inputBuffer.getReadPointer (1) - }; - - if (callback != nullptr) - { - callback->audioDeviceIOCallbackWithContext (inputChannelData, 2, - nullptr, 0, - currentBufferSize, {}); - } - - // Sleep based on buffer size to match expected callback rate - auto sleepMs = (int) (1000.0 * currentBufferSize / currentSampleRate); - Thread::sleep (jmax (1, sleepMs - 1)); - } - } - - ProcessAudioCapture capture; - String targetProcessName; - DWORD targetProcessId; - - bool isOpen_ = false; - bool isStarted = false; - double currentSampleRate = 48000.0; - int currentBufferSize = 1024; - BigInteger activeInputChannels; - String lastError; - AudioIODeviceCallback* callback = nullptr; - - JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (ApplicationAudioDevice) -}; - - -//============================================================================== -/** - AudioIODeviceType that enumerates running processes as input devices. - Shows up as "Application Audio" in the Audio Device Type dropdown. -*/ -class ApplicationAudioDeviceType final : public AudioIODeviceType -{ -public: - ApplicationAudioDeviceType() - : AudioIODeviceType ("Application Audio") - { - // JUCE's AudioDeviceSelectorComponent crashes if output device list is empty, - // so provide a dummy output device name - outputNames.add ("(No output - capture only)"); - } - - void scanForDevices() override - { - hasScanned = true; - inputNames.clear(); - inputPids.clear(); - - if (! ProcessAudioCapture::isSupported()) - return; - - auto processes = ProcessAudioCapture::getAudioProcesses(); - for (auto& proc : processes) - { - inputNames.add (proc.displayName); - inputPids.add (proc.pid); - } - } - - StringArray getDeviceNames (bool wantInputNames) const override - { - return wantInputNames ? inputNames : outputNames; - } - - int getDefaultDeviceIndex (bool) const override - { - return 0; - } - - int getIndexOfDevice (AudioIODevice* device, bool asInput) const override - { - if (device == nullptr || ! asInput) - return -1; - - for (int i = 0; i < inputNames.size(); ++i) - if (inputNames[i] == device->getName()) - return i; - - return -1; - } - - bool hasSeparateInputsAndOutputs() const override { return true; } - - AudioIODevice* createDevice (const String& outputDeviceName, - const String& inputDeviceName) override - { - auto index = inputNames.indexOf (inputDeviceName); - - if (index >= 0) - return new ApplicationAudioDevice (inputDeviceName, inputPids[index]); - - return nullptr; - } - -private: - bool hasScanned = false; - StringArray inputNames; - StringArray outputNames; - Array inputPids; - - JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (ApplicationAudioDeviceType) -}; - -#endif // JUCE_WINDOWS diff --git a/Source/ProcessAudioCapture.cpp b/Source/ProcessAudioCapture.cpp deleted file mode 100644 index 0b8b7e62a..000000000 --- a/Source/ProcessAudioCapture.cpp +++ /dev/null @@ -1,429 +0,0 @@ -// SPDX-License-Identifier: GPLv3-or-later WITH Appstore-exception -// Copyright (C) 2026 - -#include "ProcessAudioCapture.h" - -#if JUCE_WINDOWS - -// Ensure we get the Win10 FE (Iron/20H1) APIs for per-process loopback -#ifndef NTDDI_WIN10_FE -#define NTDDI_WIN10_FE 0x0A00000A -#endif -#if !defined(NTDDI_VERSION) || (NTDDI_VERSION < NTDDI_WIN10_FE) -#undef NTDDI_VERSION -#define NTDDI_VERSION NTDDI_WIN10_FE -#endif - -#include -#include -#include -#include -#include -#include -#include - -using namespace Microsoft::WRL; - -//============================================================================== -// Completion handler using WRL RuntimeClass with FtmBase for free-threaded marshaling. -// This is REQUIRED by ActivateAudioInterfaceAsync — a bare IUnknown implementation -// will return CO_E_NOT_SUPPORTED (0x8000000E). -class LoopbackActivationHandler : - public RuntimeClass, FtmBase, IActivateAudioInterfaceCompletionHandler> -{ -public: - HANDLE completionEvent = nullptr; - IAudioClient* resultClient = nullptr; - HRESULT activateResult = E_FAIL; - - LoopbackActivationHandler() - { - completionEvent = CreateEvent (nullptr, TRUE, FALSE, nullptr); - } - - ~LoopbackActivationHandler() - { - if (completionEvent != nullptr) - CloseHandle (completionEvent); - } - - STDMETHOD(ActivateCompleted) (IActivateAudioInterfaceAsyncOperation* operation) override - { - HRESULT hrActivateResult = E_FAIL; - IUnknown* activatedInterface = nullptr; - - HRESULT hr = operation->GetActivateResult (&hrActivateResult, &activatedInterface); - - if (SUCCEEDED (hr) && SUCCEEDED (hrActivateResult) && activatedInterface != nullptr) - activatedInterface->QueryInterface (__uuidof (IAudioClient), (void**) &resultClient); - - activateResult = hrActivateResult; - SetEvent (completionEvent); - return S_OK; - } - - bool waitForCompletion (DWORD timeoutMs = 10000) - { - return WaitForSingleObject (completionEvent, timeoutMs) == WAIT_OBJECT_0; - } -}; - -//============================================================================== -static String getProcessNameFromPid (DWORD pid) -{ - HANDLE snapshot = CreateToolhelp32Snapshot (TH32CS_SNAPPROCESS, 0); - if (snapshot == INVALID_HANDLE_VALUE) - return {}; - - PROCESSENTRY32W pe; - pe.dwSize = sizeof (pe); - String name; - - if (Process32FirstW (snapshot, &pe)) - { - do - { - if (pe.th32ProcessID == pid) - { - name = String (pe.szExeFile); - break; - } - } while (Process32NextW (snapshot, &pe)); - } - - CloseHandle (snapshot); - return name; -} - -//============================================================================== -ProcessAudioCapture::~ProcessAudioCapture() -{ - stopCapture(); -} - -bool ProcessAudioCapture::isSupported() -{ - // Check if ActivateAudioInterfaceAsync is available - HMODULE mmdevapi = GetModuleHandleW (L"mmdevapi.dll"); - if (mmdevapi == nullptr) - mmdevapi = LoadLibraryW (L"mmdevapi.dll"); - - if (mmdevapi == nullptr) - return false; - - if (GetProcAddress (mmdevapi, "ActivateAudioInterfaceAsync") == nullptr) - return false; - - // Check Windows build number using RtlGetVersion (reliable, not affected by manifests) - using RtlGetVersionFunc = LONG (WINAPI*)(OSVERSIONINFOEXW*); - auto ntdll = GetModuleHandleW (L"ntdll.dll"); - if (ntdll == nullptr) - return false; - - auto rtlGetVersion = reinterpret_cast (GetProcAddress (ntdll, "RtlGetVersion")); - if (rtlGetVersion == nullptr) - return false; - - OSVERSIONINFOEXW osvi = {}; - osvi.dwOSVersionInfoSize = sizeof (osvi); - rtlGetVersion (&osvi); - - return osvi.dwBuildNumber >= 20348; -} - -Array ProcessAudioCapture::getAudioProcesses() -{ - Array result; - - // Use IAudioSessionManager2 to enumerate only processes with active audio sessions - // Note: do NOT call CoInitializeEx here — JUCE's message thread already has COM initialized - - IMMDeviceEnumerator* enumerator = nullptr; - HRESULT hr = CoCreateInstance (__uuidof (MMDeviceEnumerator), nullptr, - CLSCTX_ALL, __uuidof (IMMDeviceEnumerator), - (void**) &enumerator); - if (FAILED (hr) || enumerator == nullptr) - return result; - - IMMDevice* device = nullptr; - hr = enumerator->GetDefaultAudioEndpoint (eRender, eConsole, &device); - if (FAILED (hr) || device == nullptr) - { - enumerator->Release(); - return result; - } - - IAudioSessionManager2* sessionManager = nullptr; - hr = device->Activate (__uuidof (IAudioSessionManager2), CLSCTX_ALL, - nullptr, (void**) &sessionManager); - if (FAILED (hr) || sessionManager == nullptr) - { - device->Release(); - enumerator->Release(); - return result; - } - - IAudioSessionEnumerator* sessionEnumerator = nullptr; - hr = sessionManager->GetSessionEnumerator (&sessionEnumerator); - if (FAILED (hr) || sessionEnumerator == nullptr) - { - sessionManager->Release(); - device->Release(); - enumerator->Release(); - return result; - } - - int sessionCount = 0; - sessionEnumerator->GetCount (&sessionCount); - - Array seenPids; - - for (int i = 0; i < sessionCount; ++i) - { - IAudioSessionControl* sessionControl = nullptr; - if (FAILED (sessionEnumerator->GetSession (i, &sessionControl)) || sessionControl == nullptr) - continue; - - IAudioSessionControl2* sessionControl2 = nullptr; - hr = sessionControl->QueryInterface (__uuidof (IAudioSessionControl2), (void**) &sessionControl2); - sessionControl->Release(); - - if (FAILED (hr) || sessionControl2 == nullptr) - continue; - - if (sessionControl2->IsSystemSoundsSession() == S_OK) - { - sessionControl2->Release(); - continue; - } - - DWORD pid = 0; - hr = sessionControl2->GetProcessId (&pid); - sessionControl2->Release(); - - if (FAILED (hr) || pid == 0 || seenPids.contains (pid)) - continue; - - seenPids.add (pid); - - String name = getProcessNameFromPid (pid); - if (name.isEmpty()) - continue; - - ProcessInfo info; - info.pid = pid; - info.name = name; - info.displayName = name + " (PID " + String (pid) + ")"; - result.add (info); - } - - sessionEnumerator->Release(); - sessionManager->Release(); - device->Release(); - enumerator->Release(); - - struct ProcessInfoComparator - { - int compareElements (const ProcessInfo& a, const ProcessInfo& b) const - { - return a.name.compareIgnoreCase (b.name); - } - }; - - ProcessInfoComparator comparator; - result.sort (comparator); - - return result; -} - -bool ProcessAudioCapture::startCapture (DWORD processId, double sampleRate, int numChannels, int bufferSize) -{ - if (! isSupported()) - return false; - - stopCapture(); - - // Set up activation params for process loopback - AUDIOCLIENT_ACTIVATION_PARAMS activationParams = {}; - activationParams.ActivationType = AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK; - activationParams.ProcessLoopbackParams.TargetProcessId = processId; - activationParams.ProcessLoopbackParams.ProcessLoopbackMode = PROCESS_LOOPBACK_MODE_INCLUDE_TARGET_PROCESS_TREE; - - PROPVARIANT activateParamsPropVariant = {}; - activateParamsPropVariant.vt = VT_BLOB; - activateParamsPropVariant.blob.cbSize = sizeof (activationParams); - activateParamsPropVariant.blob.pBlobData = reinterpret_cast (&activationParams); - - // Create completion handler using WRL (FtmBase required for free-threaded marshaling) - ComPtr handler; - HRESULT hr = MakeAndInitialize (&handler); - if (FAILED (hr)) - return false; - - IActivateAudioInterfaceAsyncOperation* asyncOp = nullptr; - - hr = ActivateAudioInterfaceAsync ( - VIRTUAL_AUDIO_DEVICE_PROCESS_LOOPBACK, - __uuidof (IAudioClient), - &activateParamsPropVariant, - handler.Get(), - &asyncOp); - - if (FAILED (hr)) - { - if (asyncOp) asyncOp->Release(); - return false; - } - - if (! handler->waitForCompletion (10000)) - { - if (asyncOp) asyncOp->Release(); - return false; - } - - if (FAILED (handler->activateResult) || handler->resultClient == nullptr) - { - if (asyncOp) asyncOp->Release(); - return false; - } - - audioClient = handler->resultClient; - audioClient->AddRef(); - - if (asyncOp) asyncOp->Release(); - - // GetMixFormat returns E_NOTIMPL for process loopback — use requested format - WAVEFORMATEX* mixFormat = nullptr; - hr = audioClient->GetMixFormat (&mixFormat); - - bool usingDefaultFormat = false; - static WAVEFORMATEX defaultFormat = {}; - - if (FAILED (hr) || mixFormat == nullptr) - { - defaultFormat.wFormatTag = WAVE_FORMAT_IEEE_FLOAT; - defaultFormat.nChannels = 2; - defaultFormat.nSamplesPerSec = (DWORD) sampleRate; - defaultFormat.wBitsPerSample = 32; - defaultFormat.nBlockAlign = defaultFormat.nChannels * defaultFormat.wBitsPerSample / 8; - defaultFormat.nAvgBytesPerSec = defaultFormat.nSamplesPerSec * defaultFormat.nBlockAlign; - defaultFormat.cbSize = 0; - mixFormat = &defaultFormat; - usingDefaultFormat = true; - } - - captureSampleRate = mixFormat->nSamplesPerSec; - captureNumChannels = mixFormat->nChannels; - - // Initialize with loopback flag (matches Microsoft's sample) - REFERENCE_TIME bufferDuration = 10000000LL; // 1 second buffer - hr = audioClient->Initialize ( - AUDCLNT_SHAREMODE_SHARED, - AUDCLNT_STREAMFLAGS_LOOPBACK | AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM | AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY, - bufferDuration, - 0, - mixFormat, - nullptr); - - if (! usingDefaultFormat) - CoTaskMemFree (mixFormat); - - if (FAILED (hr)) - { - audioClient->Release(); - audioClient = nullptr; - return false; - } - - // Get capture client - hr = audioClient->GetService (__uuidof (IAudioCaptureClient), (void**) &captureClient); - if (FAILED (hr)) - { - audioClient->Release(); - audioClient = nullptr; - return false; - } - - // Start capturing - hr = audioClient->Start(); - if (FAILED (hr)) - { - captureClient->Release(); - captureClient = nullptr; - audioClient->Release(); - audioClient = nullptr; - return false; - } - - capturing.store (true); - return true; -} - -void ProcessAudioCapture::stopCapture() -{ - capturing.store (false); - - if (audioClient != nullptr) - audioClient->Stop(); - - if (captureClient != nullptr) - { - captureClient->Release(); - captureClient = nullptr; - } - - if (audioClient != nullptr) - { - audioClient->Release(); - audioClient = nullptr; - } -} - -int ProcessAudioCapture::readSamples (AudioBuffer& buffer, int numFrames) -{ - if (! capturing.load() || captureClient == nullptr) - return 0; - - int framesRead = 0; - - while (framesRead < numFrames) - { - UINT32 packetLength = 0; - HRESULT hr = captureClient->GetNextPacketSize (&packetLength); - - if (FAILED (hr) || packetLength == 0) - break; - - BYTE* data = nullptr; - UINT32 numFramesAvailable = 0; - DWORD flags = 0; - - hr = captureClient->GetBuffer (&data, &numFramesAvailable, &flags, nullptr, nullptr); - if (FAILED (hr)) - break; - - int framesToCopy = jmin ((int) numFramesAvailable, numFrames - framesRead); - - if (flags & AUDCLNT_BUFFERFLAGS_SILENT) - { - for (int ch = 0; ch < buffer.getNumChannels() && ch < captureNumChannels; ++ch) - FloatVectorOperations::clear (buffer.getWritePointer (ch, framesRead), framesToCopy); - } - else if (data != nullptr) - { - const float* src = reinterpret_cast (data); - for (int frame = 0; frame < framesToCopy; ++frame) - { - for (int ch = 0; ch < buffer.getNumChannels() && ch < captureNumChannels; ++ch) - buffer.getWritePointer (ch)[framesRead + frame] = src[frame * captureNumChannels + ch]; - } - } - - framesRead += framesToCopy; - captureClient->ReleaseBuffer (numFramesAvailable); - } - - return framesRead; -} - -#endif // JUCE_WINDOWS diff --git a/Source/ProcessAudioCapture.h b/Source/ProcessAudioCapture.h deleted file mode 100644 index 64fcc3f37..000000000 --- a/Source/ProcessAudioCapture.h +++ /dev/null @@ -1,76 +0,0 @@ -// SPDX-License-Identifier: GPLv3-or-later WITH Appstore-exception -// Copyright (C) 2026 - -#pragma once - -#include "JuceHeader.h" - -#if JUCE_WINDOWS - -#include -#include - -// Forward declarations — actual Windows audio COM types are only used in .cpp -struct IAudioClient; -struct IAudioCaptureClient; - -//============================================================================== -/** - Per-process audio capture using Windows 11's AUDIOCLIENT_PROCESS_LOOPBACK_PARAMS. - - This captures audio from a specific Windows process (e.g. a game) without needing - a virtual audio cable or loopback driver. - - Requires Windows 10 Build 20348+ / Windows 11. -*/ -class ProcessAudioCapture -{ -public: - ProcessAudioCapture() = default; - ~ProcessAudioCapture(); - - //============================================================================== - struct ProcessInfo - { - DWORD pid; - String name; // e.g. "SnowRunner.exe" - String displayName; // e.g. "SnowRunner.exe (PID 1234)" - }; - - /** Returns a list of currently running processes. */ - static Array getAudioProcesses(); - - /** Returns true if the per-process capture API is available on this OS version. */ - static bool isSupported(); - - //============================================================================== - /** Start capturing audio from the given process ID. - Returns true on success. */ - bool startCapture (DWORD processId, double sampleRate, int numChannels, int bufferSize); - - /** Stop capturing. */ - void stopCapture(); - - /** Returns true if currently capturing. */ - bool isCapturing() const { return capturing.load(); } - - /** Read captured samples into the provided buffer. - Returns the number of frames actually read. */ - int readSamples (AudioBuffer& buffer, int numFrames); - - /** Get the capture format info. */ - double getSampleRate() const { return captureSampleRate; } - int getNumChannels() const { return captureNumChannels; } - -private: - std::atomic capturing { false }; - double captureSampleRate = 0; - int captureNumChannels = 0; - - IAudioClient* audioClient = nullptr; - IAudioCaptureClient* captureClient = nullptr; - - JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (ProcessAudioCapture) -}; - -#endif // JUCE_WINDOWS diff --git a/Source/SonoStandaloneFilterWindow.h b/Source/SonoStandaloneFilterWindow.h index a593fa0c9..56d6cf48f 100644 --- a/Source/SonoStandaloneFilterWindow.h +++ b/Source/SonoStandaloneFilterWindow.h @@ -37,9 +37,6 @@ // HACK #include "SonobusPluginEditor.h" -#if JUCE_WINDOWS -#include "ApplicationAudioDevice.h" -#endif #include #include @@ -450,11 +447,6 @@ class StandalonePluginHolder : private AudioIODeviceCallback, preferredDefaultDeviceName, prefSetupOptions.get()); - #if JUCE_WINDOWS - // Add Application Audio AFTER initialise so WASAPI is the default device type - if (ProcessAudioCapture::isSupported()) - deviceManager.addAudioDeviceType (std::make_unique()); - #endif #if JUCE_IOS // get current audio device and change a setting if necessary From 3376a0773b312fba8b6aa0822bd023b99de53d6f Mon Sep 17 00:00:00 2001 From: mthwJsmith Date: Wed, 25 Mar 2026 21:39:22 +0000 Subject: [PATCH 10/10] =?UTF-8?q?Update=20audio.md=20=E2=80=94=20shipped?= =?UTF-8?q?=20status,=20debugging=20results,=20shelved=20Application=20Aud?= =?UTF-8?q?io?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- audio.md | 128 +++++++++++++++++-------------------------------------- 1 file changed, 39 insertions(+), 89 deletions(-) diff --git a/audio.md b/audio.md index 95b6fd374..7338ea624 100644 --- a/audio.md +++ b/audio.md @@ -1,79 +1,48 @@ # SonoBus Fork — Audio Improvements Plan ## Goal -Fork SonoBus to add WASAPI loopback capture, per-process audio capture, and UX improvements for a direct ethernet audio streaming setup (two PCs, 192.168.1.x, one-way lossless audio → Apple USB-C DAC → amp → speakers). +Fork SonoBus to add WASAPI loopback capture and UX improvements for a direct ethernet audio streaming setup (two PCs, 192.168.1.x, one-way lossless audio → Apple USB-C DAC → amp → speakers). ## Fork - **Upstream**: https://github.com/sonosaurus/sonobus - **Fork**: https://github.com/mthwJsmith/sonobus - **Branch**: `feature/wasapi-loopback-and-improvements` +- **WIP Branch**: `feature/application-audio-wip-broken` (Application Audio — works but audio quality is broken) - **Base**: JUCE 7 (sono7good) — JUCE 8 had shared mode loopback issues, reverted +- **PR**: https://github.com/sonosaurus/sonobus/pull/280 +- **Release**: https://github.com/mthwJsmith/sonobus/releases/tag/v1.7.2-loopback ## Current Status -### WORKING -- **WASAPI Loopback Capture** — loopback devices show in input list, audio levels confirmed -- **Application Audio (per-process capture)** — Win11 API working, shows running audio apps +### SHIPPED (v1.7.2-loopback) +- **WASAPI Loopback Capture** — loopback devices show in input list, audio levels confirmed, sounds great - **Save Direct Connect Address** — pre-fills last used IP:port - **Build system** — compiles with VS2022, ASIO SDK, on JUCE 7 - -### BROKEN — Direct peer connection not working -- Our custom build cannot establish direct peer connections (raw connect) -- The old installed SonoBus 1.7.2 (`C:\Program Files\SonoBus\SonoBus.exe`) works perfectly -- Same version (1.7.2), same network, same firewall rules — so something in our code changes broke it -- **Not a network issue** — ping 192.168.1.2 works, <1ms -- **Not a firewall issue** — firewall rules exist for our exe paths +- **Direct peer connection** — working after clearing stale %APPDATA% state + +### SHELVED — Application Audio (per-process capture) +- Moved to `feature/application-audio-wip-broken` branch +- Win11 API works, shows running audio apps, captures audio +- **Audio quality is broken** — sounds super glitchy/choppy, likely sample rate or buffer mismatch +- Code preserved for future investigation + +### Connection Debugging Results (2025-03-25) +Built v0–v3 test versions to isolate a connection issue: +- **v0-clean** (upstream, no changes) — didn't connect +- **v1-loopback** (WASAPI changes only) — worked +- **v2-connect** (+ save address) — didn't connect initially +- **v3-full** (+ Application Audio) — didn't connect initially +- **Root cause**: stale SonoBus state in `%APPDATA%`. After clearing, all versions worked. +- The WASAPI `else if` → `if` change in `createDevices()` may have incidentally helped device initialization. ### Known Issues - **Loopback + same output device conflict** — in shared mode, can't use same device as both output AND loopback input. Set output to `<< none >>` on sending PC (which is correct for the use case anyway) - **JUCE 8 shared mode loopback broken** — reverted to JUCE 7 -- **Auto-reconnect direct peer removed** — ports are ephemeral, can't auto-reconnect (saved address only pre-fills the text field) - -## Debugging Plan — Connection Issue - -Need to isolate which change broke direct connect. Plan: build multiple versions with incremental changes, test each on USB drive. - -### Test Builds (put all on D:\ USB drive) - -1. **`SonoBus-v0-clean.exe`** — Fresh clone of upstream, zero changes. Should work like installed version. If this doesn't work either, the issue is build config not code changes. - -2. **`SonoBus-v1-loopback.exe`** — Only WASAPI loopback changes to `juce_WASAPI_windows.cpp`. No processor/connect/Application Audio changes. Tests if JUCE WASAPI modifications broke networking. - -3. **`SonoBus-v2-connect.exe`** — Loopback + save direct connect address (ConnectView.cpp + SonobusPluginProcessor changes). Tests if the processor state changes broke networking. - -4. **`SonoBus-v3-full.exe`** — Everything including Application Audio. Current state. - -### How to test each -- Run on both PCs -- Try raw connect with 192.168.1.2:port -- If connection works → that version is fine, bug is in the next version's additions - -### How to build each version -```bash -# From sonobus directory -CMAKE_TOOLCHAIN_FILE="" cmake -B build -G "Visual Studio 17 2022" -A x64 -CMAKE_TOOLCHAIN_FILE="" cmake --build build --config Release --target SonoBus_Standalone -# Output: build/SonoBus_artefacts/Release/Standalone/SonoBus.exe -``` - -### How to get clean source -```bash -# Clean clone (already done at ../sonobus-clean) -cd /c/Users/mthwj/documents/workspace/audio -git clone https://github.com/sonosaurus/sonobus.git sonobus-clean - -# Or in existing repo, check out specific commits: -# git stash / git checkout / build / git checkout - / git stash pop -``` +- **Auto-reconnect direct peer not feasible** — ports are ephemeral, can't auto-reconnect (saved address only pre-fills the text field) ## Features Detail -### 1. Save Direct Connect Address — DONE -- Saves last used IP:port to processor state (extraState tree) -- Pre-fills the direct connect text field on next launch -- Files changed: `SonobusPluginProcessor.h`, `SonobusPluginProcessor.cpp`, `ConnectView.cpp` - -### 2. WASAPI Loopback Capture — DONE +### 1. WASAPI Loopback Capture — DONE - Eliminates need for Voicemeeter/VB-Cable entirely - Adds render devices as "(Loopback)" input devices in JUCE's WASAPI backend - SonoBus can directly capture desktop/game audio (lossless PCM) @@ -88,19 +57,17 @@ git clone https://github.com/sonosaurus/sonobus.git sonobus-clean - Modified `tryFormat()`, `findSupportedFormat()`, `querySupportedSampleRates()` to force shared mode for loopback - `WASAPIInputDevice` constructor accepts loopback flag -### 3. Per-Process Audio Capture (Win11 API) — DONE -- New files: `Source/ProcessAudioCapture.h`, `Source/ProcessAudioCapture.cpp`, `Source/ApplicationAudioDevice.h` -- Uses `AUDIOCLIENT_PROCESS_LOOPBACK_PARAMS` + `ActivateAudioInterfaceAsync` -- Completion handler MUST use WRL `RuntimeClass` for free-threaded marshaling -- `GetMixFormat()` returns E_NOTIMPL for process loopback — use caller's requested sample rate -- Do NOT call `CoInitializeEx` in `getAudioProcesses()` — JUCE's message thread already has COM -- `getIndexOfDevice()` must null-check the device pointer -- Dummy output device name required so JUCE's AudioDeviceSelectorComponent doesn't crash -- Application Audio device type added AFTER `deviceManager.initialise()` so WASAPI is default - -### 4. Save Direct Connect Address — DONE -- Pre-fills last used address in Direct Connect dialog +### 2. Save Direct Connect Address — DONE +- Saves last used IP:port to processor state (extraState tree) +- Pre-fills the direct connect text field on next launch - Auto-reconnect removed (ephemeral ports make it impossible) +- Files changed: `SonobusPluginProcessor.h`, `SonobusPluginProcessor.cpp`, `ConnectView.cpp` + +### 3. Per-Process Audio Capture (Win11 API) — SHELVED +- On `feature/application-audio-wip-broken` branch +- Files: `Source/ProcessAudioCapture.h`, `Source/ProcessAudioCapture.cpp`, `Source/ApplicationAudioDevice.h` +- Uses `AUDIOCLIENT_PROCESS_LOOPBACK_PARAMS` + `ActivateAudioInterfaceAsync` +- Audio quality broken — glitchy/choppy output, needs investigation ## Architecture Notes @@ -112,14 +79,6 @@ git clone https://github.com/sonosaurus/sonobus.git sonobus-clean - Loopback device IDs use `JUCE_LOOPBACK::` prefix to distinguish from regular capture devices - **Cannot use same device as both output AND loopback input in shared mode** — set output to none on sender -### Per-Process Capture (how it works) -- `ActivateAudioInterfaceAsync` with `AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK` -- `AUDIOCLIENT_PROCESS_LOOPBACK_PARAMS` specifies target PID -- Requires WRL RuntimeClass with FtmBase (bare IUnknown returns CO_E_NOT_SUPPORTED 0x8000000E) -- `GetMixFormat()` returns E_NOTIMPL — must hardcode format (32-bit float stereo at requested rate) -- AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM handles any resampling -- Requires Windows 10 Build 20348+ (checked at runtime via RtlGetVersion) - ### SonoBus Network Protocol - Audio over OSC (AoO) — UDP peer-to-peer - Supports PCM 16/24/32-bit and Opus codec @@ -154,12 +113,6 @@ Requires: CMake 3.15+, Visual Studio 2022, Windows SDK, ASIO SDK 3. Output: **<< none >>** (IMPORTANT: don't set output to same device as loopback input) 4. Direct Connect to other PC's IP:port -### Application Audio (Sending PC, Win11 only) -1. Audio Device Type: **Application Audio** -2. Input: Pick the process (e.g. "brave.exe (PID 1234)") -3. Output shows "(No output - capture only)" — this is normal -4. Direct Connect to other PC's IP:port - ### Receiving PC 1. Audio Device Type: **Windows Audio** 2. Input: **<< none >>** @@ -168,17 +121,14 @@ Requires: CMake 3.15+, Visual Studio 2022, Windows SDK, ASIO SDK ## Key Files - `Source/ConnectView.cpp` — direct connect UI, saves last address - `Source/SonobusPluginProcessor.cpp` — state save/load, connection logic -- `Source/ProcessAudioCapture.cpp/.h` — per-process audio capture (Win11 API) -- `Source/ApplicationAudioDevice.h` — Application Audio device type for JUCE -- `Source/SonoStandaloneFilterWindow.h` — where Application Audio type is registered +- `Source/SonoStandaloneFilterWindow.h` — device type registration - `deps/juce/.../juce_WASAPI_windows.cpp` — WASAPI device enumeration & loopback capture - `deps/aoo/` — Audio over OSC networking library - `CMakeLists.txt` — build config ## Bugs Found & Fixed -- **JUCE crash: null device in getIndexOfDevice** — JUCE passes nullptr when no device open, our code called `device->getName()` on it -- **JUCE crash: empty output device list** — AudioDeviceSelectorComponent crashes if getDeviceNames(false) returns empty, need dummy output -- **WRL FtmBase required** — ActivateAudioInterfaceAsync returns 0x8000000E without free-threaded marshaling -- **GetMixFormat E_NOTIMPL** — process loopback doesn't support GetMixFormat, must use hardcoded format -- **COM apartment conflict** — don't call CoInitializeEx in getAudioProcesses, JUCE message thread is already STA +- **Stale %APPDATA% state breaks connections** — clearing SonoBus appdata fixes direct connect issues between different builds - **JUCE 8 shared mode loopback** — broken, reverted to JUCE 7 + +## Resources +- Virtual Audio Driver (for PCs with no real output): https://github.com/VirtualDrivers/Virtual-Audio-Driver