native macos backend, reduce latency

This commit is contained in:
Boof2015
2026-03-21 19:07:47 -04:00
parent 78d8be23a3
commit c6fce51a42
12 changed files with 1304 additions and 112 deletions
+16 -1
View File
@@ -19,13 +19,25 @@
"defines": ["NAPI_DISABLE_CPP_EXCEPTIONS"], "defines": ["NAPI_DISABLE_CPP_EXCEPTIONS"],
"conditions": [ "conditions": [
["OS=='mac'", { ["OS=='mac'", {
"sources": [
"src/macos_capture.mm"
],
"xcode_settings": { "xcode_settings": {
"GCC_ENABLE_CPP_EXCEPTIONS": "YES", "GCC_ENABLE_CPP_EXCEPTIONS": "YES",
"CLANG_CXX_LIBRARY": "libc++", "CLANG_CXX_LIBRARY": "libc++",
"MACOSX_DEPLOYMENT_TARGET": "10.15" "CLANG_ENABLE_OBJC_ARC": "YES",
"MACOSX_DEPLOYMENT_TARGET": "10.15",
"OTHER_LDFLAGS": [
"-framework Foundation",
"-framework CoreAudio",
"-framework AudioToolbox"
]
} }
}], }],
["OS=='win'", { ["OS=='win'", {
"sources": [
"src/macos_capture_stub.cpp"
],
"msvs_settings": { "msvs_settings": {
"VCCLCompilerTool": { "VCCLCompilerTool": {
"ExceptionHandling": 1, "ExceptionHandling": 1,
@@ -34,6 +46,9 @@
} }
}], }],
["OS=='linux'", { ["OS=='linux'", {
"sources": [
"src/macos_capture_stub.cpp"
],
"cflags_cc": ["-std=c++17", "-O3", "-ffast-math", "-fPIC"] "cflags_cc": ["-std=c++17", "-O3", "-ffast-math", "-fPIC"]
}] }]
] ]
+5
View File
@@ -0,0 +1,5 @@
#pragma once
#include <napi.h>
void RegisterMacOSCapture(Napi::Env env, Napi::Object exports);
+839
View File
@@ -0,0 +1,839 @@
#include "macos_capture.h"
#if defined(__APPLE__)
#import <Foundation/Foundation.h>
#import <CoreAudio/CoreAudio.h>
#import <CoreAudio/AudioHardwareTapping.h>
#import <CoreAudio/CATapDescription.h>
#include <algorithm>
#include <chrono>
#include <cstdint>
#include <cstring>
#include <deque>
#include <limits>
#include <mutex>
#include <string>
#include <vector>
namespace {
constexpr size_t kMaxQueuedChunks = 256;
constexpr size_t kDefaultDrainChunkLimit = 64;
constexpr AudioObjectID kUnknownObject = kAudioObjectUnknown;
struct OutputDeviceInfo {
std::string uid;
std::string label;
double sampleRate;
UInt32 channelCount;
bool isDefault;
};
struct CapturedChunk {
std::vector<float> left;
std::vector<float> right;
UInt32 channelCount = 2;
double capturedAtMilliseconds = 0.0;
uint64_t sequence = 0;
};
double monotonicMilliseconds() {
const auto now = std::chrono::steady_clock::now().time_since_epoch();
return std::chrono::duration<double, std::milli>(now).count();
}
std::string cfStringToStdString(CFStringRef value) {
if (value == nullptr) {
return {};
}
const CFIndex length = CFStringGetLength(value);
const CFIndex maxBytes =
CFStringGetMaximumSizeForEncoding(length, kCFStringEncodingUTF8) + 1;
std::vector<char> buffer(static_cast<size_t>(std::max<CFIndex>(1, maxBytes)), '\0');
if (!CFStringGetCString(value, buffer.data(), maxBytes, kCFStringEncodingUTF8)) {
return {};
}
return std::string(buffer.data());
}
NSString* toNSString(const std::string& value) {
return [[NSString alloc] initWithUTF8String:value.c_str()];
}
template <typename T>
bool getPropertyData(AudioObjectID objectId,
AudioObjectPropertySelector selector,
AudioObjectPropertyScope scope,
AudioObjectPropertyElement element,
T* outValue) {
if (outValue == nullptr) {
return false;
}
AudioObjectPropertyAddress address{
selector,
scope,
element,
};
UInt32 size = sizeof(T);
return AudioObjectGetPropertyData(objectId, &address, 0, nullptr, &size, outValue) == noErr;
}
bool getDeviceStringProperty(AudioDeviceID deviceId,
AudioObjectPropertySelector selector,
std::string* outValue) {
if (outValue == nullptr) {
return false;
}
AudioObjectPropertyAddress address{
selector,
kAudioObjectPropertyScopeGlobal,
kAudioObjectPropertyElementMain,
};
CFStringRef stringValue = nullptr;
UInt32 size = sizeof(stringValue);
const OSStatus status =
AudioObjectGetPropertyData(deviceId, &address, 0, nullptr, &size, &stringValue);
if (status != noErr || stringValue == nullptr) {
return false;
}
*outValue = cfStringToStdString(stringValue);
CFRelease(stringValue);
return !outValue->empty();
}
AudioDeviceID getDefaultOutputDeviceId() {
AudioDeviceID deviceId = kUnknownObject;
if (!getPropertyData(kAudioObjectSystemObject,
kAudioHardwarePropertyDefaultOutputDevice,
kAudioObjectPropertyScopeGlobal,
kAudioObjectPropertyElementMain,
&deviceId)) {
return kUnknownObject;
}
return deviceId;
}
bool getDeviceNominalSampleRate(AudioDeviceID deviceId, double* outSampleRate) {
if (outSampleRate == nullptr) {
return false;
}
Float64 sampleRate = 0.0;
if (!getPropertyData(deviceId,
kAudioDevicePropertyNominalSampleRate,
kAudioObjectPropertyScopeGlobal,
kAudioObjectPropertyElementMain,
&sampleRate)) {
return false;
}
if (sampleRate <= 0.0) {
return false;
}
*outSampleRate = static_cast<double>(sampleRate);
return true;
}
UInt32 getOutputChannelCount(AudioDeviceID deviceId) {
AudioObjectPropertyAddress address{
kAudioDevicePropertyStreamConfiguration,
kAudioDevicePropertyScopeOutput,
kAudioObjectPropertyElementMain,
};
UInt32 size = 0;
if (AudioObjectGetPropertyDataSize(deviceId, &address, 0, nullptr, &size) != noErr ||
size == 0) {
return 0;
}
std::vector<uint8_t> storage(size);
auto* bufferList = reinterpret_cast<AudioBufferList*>(storage.data());
if (AudioObjectGetPropertyData(deviceId, &address, 0, nullptr, &size, bufferList) !=
noErr) {
return 0;
}
UInt32 channelCount = 0;
for (UInt32 index = 0; index < bufferList->mNumberBuffers; ++index) {
channelCount += bufferList->mBuffers[index].mNumberChannels;
}
return channelCount;
}
std::vector<OutputDeviceInfo> enumerateOutputDevices() {
AudioObjectPropertyAddress address{
kAudioHardwarePropertyDevices,
kAudioObjectPropertyScopeGlobal,
kAudioObjectPropertyElementMain,
};
UInt32 size = 0;
if (AudioObjectGetPropertyDataSize(kAudioObjectSystemObject, &address, 0, nullptr, &size) !=
noErr ||
size == 0) {
return {};
}
const UInt32 deviceCount = size / sizeof(AudioDeviceID);
std::vector<AudioDeviceID> deviceIds(deviceCount, kUnknownObject);
if (AudioObjectGetPropertyData(
kAudioObjectSystemObject, &address, 0, nullptr, &size, deviceIds.data()) != noErr) {
return {};
}
const AudioDeviceID defaultDeviceId = getDefaultOutputDeviceId();
std::vector<OutputDeviceInfo> devices;
devices.reserve(deviceCount);
for (AudioDeviceID deviceId : deviceIds) {
const UInt32 channelCount = getOutputChannelCount(deviceId);
if (channelCount == 0) {
continue;
}
std::string uid;
if (!getDeviceStringProperty(deviceId, kAudioDevicePropertyDeviceUID, &uid)) {
continue;
}
std::string label;
if (!getDeviceStringProperty(deviceId, kAudioObjectPropertyName, &label)) {
label = uid;
}
double sampleRate = 0.0;
if (!getDeviceNominalSampleRate(deviceId, &sampleRate)) {
sampleRate = 48000.0;
}
devices.push_back(OutputDeviceInfo{
uid,
label,
sampleRate,
channelCount,
deviceId == defaultDeviceId,
});
}
return devices;
}
bool getTapFormat(AudioObjectID tapId, AudioStreamBasicDescription* outFormat) {
if (outFormat == nullptr) {
return false;
}
AudioObjectPropertyAddress address{
kAudioTapPropertyFormat,
kAudioObjectPropertyScopeGlobal,
kAudioObjectPropertyElementMain,
};
UInt32 size = sizeof(AudioStreamBasicDescription);
return AudioObjectGetPropertyData(tapId, &address, 0, nullptr, &size, outFormat) == noErr;
}
float decodeSignedIntegerSample(const uint8_t* data, UInt32 bytesPerSample, bool isBigEndian) {
if (data == nullptr || bytesPerSample == 0 || bytesPerSample > 4) {
return 0.0f;
}
int32_t rawValue = 0;
if (isBigEndian) {
for (UInt32 byteIndex = 0; byteIndex < bytesPerSample; ++byteIndex) {
rawValue = (rawValue << 8) | data[byteIndex];
}
} else {
for (UInt32 byteIndex = 0; byteIndex < bytesPerSample; ++byteIndex) {
rawValue |= static_cast<int32_t>(data[byteIndex]) << (byteIndex * 8);
}
}
const UInt32 totalBits = bytesPerSample * 8;
const int32_t signMask = 1 << (totalBits - 1);
if ((rawValue & signMask) != 0) {
rawValue |= ~((1 << totalBits) - 1);
}
const double maxMagnitude = static_cast<double>((1u << (totalBits - 1)) - 1u);
if (maxMagnitude <= 0.0) {
return 0.0f;
}
return static_cast<float>(static_cast<double>(rawValue) / maxMagnitude);
}
float readSampleFromFormat(const uint8_t* data,
const AudioStreamBasicDescription& format,
UInt32 sampleIndex) {
if (data == nullptr) {
return 0.0f;
}
const UInt32 bytesPerChannel = format.mBitsPerChannel / 8;
if (bytesPerChannel == 0) {
return 0.0f;
}
const uint8_t* samplePtr = data + static_cast<size_t>(sampleIndex) * bytesPerChannel;
const bool isFloat = (format.mFormatFlags & kAudioFormatFlagIsFloat) != 0;
const bool isBigEndian = (format.mFormatFlags & kAudioFormatFlagIsBigEndian) != 0;
if (isFloat && format.mBitsPerChannel == 32) {
Float32 value = 0.0f;
std::memcpy(&value, samplePtr, sizeof(Float32));
return value;
}
if (isFloat && format.mBitsPerChannel == 64) {
Float64 value = 0.0;
std::memcpy(&value, samplePtr, sizeof(Float64));
return static_cast<float>(value);
}
if ((format.mFormatFlags & kAudioFormatFlagIsSignedInteger) != 0) {
return decodeSignedIntegerSample(samplePtr, bytesPerChannel, isBigEndian);
}
return 0.0f;
}
bool isFormatInterleaved(const AudioStreamBasicDescription& format) {
return (format.mFormatFlags & kAudioFormatFlagIsNonInterleaved) == 0;
}
std::string formatStatusMessage(const char* operation, OSStatus status) {
return std::string(operation) + " failed (" + std::to_string(static_cast<int>(status)) + ")";
}
class MacOSNativeCaptureEngine {
public:
Napi::Object GetSupport(Napi::Env env) {
Napi::Object support = Napi::Object::New(env);
if (@available(macOS 14.2, *)) {
support.Set("available", Napi::Boolean::New(env, true));
support.Set("reason", env.Null());
return support;
}
support.Set("available", Napi::Boolean::New(env, false));
support.Set(
"reason",
Napi::String::New(env, "Native output-device capture requires macOS 14.2 or newer."));
return support;
}
Napi::Array ListOutputDevices(Napi::Env env) {
Napi::Array result = Napi::Array::New(env);
if (!isSupported()) {
return result;
}
const auto devices = enumerateOutputDevices();
for (size_t index = 0; index < devices.size(); ++index) {
const auto& device = devices[index];
Napi::Object entry = Napi::Object::New(env);
entry.Set("id", Napi::String::New(env, device.uid));
entry.Set("label", Napi::String::New(env, device.label));
entry.Set("kind", Napi::String::New(env, "system"));
entry.Set("isDefault", Napi::Boolean::New(env, device.isDefault));
entry.Set("sampleRate", Napi::Number::New(env, device.sampleRate));
entry.Set(
"channelCount",
Napi::Number::New(env, static_cast<double>(device.channelCount)));
result.Set(static_cast<uint32_t>(index), entry);
}
return result;
}
Napi::Object Start(Napi::Env env, const std::string& requestedDeviceUid) {
Napi::Object result = Napi::Object::New(env);
const Napi::Object support = GetSupport(env);
if (!support.Get("available").As<Napi::Boolean>().Value()) {
Napi::Error::New(
env, support.Get("reason").As<Napi::String>().Utf8Value())
.ThrowAsJavaScriptException();
return result;
}
std::string errorMessage;
if (!startInternal(requestedDeviceUid, &errorMessage)) {
Napi::Error::New(env, errorMessage).ThrowAsJavaScriptException();
return result;
}
std::lock_guard<std::mutex> lock(stateMutex_);
result.Set("sampleRate", Napi::Number::New(env, sampleRate_));
result.Set("channelCount", Napi::Number::New(env, static_cast<double>(channelCount_)));
result.Set("deviceId", Napi::String::New(env, activeDeviceUid_));
result.Set("deviceLabel", Napi::String::New(env, activeDeviceLabel_));
return result;
}
void Stop() {
std::lock_guard<std::mutex> lock(stateMutex_);
stopLocked();
}
Napi::Object Drain(Napi::Env env, size_t maxChunks) {
const size_t drainLimit =
maxChunks == 0 ? kDefaultDrainChunkLimit : std::min(maxChunks, kMaxQueuedChunks);
std::deque<CapturedChunk> drained;
uint64_t overwriteCount = 0;
{
std::lock_guard<std::mutex> queueLock(chunkMutex_);
overwriteCount = overwriteCount_;
const size_t count = std::min(drainLimit, chunkQueue_.size());
for (size_t index = 0; index < count; ++index) {
drained.push_back(std::move(chunkQueue_.front()));
chunkQueue_.pop_front();
}
}
Napi::Array chunks = Napi::Array::New(env, drained.size());
for (size_t index = 0; index < drained.size(); ++index) {
CapturedChunk& chunk = drained[index];
Napi::Object entry = Napi::Object::New(env);
Napi::Float32Array left =
Napi::Float32Array::New(env, chunk.left.size());
Napi::Float32Array right =
Napi::Float32Array::New(env, chunk.right.size());
if (!chunk.left.empty()) {
std::memcpy(
left.Data(), chunk.left.data(), chunk.left.size() * sizeof(float));
}
if (!chunk.right.empty()) {
std::memcpy(
right.Data(), chunk.right.data(), chunk.right.size() * sizeof(float));
}
entry.Set("left", left);
entry.Set("right", right);
entry.Set(
"channelCount",
Napi::Number::New(env, static_cast<double>(chunk.channelCount)));
entry.Set(
"capturedAtMilliseconds",
Napi::Number::New(env, chunk.capturedAtMilliseconds));
entry.Set(
"sequence",
Napi::Number::New(env, static_cast<double>(chunk.sequence)));
chunks.Set(static_cast<uint32_t>(index), entry);
}
Napi::Object result = Napi::Object::New(env);
result.Set("chunks", chunks);
result.Set(
"overwriteCount",
Napi::Number::New(env, static_cast<double>(overwriteCount)));
result.Set(
"queueDepth",
Napi::Number::New(env, static_cast<double>(chunkQueue_.size())));
return result;
}
double NowMilliseconds() const {
return monotonicMilliseconds();
}
private:
static OSStatus StaticIOProc(AudioObjectID inDevice,
const AudioTimeStamp* inNow,
const AudioBufferList* inInputData,
const AudioTimeStamp* inInputTime,
AudioBufferList* outOutputData,
const AudioTimeStamp* inOutputTime,
void* inClientData) {
auto* self = static_cast<MacOSNativeCaptureEngine*>(inClientData);
if (self == nullptr) {
return noErr;
}
return self->handleIO(
inDevice, inNow, inInputData, inInputTime, outOutputData, inOutputTime);
}
OSStatus handleIO(AudioObjectID,
const AudioTimeStamp*,
const AudioBufferList* inputData,
const AudioTimeStamp*,
AudioBufferList*,
const AudioTimeStamp*) {
AudioStreamBasicDescription format{};
UInt32 channelCount = 0;
{
std::lock_guard<std::mutex> stateLock(stateMutex_);
if (!active_ || inputData == nullptr) {
return noErr;
}
format = tapFormat_;
channelCount = channelCount_;
}
if (inputData->mNumberBuffers == 0 || format.mBytesPerFrame == 0) {
return noErr;
}
const bool interleaved = isFormatInterleaved(format);
const AudioBuffer& firstBuffer = inputData->mBuffers[0];
const UInt32 frames =
format.mBytesPerFrame == 0 ? 0 : firstBuffer.mDataByteSize / format.mBytesPerFrame;
if (frames == 0) {
return noErr;
}
CapturedChunk chunk;
chunk.channelCount = channelCount;
chunk.capturedAtMilliseconds = monotonicMilliseconds();
{
std::lock_guard<std::mutex> stateLock(stateMutex_);
chunk.sequence = ++sequence_;
}
chunk.left.resize(frames);
chunk.right.resize(frames);
if (interleaved) {
const uint8_t* rawData = static_cast<const uint8_t*>(firstBuffer.mData);
for (UInt32 frameIndex = 0; frameIndex < frames; ++frameIndex) {
const UInt32 sampleBaseIndex = frameIndex * std::max<UInt32>(1, channelCount);
const float leftSample =
readSampleFromFormat(rawData, format, sampleBaseIndex);
const float rightSample = channelCount > 1
? readSampleFromFormat(rawData, format, sampleBaseIndex + 1)
: leftSample;
chunk.left[frameIndex] = leftSample;
chunk.right[frameIndex] = rightSample;
}
} else {
const uint8_t* leftData = static_cast<const uint8_t*>(inputData->mBuffers[0].mData);
const uint8_t* rightData = static_cast<const uint8_t*>(
inputData->mNumberBuffers > 1 ? inputData->mBuffers[1].mData
: inputData->mBuffers[0].mData);
for (UInt32 frameIndex = 0; frameIndex < frames; ++frameIndex) {
chunk.left[frameIndex] =
readSampleFromFormat(leftData, format, frameIndex);
chunk.right[frameIndex] = inputData->mNumberBuffers > 1
? readSampleFromFormat(rightData, format, frameIndex)
: chunk.left[frameIndex];
}
}
{
std::lock_guard<std::mutex> queueLock(chunkMutex_);
if (chunkQueue_.size() >= kMaxQueuedChunks) {
chunkQueue_.pop_front();
++overwriteCount_;
}
chunkQueue_.push_back(std::move(chunk));
}
return noErr;
}
bool startInternal(const std::string& requestedDeviceUid, std::string* outErrorMessage) {
std::lock_guard<std::mutex> lock(stateMutex_);
stopLocked();
if (!isSupported()) {
if (outErrorMessage != nullptr) {
*outErrorMessage =
"Native output-device capture requires macOS 14.2 or newer.";
}
return false;
}
const auto devices = enumerateOutputDevices();
if (devices.empty()) {
if (outErrorMessage != nullptr) {
*outErrorMessage = "No macOS output devices are available.";
}
return false;
}
const OutputDeviceInfo* selected = nullptr;
if (!requestedDeviceUid.empty()) {
for (const auto& device : devices) {
if (device.uid == requestedDeviceUid) {
selected = &device;
break;
}
}
if (selected == nullptr) {
if (outErrorMessage != nullptr) {
*outErrorMessage =
"The selected macOS output device is no longer available.";
}
return false;
}
} else {
for (const auto& device : devices) {
if (device.isDefault) {
selected = &device;
break;
}
}
if (selected == nullptr) {
selected = &devices.front();
}
}
if (@available(macOS 14.2, *)) {
@autoreleasepool {
NSString* deviceUID = toNSString(selected->uid);
NSString* deviceLabel = toNSString(selected->label);
NSArray<NSNumber*>* excludedProcesses = @[];
CATapDescription* tapDescription =
[[CATapDescription alloc] initExcludingProcesses:excludedProcesses
andDeviceUID:deviceUID
withStream:0];
tapDescription.name = [NSString stringWithFormat:@"Prism Tap %@", deviceLabel];
tapDescription.UUID = [NSUUID UUID];
tapDescription.privateTap = YES;
tapDescription.muteBehavior = CATapUnmuted;
const OSStatus tapStatus =
AudioHardwareCreateProcessTap(tapDescription, &tapId_);
if (tapStatus != noErr) {
if (outErrorMessage != nullptr) {
*outErrorMessage =
formatStatusMessage("AudioHardwareCreateProcessTap", tapStatus);
}
tapId_ = kUnknownObject;
return false;
}
NSString* aggregateUID = [NSString
stringWithFormat:@"com.astra.prism.capture.%@", [NSUUID UUID].UUIDString];
NSDictionary* aggregateDescription = @{
[NSString stringWithUTF8String:kAudioAggregateDeviceNameKey]:
[NSString stringWithFormat:@"Prism Capture %@", deviceLabel],
[NSString stringWithUTF8String:kAudioAggregateDeviceUIDKey]: aggregateUID,
[NSString stringWithUTF8String:kAudioAggregateDeviceIsPrivateKey]: @YES,
[NSString stringWithUTF8String:kAudioAggregateDeviceMainSubDeviceKey]:
deviceUID,
[NSString stringWithUTF8String:kAudioAggregateDeviceTapAutoStartKey]: @YES,
[NSString stringWithUTF8String:kAudioAggregateDeviceSubDeviceListKey]: @[
@{
[NSString stringWithUTF8String:kAudioSubDeviceUIDKey]: deviceUID,
[NSString stringWithUTF8String:kAudioSubDeviceNameKey]: deviceLabel,
[NSString stringWithUTF8String:kAudioSubDeviceDriftCompensationKey]:
@YES,
[NSString
stringWithUTF8String:kAudioSubDeviceDriftCompensationQualityKey]:
@(kAudioAggregateDriftCompensationMediumQuality),
}
],
[NSString stringWithUTF8String:kAudioAggregateDeviceTapListKey]: @[
@{
[NSString stringWithUTF8String:kAudioSubTapUIDKey]:
tapDescription.UUID.UUIDString,
[NSString stringWithUTF8String:kAudioSubTapDriftCompensationKey]:
@YES,
[NSString
stringWithUTF8String:kAudioSubTapDriftCompensationQualityKey]:
@(kAudioAggregateDriftCompensationMediumQuality),
}
],
};
const OSStatus aggregateStatus = AudioHardwareCreateAggregateDevice(
(__bridge CFDictionaryRef)aggregateDescription, &aggregateDeviceId_);
if (aggregateStatus != noErr) {
if (outErrorMessage != nullptr) {
*outErrorMessage = formatStatusMessage(
"AudioHardwareCreateAggregateDevice", aggregateStatus);
}
AudioHardwareDestroyProcessTap(tapId_);
tapId_ = kUnknownObject;
aggregateDeviceId_ = kUnknownObject;
return false;
}
}
}
if (!getTapFormat(tapId_, &tapFormat_)) {
tapFormat_ = AudioStreamBasicDescription{};
tapFormat_.mSampleRate = selected->sampleRate;
tapFormat_.mChannelsPerFrame = std::max<UInt32>(2, selected->channelCount);
tapFormat_.mBitsPerChannel = 32;
tapFormat_.mBytesPerFrame =
tapFormat_.mChannelsPerFrame * sizeof(Float32);
tapFormat_.mFramesPerPacket = 1;
tapFormat_.mBytesPerPacket =
tapFormat_.mBytesPerFrame * tapFormat_.mFramesPerPacket;
tapFormat_.mFormatID = kAudioFormatLinearPCM;
tapFormat_.mFormatFlags = kAudioFormatFlagIsFloat | kAudioFormatFlagIsPacked;
}
const OSStatus ioProcStatus =
AudioDeviceCreateIOProcID(aggregateDeviceId_, StaticIOProc, this, &ioProcId_);
if (ioProcStatus != noErr) {
if (outErrorMessage != nullptr) {
*outErrorMessage =
formatStatusMessage("AudioDeviceCreateIOProcID", ioProcStatus);
}
stopLocked();
return false;
}
const OSStatus startStatus = AudioDeviceStart(aggregateDeviceId_, ioProcId_);
if (startStatus != noErr) {
if (outErrorMessage != nullptr) {
*outErrorMessage = formatStatusMessage("AudioDeviceStart", startStatus);
}
stopLocked();
return false;
}
{
std::lock_guard<std::mutex> queueLock(chunkMutex_);
chunkQueue_.clear();
overwriteCount_ = 0;
}
active_ = true;
activeDeviceUid_ = selected->uid;
activeDeviceLabel_ = selected->label;
sampleRate_ = tapFormat_.mSampleRate > 0 ? tapFormat_.mSampleRate : selected->sampleRate;
channelCount_ =
std::max<UInt32>(1, tapFormat_.mChannelsPerFrame > 0 ? tapFormat_.mChannelsPerFrame
: selected->channelCount);
sequence_ = 0;
return true;
}
void stopLocked() {
active_ = false;
if (aggregateDeviceId_ != kUnknownObject && ioProcId_ != nullptr) {
AudioDeviceStop(aggregateDeviceId_, ioProcId_);
AudioDeviceDestroyIOProcID(aggregateDeviceId_, ioProcId_);
ioProcId_ = nullptr;
}
if (aggregateDeviceId_ != kUnknownObject) {
AudioHardwareDestroyAggregateDevice(aggregateDeviceId_);
aggregateDeviceId_ = kUnknownObject;
}
if (tapId_ != kUnknownObject) {
if (@available(macOS 14.2, *)) {
AudioHardwareDestroyProcessTap(tapId_);
}
tapId_ = kUnknownObject;
}
tapFormat_ = AudioStreamBasicDescription{};
activeDeviceUid_.clear();
activeDeviceLabel_.clear();
sampleRate_ = 48000.0;
channelCount_ = 2;
sequence_ = 0;
std::lock_guard<std::mutex> queueLock(chunkMutex_);
chunkQueue_.clear();
overwriteCount_ = 0;
}
bool isSupported() const {
if (@available(macOS 14.2, *)) {
return true;
}
return false;
}
mutable std::mutex stateMutex_;
std::mutex chunkMutex_;
std::deque<CapturedChunk> chunkQueue_;
uint64_t overwriteCount_ = 0;
uint64_t sequence_ = 0;
AudioObjectID tapId_ = kUnknownObject;
AudioObjectID aggregateDeviceId_ = kUnknownObject;
AudioDeviceIOProcID ioProcId_ = nullptr;
AudioStreamBasicDescription tapFormat_{};
bool active_ = false;
std::string activeDeviceUid_;
std::string activeDeviceLabel_;
double sampleRate_ = 48000.0;
UInt32 channelCount_ = 2;
};
MacOSNativeCaptureEngine& engine() {
static MacOSNativeCaptureEngine instance;
return instance;
}
Napi::Value MacOSGetSupport(const Napi::CallbackInfo& info) {
return engine().GetSupport(info.Env());
}
Napi::Value MacOSListOutputDevices(const Napi::CallbackInfo& info) {
return engine().ListOutputDevices(info.Env());
}
Napi::Value MacOSStart(const Napi::CallbackInfo& info) {
std::string requestedDeviceUid;
if (info.Length() >= 1 && info[0].IsString()) {
requestedDeviceUid = info[0].As<Napi::String>().Utf8Value();
}
return engine().Start(info.Env(), requestedDeviceUid);
}
Napi::Value MacOSStop(const Napi::CallbackInfo& info) {
engine().Stop();
return info.Env().Undefined();
}
Napi::Value MacOSDrain(const Napi::CallbackInfo& info) {
size_t maxChunks = kDefaultDrainChunkLimit;
if (info.Length() >= 1 && info[0].IsNumber()) {
const int64_t requested = info[0].As<Napi::Number>().Int64Value();
if (requested > 0) {
maxChunks = static_cast<size_t>(requested);
}
}
return engine().Drain(info.Env(), maxChunks);
}
Napi::Value MacOSNowMilliseconds(const Napi::CallbackInfo& info) {
return Napi::Number::New(info.Env(), engine().NowMilliseconds());
}
} // namespace
void RegisterMacOSCapture(Napi::Env env, Napi::Object exports) {
Napi::Object captureExports = Napi::Object::New(env);
captureExports.Set("getSupport", Napi::Function::New(env, MacOSGetSupport));
captureExports.Set(
"listOutputDevices", Napi::Function::New(env, MacOSListOutputDevices));
captureExports.Set("start", Napi::Function::New(env, MacOSStart));
captureExports.Set("stop", Napi::Function::New(env, MacOSStop));
captureExports.Set("drain", Napi::Function::New(env, MacOSDrain));
captureExports.Set(
"nowMilliseconds", Napi::Function::New(env, MacOSNowMilliseconds));
exports.Set("macosCapture", captureExports);
}
#endif // defined(__APPLE__)
+54
View File
@@ -0,0 +1,54 @@
#include "macos_capture.h"
namespace {
Napi::Value GetSupport(const Napi::CallbackInfo& info) {
Napi::Object support = Napi::Object::New(info.Env());
support.Set("available", Napi::Boolean::New(info.Env(), false));
support.Set(
"reason",
Napi::String::New(
info.Env(), "Native macOS output-device capture is unavailable on this platform."));
return support;
}
Napi::Value ListOutputDevices(const Napi::CallbackInfo& info) {
return Napi::Array::New(info.Env());
}
Napi::Value Start(const Napi::CallbackInfo& info) {
Napi::Error::New(
info.Env(), "Native macOS output-device capture is unavailable on this platform.")
.ThrowAsJavaScriptException();
return info.Env().Undefined();
}
Napi::Value Stop(const Napi::CallbackInfo& info) {
return info.Env().Undefined();
}
Napi::Value Drain(const Napi::CallbackInfo& info) {
Napi::Object result = Napi::Object::New(info.Env());
result.Set("chunks", Napi::Array::New(info.Env()));
result.Set("overwriteCount", Napi::Number::New(info.Env(), 0));
result.Set("queueDepth", Napi::Number::New(info.Env(), 0));
return result;
}
Napi::Value NowMilliseconds(const Napi::CallbackInfo& info) {
return Napi::Number::New(info.Env(), 0);
}
} // namespace
void RegisterMacOSCapture(Napi::Env env, Napi::Object exports) {
Napi::Object captureExports = Napi::Object::New(env);
captureExports.Set("getSupport", Napi::Function::New(env, GetSupport));
captureExports.Set(
"listOutputDevices", Napi::Function::New(env, ListOutputDevices));
captureExports.Set("start", Napi::Function::New(env, Start));
captureExports.Set("stop", Napi::Function::New(env, Stop));
captureExports.Set("drain", Napi::Function::New(env, Drain));
captureExports.Set("nowMilliseconds", Napi::Function::New(env, NowMilliseconds));
exports.Set("macosCapture", captureExports);
}
+3
View File
@@ -1,5 +1,6 @@
#include <napi.h> #include <napi.h>
#include <cstring> #include <cstring>
#include "macos_capture.h"
#include "oscilloscope.h" #include "oscilloscope.h"
#include "spectrum.h" #include "spectrum.h"
#include "vectorscope.h" #include "vectorscope.h"
@@ -294,6 +295,8 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) {
vecExports.Set("reset", Napi::Function::New(env, VectorscopeReset)); vecExports.Set("reset", Napi::Function::New(env, VectorscopeReset));
exports.Set("vectorscope", vecExports); exports.Set("vectorscope", vecExports);
RegisterMacOSCapture(env, exports);
return exports; return exports;
} }
+54 -5
View File
@@ -1,5 +1,9 @@
import { contextBridge, ipcRenderer } from 'electron' import { contextBridge, ipcRenderer } from 'electron'
import type { CaptureBackendSupport } from '../types/capture' import type { CaptureBackendSupport, CaptureBackendSupportEntry } from '../types/capture'
import type { NativeCaptureAPI } from '../types/nativeCapture'
import type { VisualizerDSP } from '../renderer/audio/native/visualizer-dsp'
type NativeAddonModule = VisualizerDSP & NativeCaptureAPI
// Expose Electron API to renderer // Expose Electron API to renderer
contextBridge.exposeInMainWorld('electronAPI', { contextBridge.exposeInMainWorld('electronAPI', {
@@ -9,7 +13,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
toggleAlwaysOnTop: () => ipcRenderer.send('window:toggle-always-on-top'), toggleAlwaysOnTop: () => ipcRenderer.send('window:toggle-always-on-top'),
isAlwaysOnTop: () => ipcRenderer.invoke('window:is-always-on-top'), isAlwaysOnTop: () => ipcRenderer.invoke('window:is-always-on-top'),
getDesktopSources: () => ipcRenderer.invoke('audio:get-desktop-sources') as Promise<{ id: string; name: string }[]>, getDesktopSources: () => ipcRenderer.invoke('audio:get-desktop-sources') as Promise<{ id: string; name: string }[]>,
getCaptureBackendSupport: () => ipcRenderer.invoke('capture:get-backend-support') as Promise<CaptureBackendSupport>, getCaptureBackendSupport: async () => {
const support = await ipcRenderer.invoke('capture:get-backend-support') as CaptureBackendSupport
return {
...support,
nativeBackend: resolveNativeCaptureSupport(support.nativeBackend),
} satisfies CaptureBackendSupport
},
expandSettings: (panelHeight: number) => ipcRenderer.send('window:expand-settings', panelHeight), expandSettings: (panelHeight: number) => ipcRenderer.send('window:expand-settings', panelHeight),
collapseSettings: (panelHeight: number) => ipcRenderer.send('window:collapse-settings', panelHeight), collapseSettings: (panelHeight: number) => ipcRenderer.send('window:collapse-settings', panelHeight),
setSettingsHeight: (panelHeight: number) => ipcRenderer.send('window:set-settings-height', panelHeight), setSettingsHeight: (panelHeight: number) => ipcRenderer.send('window:set-settings-height', panelHeight),
@@ -36,15 +46,54 @@ contextBridge.exposeInMainWorld('electronAPI', {
}) })
// Native DSP module — load if available, gracefully degrade if not // Native DSP module — load if available, gracefully degrade if not
let visualizerDSP: unknown = null let nativeAddonModule: NativeAddonModule | null = null
try { try {
const isDev = process.env.NODE_ENV === 'development' const isDev = process.env.NODE_ENV === 'development'
const modulePath = isDev const modulePath = isDev
? require('path').join(__dirname, '../../native/build/Release/visualizer_dsp.node') ? require('path').join(__dirname, '../../native/build/Release/visualizer_dsp.node')
: require('path').join(process.resourcesPath!, 'native/visualizer_dsp.node') : require('path').join(process.resourcesPath!, 'native/visualizer_dsp.node')
visualizerDSP = require(modulePath) nativeAddonModule = require(modulePath) as NativeAddonModule
} catch { } catch {
console.warn('Native DSP module not available — using JS fallback') console.warn('Native DSP module not available — using JS fallback')
} }
contextBridge.exposeInMainWorld('visualizerAPI', visualizerDSP) function resolveNativeCaptureSupport(
fallbackEntry: CaptureBackendSupportEntry,
): CaptureBackendSupportEntry {
if (process.platform !== 'darwin') {
return fallbackEntry
}
const macosCapture = nativeAddonModule?.macosCapture
if (!macosCapture) {
return {
kind: 'native-macos',
available: false,
reason: 'Native capture module is not available in this build.',
}
}
const support = macosCapture.getSupport()
return {
kind: 'native-macos',
available: support.available,
reason: support.reason,
}
}
const visualizerAPI = nativeAddonModule
? {
oscilloscope: nativeAddonModule.oscilloscope,
spectrum: nativeAddonModule.spectrum,
vectorscope: nativeAddonModule.vectorscope,
}
: null
const nativeCaptureAPI = nativeAddonModule
? {
macosCapture: nativeAddonModule.macosCapture,
}
: null
contextBridge.exposeInMainWorld('visualizerAPI', visualizerAPI)
contextBridge.exposeInMainWorld('nativeCaptureAPI', nativeCaptureAPI)
+194 -19
View File
@@ -13,6 +13,7 @@ import type {
CaptureMode, CaptureMode,
CaptureSourceDescriptor, CaptureSourceDescriptor,
} from '../../types/capture' } from '../../types/capture'
import type { NativeMacOSCaptureDrainResult, NativeMacOSCaptureStartResult } from '../../types/nativeCapture'
export type { CaptureMode } from '../../types/capture' export type { CaptureMode } from '../../types/capture'
@@ -60,6 +61,7 @@ export interface CaptureManagerStatus {
type StatusListener = (status: CaptureManagerStatus) => void type StatusListener = (status: CaptureManagerStatus) => void
const DEFAULT_BACKEND_POLICY: CaptureBackendPolicy = 'auto' const DEFAULT_BACKEND_POLICY: CaptureBackendPolicy = 'auto'
const DEFAULT_SYSTEM_SOURCE_ID = '__default_system_output__'
const DEFAULT_BACKEND_SUPPORT: CaptureBackendSupport = { const DEFAULT_BACKEND_SUPPORT: CaptureBackendSupport = {
policyOptions: ['auto', 'native', 'electron'], policyOptions: ['auto', 'native', 'electron'],
@@ -92,6 +94,15 @@ function toDeviceSourceDescriptor(device: MediaDeviceInfo): CaptureSourceDescrip
} }
} }
function getDefaultSystemSourceDescriptor(): CaptureSourceDescriptor {
return {
id: DEFAULT_SYSTEM_SOURCE_ID,
label: 'System Output',
kind: 'system',
isDefault: true,
}
}
class ElectronCaptureRuntime { class ElectronCaptureRuntime {
private audioContext: AudioContext | null = null private audioContext: AudioContext | null = null
private stream: MediaStream | null = null private stream: MediaStream | null = null
@@ -128,12 +139,7 @@ class ElectronCaptureRuntime {
} }
async listSystemSources(): Promise<CaptureSourceDescriptor[]> { async listSystemSources(): Promise<CaptureSourceDescriptor[]> {
const sources = await window.electronAPI.getDesktopSources() return [getDefaultSystemSourceDescriptor()]
return sources.map((source) => ({
id: source.id,
label: source.name,
kind: 'system',
}))
} }
async listDeviceSources(): Promise<CaptureSourceDescriptor[]> { async listDeviceSources(): Promise<CaptureSourceDescriptor[]> {
@@ -332,6 +338,142 @@ class ElectronDeviceCaptureBackend implements CaptureBackend {
} }
} }
class NativeMacOSCaptureBackend implements CaptureBackend {
readonly kind = 'native-macos' as const
private readonly chunkListeners = new Set<(chunk: CaptureChunk) => void>()
private pollTimer: number | null = null
private active = false
private sampleRate = 48000
private channelCount = 2
private supportReason: string | null
private performanceOffsetMilliseconds = 0
constructor(private readonly supportEntry: CaptureBackendSupportEntry) {
this.supportReason = supportEntry.reason
}
async start(request?: CaptureBackendStartRequest): Promise<void> {
const nativeCapture = window.nativeCaptureAPI?.macosCapture
if (!nativeCapture) {
throw new Error('Native macOS capture module is not available in this build.')
}
const support = nativeCapture.getSupport()
if (!support.available) {
throw new Error(support.reason ?? 'Native macOS capture is unavailable.')
}
const nativeNow = nativeCapture.nowMilliseconds()
this.performanceOffsetMilliseconds = performance.now() - nativeNow
const startResult = nativeCapture.start(
request?.deviceId && request.deviceId !== DEFAULT_SYSTEM_SOURCE_ID
? request.deviceId
: undefined,
) as NativeMacOSCaptureStartResult
this.sampleRate = Math.max(1, Math.floor(startResult.sampleRate) || 48000)
this.channelCount = Math.max(1, Math.floor(startResult.channelCount) || 2)
this.supportReason = null
this.active = true
this.startPolling()
}
async stop(): Promise<void> {
this.stopPolling()
window.nativeCaptureAPI?.macosCapture.stop()
this.active = false
}
async listSources(): Promise<CaptureSourceDescriptor[]> {
const nativeCapture = window.nativeCaptureAPI?.macosCapture
if (!nativeCapture) {
return [getDefaultSystemSourceDescriptor()]
}
const support = nativeCapture.getSupport()
if (!support.available) {
return [getDefaultSystemSourceDescriptor()]
}
const sources = nativeCapture.listOutputDevices()
if (!sources.length) {
return [getDefaultSystemSourceDescriptor()]
}
return sources.map((source) => ({
id: source.id,
label: source.label,
kind: 'system',
isDefault: source.isDefault,
sampleRate: source.sampleRate,
channelCount: source.channelCount,
}))
}
subscribe(listener: (chunk: CaptureChunk) => void): () => void {
this.chunkListeners.add(listener)
return () => {
this.chunkListeners.delete(listener)
}
}
getStatus(): CaptureBackendStatus {
return {
kind: this.kind,
active: this.active,
available: this.supportEntry.available,
reason: this.supportReason,
sampleRate: this.sampleRate,
channelCount: this.channelCount,
}
}
private startPolling(): void {
this.stopPolling()
const poll = (): void => {
if (!this.active) return
try {
const result = window.nativeCaptureAPI?.macosCapture.drain(32) as NativeMacOSCaptureDrainResult | undefined
if (result) {
for (const chunk of result.chunks) {
const routedChunk: CaptureChunk = {
left: chunk.left,
right: chunk.right,
channelCount: Math.max(1, Math.floor(chunk.channelCount) || 1),
capturedAt: chunk.capturedAtMilliseconds + this.performanceOffsetMilliseconds,
sequence: Math.max(1, Math.floor(chunk.sequence) || 1),
}
for (const listener of this.chunkListeners) {
listener(routedChunk)
}
}
}
} catch (error) {
console.error('Native macOS capture poll failed:', error)
this.active = false
this.stopPolling()
return
}
this.pollTimer = window.setTimeout(poll, 4)
}
this.pollTimer = window.setTimeout(poll, 0)
}
private stopPolling(): void {
if (this.pollTimer !== null) {
window.clearTimeout(this.pollTimer)
this.pollTimer = null
}
}
}
class NativeUnavailableCaptureBackend implements CaptureBackend { class NativeUnavailableCaptureBackend implements CaptureBackend {
readonly kind: CaptureBackendKind readonly kind: CaptureBackendKind
private readonly reason: string | null private readonly reason: string | null
@@ -380,6 +522,7 @@ class AudioCapture {
private activeBackend: CaptureBackend | null = null private activeBackend: CaptureBackend | null = null
private selectedDeviceId: string | null = null private selectedDeviceId: string | null = null
private selectedSystemSourceId: string | null = DEFAULT_SYSTEM_SOURCE_ID
private captureMode: CaptureMode = 'system' private captureMode: CaptureMode = 'system'
private backendPolicy: CaptureBackendPolicy = DEFAULT_BACKEND_POLICY private backendPolicy: CaptureBackendPolicy = DEFAULT_BACKEND_POLICY
private activeBackendReason: string | null = null private activeBackendReason: string | null = null
@@ -403,12 +546,16 @@ class AudioCapture {
} }
async refreshBackendSupport(): Promise<CaptureBackendSupport> { async refreshBackendSupport(): Promise<CaptureBackendSupport> {
this.backendSupport = null
this.backendSupportPromise = null this.backendSupportPromise = null
return this.ensureBackendSupport() return this.ensureBackendSupport()
} }
async startSystemAudio(): Promise<void> { async startSystemAudio(sourceId?: string): Promise<void> {
this.captureMode = 'system' this.captureMode = 'system'
if (sourceId) {
this.selectedSystemSourceId = sourceId
}
await this.start() await this.start()
} }
@@ -428,9 +575,13 @@ class AudioCapture {
const support = await this.ensureBackendSupport() const support = await this.ensureBackendSupport()
const requestedMode = this.captureMode const requestedMode = this.captureMode
const requestedDeviceId = requestedMode === 'device' ? this.selectedDeviceId ?? undefined : undefined const requestedDeviceId = requestedMode === 'device'
? this.selectedDeviceId ?? undefined
: this.selectedSystemSourceId ?? DEFAULT_SYSTEM_SOURCE_ID
const candidateBackends = this.resolveCandidateBackends(support, requestedMode) const candidateBackends = this.resolveCandidateBackends(support, requestedMode)
await this.stopActiveCapture()
let lastError: Error | null = null let lastError: Error | null = null
let nativeFallbackReason: string | null = null let nativeFallbackReason: string | null = null
@@ -462,15 +613,7 @@ class AudioCapture {
} }
stop(): void { stop(): void {
if (this.sessionId !== null) { void this.stopActiveCapture()
audioRouter.endSession()
this.sessionId = null
}
if (this.activeBackend) {
void this.activeBackend.stop()
}
this.emitStatus() this.emitStatus()
} }
@@ -481,7 +624,8 @@ class AudioCapture {
} }
const activeSystemBackend = this.resolveCandidateBackends(this.backendSupport ?? DEFAULT_BACKEND_SUPPORT, 'system')[0] const activeSystemBackend = this.resolveCandidateBackends(this.backendSupport ?? DEFAULT_BACKEND_SUPPORT, 'system')[0]
return activeSystemBackend.listSources() const sources = await activeSystemBackend.listSources()
return sources.length ? sources : [getDefaultSystemSourceDescriptor()]
} }
async listDevices(): Promise<MediaDeviceInfo[]> { async listDevices(): Promise<MediaDeviceInfo[]> {
@@ -498,6 +642,15 @@ class AudioCapture {
this.emitStatus() this.emitStatus()
} }
getSelectedSystemSourceId(): string | null {
return this.selectedSystemSourceId
}
setSelectedSystemSourceId(id: string | null): void {
this.selectedSystemSourceId = id ?? DEFAULT_SYSTEM_SOURCE_ID
this.emitStatus()
}
getCaptureMode(): CaptureMode { getCaptureMode(): CaptureMode {
return this.captureMode return this.captureMode
} }
@@ -544,7 +697,7 @@ class AudioCapture {
.catch(() => DEFAULT_BACKEND_SUPPORT) .catch(() => DEFAULT_BACKEND_SUPPORT)
.then((support) => { .then((support) => {
this.backendSupport = support this.backendSupport = support
this.nativeBackend = new NativeUnavailableCaptureBackend(support.nativeBackend) this.nativeBackend = this.createNativeBackend(support.nativeBackend)
this.emitStatus() this.emitStatus()
return support return support
}) })
@@ -577,6 +730,28 @@ class AudioCapture {
} }
} }
private createNativeBackend(supportEntry: CaptureBackendSupportEntry): CaptureBackend {
const backend = supportEntry.kind === 'native-macos' && supportEntry.available
? new NativeMacOSCaptureBackend(supportEntry)
: new NativeUnavailableCaptureBackend(supportEntry)
backend.subscribe((chunk) => this.handleChunk(backend.kind, chunk))
return backend
}
private async stopActiveCapture(): Promise<void> {
if (this.sessionId !== null) {
audioRouter.endSession()
this.sessionId = null
}
if (this.activeBackend) {
const backend = this.activeBackend
this.activeBackend = null
await backend.stop()
}
}
private handleChunk(originKind: CaptureBackendKind, chunk: CaptureChunk): void { private handleChunk(originKind: CaptureBackendKind, chunk: CaptureChunk): void {
if (!this.activeBackend || this.activeBackend.kind !== originKind || this.sessionId === null) { if (!this.activeBackend || this.activeBackend.kind !== originKind || this.sessionId === null) {
return return
+51 -85
View File
@@ -1,11 +1,9 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties, type JSX, type ReactNode } from 'react' import { useEffect, useMemo, useRef, type CSSProperties, type JSX, type ReactNode } from 'react'
import { useAudioStore } from '../stores/audioStore' import { useAudioStore } from '../stores/audioStore'
import { useSettingsStore, type ScopeSettings } from '../stores/settingsStore' import { useSettingsStore, type ScopeSettings } from '../stores/settingsStore'
import { useThemeStore, PRESETS, PRESET_IDS } from '../stores/themeStore' import { useThemeStore, PRESETS, PRESET_IDS } from '../stores/themeStore'
import type { ScopeKind } from '../../types/scope' import type { ScopeKind } from '../../types/scope'
import { buildAnalyzerGridTemplateColumns } from '../analyzerLayout' import { buildAnalyzerGridTemplateColumns } from '../analyzerLayout'
import { audioRouter, type AudioRouterDiagnostics } from '../audio/AudioRouter'
import type { CaptureBackendKind, CaptureBackendPolicy } from '../../types/capture'
const SCOPE_LABELS: Record<ScopeKind, string> = { const SCOPE_LABELS: Record<ScopeKind, string> = {
spectrum: 'Spectrum', spectrum: 'Spectrum',
@@ -17,23 +15,6 @@ const SCOPE_LABELS: Record<ScopeKind, string> = {
waveform: 'Waveform', waveform: 'Waveform',
} }
function captureBackendLabel(kind: CaptureBackendKind | null): string {
switch (kind) {
case 'electron-system':
return 'Electron System'
case 'electron-device':
return 'Electron Device'
case 'native-macos':
return 'Native macOS'
case 'native-windows':
return 'Native Windows'
case 'native-linux':
return 'Native Linux'
default:
return 'None'
}
}
function vectorscopeModeLabel(mode: ScopeSettings['vectorscope']['mode']): string { function vectorscopeModeLabel(mode: ScopeSettings['vectorscope']['mode']): string {
switch (mode) { switch (mode) {
case 'lissajous': case 'lissajous':
@@ -496,30 +477,29 @@ interface SettingsPanelProps {
export default function SettingsPanel({ onClose, onHeightChange }: SettingsPanelProps): JSX.Element { export default function SettingsPanel({ onClose, onHeightChange }: SettingsPanelProps): JSX.Element {
const { const {
systemSources,
devices, devices,
selectedSystemSourceId,
selectedDeviceId, selectedDeviceId,
captureMode, captureMode,
capturePolicy,
activeBackendKind,
activeBackendReason,
isCapturing, isCapturing,
captureStatus, captureStatus,
captureError, captureError,
refreshSystemSources,
refreshDevices, refreshDevices,
refreshBackendSupport, refreshBackendSupport,
selectSystemSource,
selectDevice, selectDevice,
setCaptureMode,
setCapturePolicy,
startCapture, startCapture,
} = useAudioStore() } = useAudioStore()
const { scopeSettings, updateScopeSettings, hiddenScopes, scopeOrder, widthWeights } = useSettingsStore() const { scopeSettings, updateScopeSettings, hiddenScopes, scopeOrder, widthWeights } = useSettingsStore()
const { presetId, accent, setPreset, setCustomAccent, customAccent } = useThemeStore() const { presetId, accent, setPreset, setCustomAccent, customAccent } = useThemeStore()
const panelRef = useRef<HTMLDivElement | null>(null) const panelRef = useRef<HTMLDivElement | null>(null)
const [routerDiagnostics, setRouterDiagnostics] = useState<AudioRouterDiagnostics>(
() => audioRouter.getDiagnosticsSnapshot(),
)
const visibleScopes = scopeOrder.filter((kind) => !hiddenScopes.has(kind)) const visibleScopes = useMemo(
() => scopeOrder.filter((kind) => !hiddenScopes.has(kind)),
[scopeOrder, hiddenScopes],
)
const scopeTrackStyle = useMemo(() => { const scopeTrackStyle = useMemo(() => {
const gridTemplateColumns = buildAnalyzerGridTemplateColumns(visibleScopes, widthWeights) const gridTemplateColumns = buildAnalyzerGridTemplateColumns(visibleScopes, widthWeights)
if (!gridTemplateColumns) return undefined if (!gridTemplateColumns) return undefined
@@ -527,18 +507,10 @@ export default function SettingsPanel({ onClose, onHeightChange }: SettingsPanel
}, [visibleScopes, widthWeights]) }, [visibleScopes, widthWeights])
useEffect(() => { useEffect(() => {
void Promise.all([refreshDevices(), refreshBackendSupport()]) void refreshBackendSupport()
}, [refreshBackendSupport, refreshDevices]) void refreshSystemSources()
void refreshDevices()
useEffect(() => { }, [refreshBackendSupport, refreshSystemSources, refreshDevices])
const intervalId = window.setInterval(() => {
setRouterDiagnostics(audioRouter.getDiagnosticsSnapshot())
}, 250)
return () => {
window.clearInterval(intervalId)
}
}, [])
useEffect(() => { useEffect(() => {
const panel = panelRef.current const panel = panelRef.current
@@ -565,19 +537,36 @@ export default function SettingsPanel({ onClose, onHeightChange }: SettingsPanel
}, [onHeightChange, visibleScopes.length]) }, [onHeightChange, visibleScopes.length])
const handleSourceChange = async (value: string): Promise<void> => { const handleSourceChange = async (value: string): Promise<void> => {
if (value === '__system__') { if (value.startsWith('system:')) {
setCaptureMode('system') const sourceId = value.slice('system:'.length)
await selectSystemSource(sourceId)
await startCapture() await startCapture()
return return
} }
await selectDevice(value) if (value.startsWith('device:')) {
await startCapture() const deviceId = value.slice('device:'.length)
await selectDevice(deviceId)
await startCapture()
}
} }
const handlePolicyChange = async (value: string): Promise<void> => { const selectedSourceValue = captureMode === 'system'
await setCapturePolicy(value as CaptureBackendPolicy) ? `system:${selectedSystemSourceId ?? systemSources[0]?.id ?? '__default_system_output__'}`
} : `device:${selectedDeviceId ?? ''}`
const visibleSystemSources = systemSources.length
? systemSources
: [{ id: '__default_system_output__', label: 'System Output', kind: 'system', isDefault: true }]
const showInputDevices = devices.length > 0
const renderSystemSourceLabel = (label: string, isDefault?: boolean): string => (
isDefault ? `${label} (Default)` : label
)
const renderInputDeviceValue = (deviceId: string): string => `device:${deviceId}`
const renderSystemSourceValue = (sourceId: string): string => `system:${sourceId}`
const indicatorLabel = isCapturing const indicatorLabel = isCapturing
? 'Capturing' ? 'Capturing'
@@ -587,10 +576,6 @@ export default function SettingsPanel({ onClose, onHeightChange }: SettingsPanel
? 'Capture Failed' ? 'Capture Failed'
: 'Idle' : 'Idle'
const latencyLabel = routerDiagnostics.overallP95CaptureToScopeMs === null
? 'Waiting for samples'
: `${routerDiagnostics.overallP95CaptureToScopeMs.toFixed(1)} ms p95`
return ( return (
<div className="settings-panel" ref={panelRef}> <div className="settings-panel" ref={panelRef}>
<div className="settings-panel__utility-row"> <div className="settings-panel__utility-row">
@@ -601,34 +586,27 @@ export default function SettingsPanel({ onClose, onHeightChange }: SettingsPanel
<span className="settings-control__label">Source</span> <span className="settings-control__label">Source</span>
<select <select
className="settings-control__select" className="settings-control__select"
value={captureMode === 'system' ? '__system__' : selectedDeviceId ?? ''} value={selectedSourceValue}
onChange={(event) => { onChange={(event) => {
void handleSourceChange(event.target.value) void handleSourceChange(event.target.value)
}} }}
> >
<option value="__system__">System Audio</option> <optgroup label="Output Devices">
<optgroup label="Devices"> {visibleSystemSources.map((source) => (
{devices.map((device) => ( <option key={source.id} value={renderSystemSourceValue(source.id)}>
<option key={device.deviceId} value={device.deviceId}> {renderSystemSourceLabel(source.label, source.isDefault)}
{device.label || `Input ${device.deviceId.slice(0, 8)}`}
</option> </option>
))} ))}
</optgroup> </optgroup>
</select> {showInputDevices ? (
</label> <optgroup label="Input Devices">
{devices.map((device) => (
<label className="settings-control settings-control--stack"> <option key={device.deviceId} value={renderInputDeviceValue(device.deviceId)}>
<span className="settings-control__label">Backend Policy</span> {device.label || `Input ${device.deviceId.slice(0, 8)}`}
<select </option>
className="settings-control__select" ))}
value={capturePolicy} </optgroup>
onChange={(event) => { ) : null}
void handlePolicyChange(event.target.value)
}}
>
<option value="auto">Auto</option>
<option value="native">Native</option>
<option value="electron">Electron</option>
</select> </select>
</label> </label>
@@ -637,18 +615,6 @@ export default function SettingsPanel({ onClose, onHeightChange }: SettingsPanel
<span>{indicatorLabel}</span> <span>{indicatorLabel}</span>
</div> </div>
<div className="settings-info-text">
Active backend: {captureBackendLabel(activeBackendKind)}
</div>
<div className="settings-info-text">
Latency probe: {latencyLabel} · overwrites {routerDiagnostics.totalOverwriteCount} · stale drops {routerDiagnostics.staleSessionDrops}
</div>
{activeBackendReason ? (
<div className="settings-info-text">{activeBackendReason}</div>
) : null}
{captureError ? ( {captureError ? (
<div className="settings-error-text">{captureError}</div> <div className="settings-error-text">{captureError}</div>
) : null} ) : null}
+2
View File
@@ -2,10 +2,12 @@
import type { VisualizerDSP } from './audio/native/visualizer-dsp' import type { VisualizerDSP } from './audio/native/visualizer-dsp'
import type { CaptureBackendSupport } from '../types/capture' import type { CaptureBackendSupport } from '../types/capture'
import type { NativeCaptureAPI } from '../types/nativeCapture'
declare global { declare global {
interface Window { interface Window {
visualizerAPI: VisualizerDSP | null visualizerAPI: VisualizerDSP | null
nativeCaptureAPI: NativeCaptureAPI | null
electronAPI: { electronAPI: {
platform: string platform: string
minimize: () => void minimize: () => void
+36 -2
View File
@@ -5,10 +5,13 @@ import type {
CaptureBackendPolicy, CaptureBackendPolicy,
CaptureBackendSupport, CaptureBackendSupport,
CaptureMode, CaptureMode,
CaptureSourceDescriptor,
} from '../../types/capture' } from '../../types/capture'
interface AudioState { interface AudioState {
systemSources: CaptureSourceDescriptor[]
devices: MediaDeviceInfo[] devices: MediaDeviceInfo[]
selectedSystemSourceId: string | null
selectedDeviceId: string | null selectedDeviceId: string | null
captureMode: CaptureMode captureMode: CaptureMode
capturePolicy: CaptureBackendPolicy capturePolicy: CaptureBackendPolicy
@@ -20,8 +23,10 @@ interface AudioState {
captureError: string | null captureError: string | null
sampleRate: number sampleRate: number
channelCount: number channelCount: number
refreshSystemSources: () => Promise<void>
refreshDevices: () => Promise<void> refreshDevices: () => Promise<void>
refreshBackendSupport: () => Promise<void> refreshBackendSupport: () => Promise<void>
selectSystemSource: (sourceId: string | null) => Promise<void>
selectDevice: (deviceId: string) => Promise<void> selectDevice: (deviceId: string) => Promise<void>
setCaptureMode: (mode: CaptureMode) => void setCaptureMode: (mode: CaptureMode) => void
setCapturePolicy: (policy: CaptureBackendPolicy) => Promise<void> setCapturePolicy: (policy: CaptureBackendPolicy) => Promise<void>
@@ -43,7 +48,9 @@ function applyCaptureStatus(status: CaptureManagerStatus): Partial<AudioState> {
} }
export const useAudioStore = create<AudioState>((set, get) => ({ export const useAudioStore = create<AudioState>((set, get) => ({
systemSources: [],
devices: [], devices: [],
selectedSystemSourceId: audioCapture.getSelectedSystemSourceId(),
selectedDeviceId: null, selectedDeviceId: null,
captureMode: 'system', captureMode: 'system',
capturePolicy: 'auto', capturePolicy: 'auto',
@@ -56,6 +63,21 @@ export const useAudioStore = create<AudioState>((set, get) => ({
sampleRate: 48000, sampleRate: 48000,
channelCount: 2, channelCount: 2,
refreshSystemSources: async () => {
const systemSources = await audioCapture.listSources('system')
const fallbackSourceId = systemSources[0]?.id ?? null
const currentSelectedSystemSourceId = get().selectedSystemSourceId
const nextSelectedSystemSourceId = currentSelectedSystemSourceId && systemSources.some((source) => source.id === currentSelectedSystemSourceId)
? currentSelectedSystemSourceId
: fallbackSourceId
audioCapture.setSelectedSystemSourceId(nextSelectedSystemSourceId)
set({
systemSources,
selectedSystemSourceId: nextSelectedSystemSourceId,
})
},
refreshDevices: async () => { refreshDevices: async () => {
const devices = await audioCapture.listDevices() const devices = await audioCapture.listDevices()
set({ devices }) set({ devices })
@@ -64,6 +86,16 @@ export const useAudioStore = create<AudioState>((set, get) => ({
refreshBackendSupport: async () => { refreshBackendSupport: async () => {
const backendSupport = await audioCapture.refreshBackendSupport() const backendSupport = await audioCapture.refreshBackendSupport()
set({ backendSupport }) set({ backendSupport })
await get().refreshSystemSources()
},
selectSystemSource: async (sourceId: string | null) => {
audioCapture.setSelectedSystemSourceId(sourceId)
audioCapture.setCaptureMode('system')
set({
selectedSystemSourceId: audioCapture.getSelectedSystemSourceId(),
captureMode: 'system',
})
}, },
selectDevice: async (deviceId: string) => { selectDevice: async (deviceId: string) => {
@@ -91,13 +123,15 @@ export const useAudioStore = create<AudioState>((set, get) => ({
startCapture: async () => { startCapture: async () => {
set({ captureStatus: 'connecting', captureError: null }) set({ captureStatus: 'connecting', captureError: null })
try { try {
const { captureMode, selectedDeviceId, capturePolicy } = get() const { captureMode, capturePolicy } = get()
audioCapture.setCaptureMode(captureMode) audioCapture.setCaptureMode(captureMode)
audioCapture.setBackendPolicy(capturePolicy) audioCapture.setBackendPolicy(capturePolicy)
await get().refreshBackendSupport() await get().refreshBackendSupport()
const { selectedDeviceId, selectedSystemSourceId } = get()
if (captureMode === 'system') { if (captureMode === 'system') {
await audioCapture.startSystemAudio() await audioCapture.startSystemAudio(selectedSystemSourceId ?? undefined)
} else { } else {
await audioCapture.startDevice(selectedDeviceId ?? undefined) await audioCapture.startDevice(selectedDeviceId ?? undefined)
} }
+3
View File
@@ -13,6 +13,9 @@ export interface CaptureSourceDescriptor {
id: string id: string
label: string label: string
kind: CaptureMode kind: CaptureMode
isDefault?: boolean
sampleRate?: number
channelCount?: number
} }
export interface CaptureBackendSupportEntry { export interface CaptureBackendSupportEntry {
+47
View File
@@ -0,0 +1,47 @@
export interface NativeMacOSCaptureSupport {
available: boolean
reason: string | null
}
export interface NativeMacOSCaptureSource {
id: string
label: string
kind: 'system'
isDefault: boolean
sampleRate: number
channelCount: number
}
export interface NativeMacOSCaptureStartResult {
sampleRate: number
channelCount: number
deviceId: string
deviceLabel: string
}
export interface NativeMacOSCapturedChunk {
left: Float32Array
right: Float32Array
channelCount: number
capturedAtMilliseconds: number
sequence: number
}
export interface NativeMacOSCaptureDrainResult {
chunks: NativeMacOSCapturedChunk[]
overwriteCount: number
queueDepth: number
}
export interface NativeMacOSCaptureAPI {
getSupport: () => NativeMacOSCaptureSupport
listOutputDevices: () => NativeMacOSCaptureSource[]
start: (deviceId?: string) => NativeMacOSCaptureStartResult
stop: () => void
drain: (maxChunks?: number) => NativeMacOSCaptureDrainResult
nowMilliseconds: () => number
}
export interface NativeCaptureAPI {
macosCapture: NativeMacOSCaptureAPI
}