diff --git a/native/binding.gyp b/native/binding.gyp index 27b2860..fe62ed3 100644 --- a/native/binding.gyp +++ b/native/binding.gyp @@ -19,13 +19,25 @@ "defines": ["NAPI_DISABLE_CPP_EXCEPTIONS"], "conditions": [ ["OS=='mac'", { + "sources": [ + "src/macos_capture.mm" + ], "xcode_settings": { "GCC_ENABLE_CPP_EXCEPTIONS": "YES", "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'", { + "sources": [ + "src/macos_capture_stub.cpp" + ], "msvs_settings": { "VCCLCompilerTool": { "ExceptionHandling": 1, @@ -34,6 +46,9 @@ } }], ["OS=='linux'", { + "sources": [ + "src/macos_capture_stub.cpp" + ], "cflags_cc": ["-std=c++17", "-O3", "-ffast-math", "-fPIC"] }] ] diff --git a/native/src/macos_capture.h b/native/src/macos_capture.h new file mode 100644 index 0000000..4111a56 --- /dev/null +++ b/native/src/macos_capture.h @@ -0,0 +1,5 @@ +#pragma once + +#include + +void RegisterMacOSCapture(Napi::Env env, Napi::Object exports); diff --git a/native/src/macos_capture.mm b/native/src/macos_capture.mm new file mode 100644 index 0000000..2666944 --- /dev/null +++ b/native/src/macos_capture.mm @@ -0,0 +1,839 @@ +#include "macos_capture.h" + +#if defined(__APPLE__) + +#import +#import +#import +#import + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 left; + std::vector 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(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 buffer(static_cast(std::max(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 +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(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 storage(size); + auto* bufferList = reinterpret_cast(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 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 deviceIds(deviceCount, kUnknownObject); + if (AudioObjectGetPropertyData( + kAudioObjectSystemObject, &address, 0, nullptr, &size, deviceIds.data()) != noErr) { + return {}; + } + + const AudioDeviceID defaultDeviceId = getDefaultOutputDeviceId(); + std::vector 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(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((1u << (totalBits - 1)) - 1u); + if (maxMagnitude <= 0.0) { + return 0.0f; + } + + return static_cast(static_cast(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(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(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(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(device.channelCount))); + result.Set(static_cast(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().Value()) { + Napi::Error::New( + env, support.Get("reason").As().Utf8Value()) + .ThrowAsJavaScriptException(); + return result; + } + + std::string errorMessage; + if (!startInternal(requestedDeviceUid, &errorMessage)) { + Napi::Error::New(env, errorMessage).ThrowAsJavaScriptException(); + return result; + } + + std::lock_guard lock(stateMutex_); + result.Set("sampleRate", Napi::Number::New(env, sampleRate_)); + result.Set("channelCount", Napi::Number::New(env, static_cast(channelCount_))); + result.Set("deviceId", Napi::String::New(env, activeDeviceUid_)); + result.Set("deviceLabel", Napi::String::New(env, activeDeviceLabel_)); + return result; + } + + void Stop() { + std::lock_guard 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 drained; + uint64_t overwriteCount = 0; + + { + std::lock_guard 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(chunk.channelCount))); + entry.Set( + "capturedAtMilliseconds", + Napi::Number::New(env, chunk.capturedAtMilliseconds)); + entry.Set( + "sequence", + Napi::Number::New(env, static_cast(chunk.sequence))); + chunks.Set(static_cast(index), entry); + } + + Napi::Object result = Napi::Object::New(env); + result.Set("chunks", chunks); + result.Set( + "overwriteCount", + Napi::Number::New(env, static_cast(overwriteCount))); + result.Set( + "queueDepth", + Napi::Number::New(env, static_cast(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(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 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 stateLock(stateMutex_); + chunk.sequence = ++sequence_; + } + + chunk.left.resize(frames); + chunk.right.resize(frames); + + if (interleaved) { + const uint8_t* rawData = static_cast(firstBuffer.mData); + for (UInt32 frameIndex = 0; frameIndex < frames; ++frameIndex) { + const UInt32 sampleBaseIndex = frameIndex * std::max(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(inputData->mBuffers[0].mData); + const uint8_t* rightData = static_cast( + 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 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 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* 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(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 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(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 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 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().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().Int64Value(); + if (requested > 0) { + maxChunks = static_cast(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__) diff --git a/native/src/macos_capture_stub.cpp b/native/src/macos_capture_stub.cpp new file mode 100644 index 0000000..d0fe5c8 --- /dev/null +++ b/native/src/macos_capture_stub.cpp @@ -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); +} diff --git a/native/src/main.cpp b/native/src/main.cpp index 1bd288e..fa618c1 100644 --- a/native/src/main.cpp +++ b/native/src/main.cpp @@ -1,5 +1,6 @@ #include #include +#include "macos_capture.h" #include "oscilloscope.h" #include "spectrum.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)); exports.Set("vectorscope", vecExports); + RegisterMacOSCapture(env, exports); + return exports; } diff --git a/src/preload/index.ts b/src/preload/index.ts index 06661a5..c07f1b7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,5 +1,9 @@ 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 contextBridge.exposeInMainWorld('electronAPI', { @@ -9,7 +13,13 @@ contextBridge.exposeInMainWorld('electronAPI', { toggleAlwaysOnTop: () => ipcRenderer.send('window:toggle-always-on-top'), isAlwaysOnTop: () => ipcRenderer.invoke('window:is-always-on-top'), getDesktopSources: () => ipcRenderer.invoke('audio:get-desktop-sources') as Promise<{ id: string; name: string }[]>, - getCaptureBackendSupport: () => ipcRenderer.invoke('capture:get-backend-support') as Promise, + 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), collapseSettings: (panelHeight: number) => ipcRenderer.send('window:collapse-settings', 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 -let visualizerDSP: unknown = null +let nativeAddonModule: NativeAddonModule | null = null try { const isDev = process.env.NODE_ENV === 'development' const modulePath = isDev ? require('path').join(__dirname, '../../native/build/Release/visualizer_dsp.node') : require('path').join(process.resourcesPath!, 'native/visualizer_dsp.node') - visualizerDSP = require(modulePath) + nativeAddonModule = require(modulePath) as NativeAddonModule } catch { 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) diff --git a/src/renderer/audio/AudioCapture.ts b/src/renderer/audio/AudioCapture.ts index 088a1ae..b96ffc0 100644 --- a/src/renderer/audio/AudioCapture.ts +++ b/src/renderer/audio/AudioCapture.ts @@ -13,6 +13,7 @@ import type { CaptureMode, CaptureSourceDescriptor, } from '../../types/capture' +import type { NativeMacOSCaptureDrainResult, NativeMacOSCaptureStartResult } from '../../types/nativeCapture' export type { CaptureMode } from '../../types/capture' @@ -60,6 +61,7 @@ export interface CaptureManagerStatus { type StatusListener = (status: CaptureManagerStatus) => void const DEFAULT_BACKEND_POLICY: CaptureBackendPolicy = 'auto' +const DEFAULT_SYSTEM_SOURCE_ID = '__default_system_output__' const DEFAULT_BACKEND_SUPPORT: CaptureBackendSupport = { 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 { private audioContext: AudioContext | null = null private stream: MediaStream | null = null @@ -128,12 +139,7 @@ class ElectronCaptureRuntime { } async listSystemSources(): Promise { - const sources = await window.electronAPI.getDesktopSources() - return sources.map((source) => ({ - id: source.id, - label: source.name, - kind: 'system', - })) + return [getDefaultSystemSourceDescriptor()] } async listDeviceSources(): Promise { @@ -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 { + 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 { + this.stopPolling() + window.nativeCaptureAPI?.macosCapture.stop() + this.active = false + } + + async listSources(): Promise { + 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 { readonly kind: CaptureBackendKind private readonly reason: string | null @@ -380,6 +522,7 @@ class AudioCapture { private activeBackend: CaptureBackend | null = null private selectedDeviceId: string | null = null + private selectedSystemSourceId: string | null = DEFAULT_SYSTEM_SOURCE_ID private captureMode: CaptureMode = 'system' private backendPolicy: CaptureBackendPolicy = DEFAULT_BACKEND_POLICY private activeBackendReason: string | null = null @@ -403,12 +546,16 @@ class AudioCapture { } async refreshBackendSupport(): Promise { + this.backendSupport = null this.backendSupportPromise = null return this.ensureBackendSupport() } - async startSystemAudio(): Promise { + async startSystemAudio(sourceId?: string): Promise { this.captureMode = 'system' + if (sourceId) { + this.selectedSystemSourceId = sourceId + } await this.start() } @@ -428,9 +575,13 @@ class AudioCapture { const support = await this.ensureBackendSupport() 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) + await this.stopActiveCapture() + let lastError: Error | null = null let nativeFallbackReason: string | null = null @@ -462,15 +613,7 @@ class AudioCapture { } stop(): void { - if (this.sessionId !== null) { - audioRouter.endSession() - this.sessionId = null - } - - if (this.activeBackend) { - void this.activeBackend.stop() - } - + void this.stopActiveCapture() this.emitStatus() } @@ -481,7 +624,8 @@ class AudioCapture { } 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 { @@ -498,6 +642,15 @@ class AudioCapture { this.emitStatus() } + getSelectedSystemSourceId(): string | null { + return this.selectedSystemSourceId + } + + setSelectedSystemSourceId(id: string | null): void { + this.selectedSystemSourceId = id ?? DEFAULT_SYSTEM_SOURCE_ID + this.emitStatus() + } + getCaptureMode(): CaptureMode { return this.captureMode } @@ -544,7 +697,7 @@ class AudioCapture { .catch(() => DEFAULT_BACKEND_SUPPORT) .then((support) => { this.backendSupport = support - this.nativeBackend = new NativeUnavailableCaptureBackend(support.nativeBackend) + this.nativeBackend = this.createNativeBackend(support.nativeBackend) this.emitStatus() 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 { + 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 { if (!this.activeBackend || this.activeBackend.kind !== originKind || this.sessionId === null) { return diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index 8c4f1ae..4089d3d 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -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 { useSettingsStore, type ScopeSettings } from '../stores/settingsStore' import { useThemeStore, PRESETS, PRESET_IDS } from '../stores/themeStore' import type { ScopeKind } from '../../types/scope' import { buildAnalyzerGridTemplateColumns } from '../analyzerLayout' -import { audioRouter, type AudioRouterDiagnostics } from '../audio/AudioRouter' -import type { CaptureBackendKind, CaptureBackendPolicy } from '../../types/capture' const SCOPE_LABELS: Record = { spectrum: 'Spectrum', @@ -17,23 +15,6 @@ const SCOPE_LABELS: Record = { 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 { switch (mode) { case 'lissajous': @@ -496,30 +477,29 @@ interface SettingsPanelProps { export default function SettingsPanel({ onClose, onHeightChange }: SettingsPanelProps): JSX.Element { const { + systemSources, devices, + selectedSystemSourceId, selectedDeviceId, captureMode, - capturePolicy, - activeBackendKind, - activeBackendReason, isCapturing, captureStatus, captureError, + refreshSystemSources, refreshDevices, refreshBackendSupport, + selectSystemSource, selectDevice, - setCaptureMode, - setCapturePolicy, startCapture, } = useAudioStore() const { scopeSettings, updateScopeSettings, hiddenScopes, scopeOrder, widthWeights } = useSettingsStore() const { presetId, accent, setPreset, setCustomAccent, customAccent } = useThemeStore() const panelRef = useRef(null) - const [routerDiagnostics, setRouterDiagnostics] = useState( - () => 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 gridTemplateColumns = buildAnalyzerGridTemplateColumns(visibleScopes, widthWeights) if (!gridTemplateColumns) return undefined @@ -527,18 +507,10 @@ export default function SettingsPanel({ onClose, onHeightChange }: SettingsPanel }, [visibleScopes, widthWeights]) useEffect(() => { - void Promise.all([refreshDevices(), refreshBackendSupport()]) - }, [refreshBackendSupport, refreshDevices]) - - useEffect(() => { - const intervalId = window.setInterval(() => { - setRouterDiagnostics(audioRouter.getDiagnosticsSnapshot()) - }, 250) - - return () => { - window.clearInterval(intervalId) - } - }, []) + void refreshBackendSupport() + void refreshSystemSources() + void refreshDevices() + }, [refreshBackendSupport, refreshSystemSources, refreshDevices]) useEffect(() => { const panel = panelRef.current @@ -565,19 +537,36 @@ export default function SettingsPanel({ onClose, onHeightChange }: SettingsPanel }, [onHeightChange, visibleScopes.length]) const handleSourceChange = async (value: string): Promise => { - if (value === '__system__') { - setCaptureMode('system') + if (value.startsWith('system:')) { + const sourceId = value.slice('system:'.length) + await selectSystemSource(sourceId) await startCapture() return } - await selectDevice(value) - await startCapture() + if (value.startsWith('device:')) { + const deviceId = value.slice('device:'.length) + await selectDevice(deviceId) + await startCapture() + } } - const handlePolicyChange = async (value: string): Promise => { - await setCapturePolicy(value as CaptureBackendPolicy) - } + const selectedSourceValue = captureMode === 'system' + ? `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 ? 'Capturing' @@ -587,10 +576,6 @@ export default function SettingsPanel({ onClose, onHeightChange }: SettingsPanel ? 'Capture Failed' : 'Idle' - const latencyLabel = routerDiagnostics.overallP95CaptureToScopeMs === null - ? 'Waiting for samples' - : `${routerDiagnostics.overallP95CaptureToScopeMs.toFixed(1)} ms p95` - return (
@@ -601,34 +586,27 @@ export default function SettingsPanel({ onClose, onHeightChange }: SettingsPanel Source - - - @@ -637,18 +615,6 @@ export default function SettingsPanel({ onClose, onHeightChange }: SettingsPanel {indicatorLabel}
-
- Active backend: {captureBackendLabel(activeBackendKind)} -
- -
- Latency probe: {latencyLabel} · overwrites {routerDiagnostics.totalOverwriteCount} · stale drops {routerDiagnostics.staleSessionDrops} -
- - {activeBackendReason ? ( -
{activeBackendReason}
- ) : null} - {captureError ? (
{captureError}
) : null} diff --git a/src/renderer/env.d.ts b/src/renderer/env.d.ts index a5131ce..8baa177 100644 --- a/src/renderer/env.d.ts +++ b/src/renderer/env.d.ts @@ -2,10 +2,12 @@ import type { VisualizerDSP } from './audio/native/visualizer-dsp' import type { CaptureBackendSupport } from '../types/capture' +import type { NativeCaptureAPI } from '../types/nativeCapture' declare global { interface Window { visualizerAPI: VisualizerDSP | null + nativeCaptureAPI: NativeCaptureAPI | null electronAPI: { platform: string minimize: () => void diff --git a/src/renderer/stores/audioStore.ts b/src/renderer/stores/audioStore.ts index 313e66e..828b4e6 100644 --- a/src/renderer/stores/audioStore.ts +++ b/src/renderer/stores/audioStore.ts @@ -5,10 +5,13 @@ import type { CaptureBackendPolicy, CaptureBackendSupport, CaptureMode, + CaptureSourceDescriptor, } from '../../types/capture' interface AudioState { + systemSources: CaptureSourceDescriptor[] devices: MediaDeviceInfo[] + selectedSystemSourceId: string | null selectedDeviceId: string | null captureMode: CaptureMode capturePolicy: CaptureBackendPolicy @@ -20,8 +23,10 @@ interface AudioState { captureError: string | null sampleRate: number channelCount: number + refreshSystemSources: () => Promise refreshDevices: () => Promise refreshBackendSupport: () => Promise + selectSystemSource: (sourceId: string | null) => Promise selectDevice: (deviceId: string) => Promise setCaptureMode: (mode: CaptureMode) => void setCapturePolicy: (policy: CaptureBackendPolicy) => Promise @@ -43,7 +48,9 @@ function applyCaptureStatus(status: CaptureManagerStatus): Partial { } export const useAudioStore = create((set, get) => ({ + systemSources: [], devices: [], + selectedSystemSourceId: audioCapture.getSelectedSystemSourceId(), selectedDeviceId: null, captureMode: 'system', capturePolicy: 'auto', @@ -56,6 +63,21 @@ export const useAudioStore = create((set, get) => ({ sampleRate: 48000, 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 () => { const devices = await audioCapture.listDevices() set({ devices }) @@ -64,6 +86,16 @@ export const useAudioStore = create((set, get) => ({ refreshBackendSupport: async () => { const backendSupport = await audioCapture.refreshBackendSupport() 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) => { @@ -91,13 +123,15 @@ export const useAudioStore = create((set, get) => ({ startCapture: async () => { set({ captureStatus: 'connecting', captureError: null }) try { - const { captureMode, selectedDeviceId, capturePolicy } = get() + const { captureMode, capturePolicy } = get() audioCapture.setCaptureMode(captureMode) audioCapture.setBackendPolicy(capturePolicy) await get().refreshBackendSupport() + const { selectedDeviceId, selectedSystemSourceId } = get() + if (captureMode === 'system') { - await audioCapture.startSystemAudio() + await audioCapture.startSystemAudio(selectedSystemSourceId ?? undefined) } else { await audioCapture.startDevice(selectedDeviceId ?? undefined) } diff --git a/src/types/capture.ts b/src/types/capture.ts index 5dd9264..4cc847e 100644 --- a/src/types/capture.ts +++ b/src/types/capture.ts @@ -13,6 +13,9 @@ export interface CaptureSourceDescriptor { id: string label: string kind: CaptureMode + isDefault?: boolean + sampleRate?: number + channelCount?: number } export interface CaptureBackendSupportEntry { diff --git a/src/types/nativeCapture.ts b/src/types/nativeCapture.ts new file mode 100644 index 0000000..1736c26 --- /dev/null +++ b/src/types/nativeCapture.ts @@ -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 +}