mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
native macos backend, reduce latency
This commit is contained in:
+16
-1
@@ -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"]
|
||||||
}]
|
}]
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <napi.h>
|
||||||
|
|
||||||
|
void RegisterMacOSCapture(Napi::Env env, Napi::Object exports);
|
||||||
@@ -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__)
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Vendored
+2
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user