diff --git a/native/binding.gyp b/native/binding.gyp index b07cb7c..cb57abb 100644 --- a/native/binding.gyp +++ b/native/binding.gyp @@ -21,7 +21,8 @@ ["OS=='mac'", { "sources": [ "src/macos_capture.mm", - "src/windows_capture_stub.cpp" + "src/windows_capture_stub.cpp", + "src/linux_capture_stub.cpp" ], "xcode_settings": { "GCC_ENABLE_CPP_EXCEPTIONS": "YES", @@ -38,7 +39,8 @@ ["OS=='win'", { "sources": [ "src/macos_capture_stub.cpp", - "src/windows_capture.cpp" + "src/windows_capture.cpp", + "src/linux_capture_stub.cpp" ], "defines": [ "WIN32_LEAN_AND_MEAN", @@ -59,9 +61,13 @@ ["OS=='linux'", { "sources": [ "src/macos_capture_stub.cpp", - "src/windows_capture_stub.cpp" + "src/windows_capture_stub.cpp", + "src/linux_capture.cpp" ], - "cflags_cc": ["-std=c++17", "-O3", "-ffast-math", "-fPIC"] + "cflags_cc": ["-std=c++17", "-O3", "-ffast-math", "-fPIC"], + "libraries": [ + "-lpulse" + ] }] ] } diff --git a/native/src/linux_capture.cpp b/native/src/linux_capture.cpp new file mode 100644 index 0000000..d770478 --- /dev/null +++ b/native/src/linux_capture.cpp @@ -0,0 +1,906 @@ +#include "linux_capture.h" + +#if defined(__linux__) + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr size_t kMaxQueuedChunks = 256; +constexpr size_t kDefaultDrainChunkLimit = 64; + +struct OutputDeviceInfo { + std::string id; + std::string label; + std::string monitorSourceName; + pa_sample_spec sampleSpec{}; + pa_channel_map channelMap{}; + bool hasChannelMap = false; + bool isDefault = false; +}; + +struct CapturedChunk { + std::vector left; + std::vector right; + uint32_t 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(); +} + +uint32_t readUint24LE(const uint8_t* data) { + return static_cast(data[0]) | + (static_cast(data[1]) << 8) | + (static_cast(data[2]) << 16); +} + +uint32_t readUint24BE(const uint8_t* data) { + return static_cast(data[2]) | + (static_cast(data[1]) << 8) | + (static_cast(data[0]) << 16); +} + +uint32_t readUint32LE(const uint8_t* data) { + return static_cast(data[0]) | + (static_cast(data[1]) << 8) | + (static_cast(data[2]) << 16) | + (static_cast(data[3]) << 24); +} + +uint32_t readUint32BE(const uint8_t* data) { + return static_cast(data[3]) | + (static_cast(data[2]) << 8) | + (static_cast(data[1]) << 16) | + (static_cast(data[0]) << 24); +} + +int32_t signExtend24(uint32_t value) { + if ((value & 0x00800000U) != 0) { + value |= 0xFF000000U; + } + return static_cast(value); +} + +bool isSupportedSampleFormat(pa_sample_format_t format) { + switch (format) { + case PA_SAMPLE_U8: + case PA_SAMPLE_S16LE: + case PA_SAMPLE_S16BE: + case PA_SAMPLE_S24LE: + case PA_SAMPLE_S24BE: + case PA_SAMPLE_S24_32LE: + case PA_SAMPLE_S24_32BE: + case PA_SAMPLE_S32LE: + case PA_SAMPLE_S32BE: + case PA_SAMPLE_FLOAT32LE: + case PA_SAMPLE_FLOAT32BE: + return true; + default: + return false; + } +} + +float readNormalizedSample(const uint8_t* data, pa_sample_format_t format) { + if (data == nullptr) { + return 0.0f; + } + + switch (format) { + case PA_SAMPLE_U8: + return (static_cast(data[0]) - 128) / 128.0f; + case PA_SAMPLE_S16LE: + return static_cast( + static_cast(data[0]) | + (static_cast(data[1]) << 8)) / + 32768.0f; + case PA_SAMPLE_S16BE: + return static_cast( + static_cast(data[1]) | + (static_cast(data[0]) << 8)) / + 32768.0f; + case PA_SAMPLE_S24LE: + return signExtend24(readUint24LE(data)) / 8388608.0f; + case PA_SAMPLE_S24BE: + return signExtend24(readUint24BE(data)) / 8388608.0f; + case PA_SAMPLE_S24_32LE: { + uint32_t raw = readUint32LE(data) & 0x00FFFFFFU; + return signExtend24(raw) / 8388608.0f; + } + case PA_SAMPLE_S24_32BE: { + uint32_t raw = readUint32BE(data) & 0x00FFFFFFU; + return signExtend24(raw) / 8388608.0f; + } + case PA_SAMPLE_S32LE: + return static_cast(readUint32LE(data)) / 2147483648.0f; + case PA_SAMPLE_S32BE: + return static_cast(readUint32BE(data)) / 2147483648.0f; + case PA_SAMPLE_FLOAT32LE: { + const uint32_t raw = readUint32LE(data); + float value = 0.0f; + std::memcpy(&value, &raw, sizeof(value)); + return value; + } + case PA_SAMPLE_FLOAT32BE: { + const uint32_t raw = readUint32BE(data); + float value = 0.0f; + std::memcpy(&value, &raw, sizeof(value)); + return value; + } + default: + return 0.0f; + } +} + +struct SinkEnumerationState { + pa_threaded_mainloop* mainloop = nullptr; + std::string defaultSinkName; + std::vector devices; +}; + +void HandleServerInfo(pa_context*, const pa_server_info* info, void* userdata) { + auto* state = static_cast(userdata); + if (state != nullptr && info != nullptr && info->default_sink_name != nullptr) { + state->defaultSinkName = info->default_sink_name; + } + if (state != nullptr && state->mainloop != nullptr) { + pa_threaded_mainloop_signal(state->mainloop, 0); + } +} + +void HandleSinkInfo(pa_context*, const pa_sink_info* info, int eol, void* userdata) { + auto* state = static_cast(userdata); + if (state == nullptr || state->mainloop == nullptr) { + return; + } + + if (eol > 0) { + pa_threaded_mainloop_signal(state->mainloop, 0); + return; + } + + if (info != nullptr && info->name != nullptr && info->monitor_source_name != nullptr) { + OutputDeviceInfo device; + device.id = info->name; + device.label = info->description != nullptr ? info->description : info->name; + device.monitorSourceName = info->monitor_source_name; + device.sampleSpec = info->sample_spec; + device.channelMap = info->channel_map; + device.hasChannelMap = info->channel_map.channels > 0; + state->devices.push_back(device); + } + + pa_threaded_mainloop_signal(state->mainloop, 0); +} + +class PulseContextConnection { +public: + PulseContextConnection() = default; + + ~PulseContextConnection() { + disconnect(); + } + + bool connect(const std::string& contextName, std::string* outErrorMessage) { + disconnect(); + + mainloop_ = pa_threaded_mainloop_new(); + if (mainloop_ == nullptr) { + if (outErrorMessage != nullptr) { + *outErrorMessage = "Could not create a PulseAudio main loop."; + } + return false; + } + + context_ = + pa_context_new(pa_threaded_mainloop_get_api(mainloop_), contextName.c_str()); + if (context_ == nullptr) { + if (outErrorMessage != nullptr) { + *outErrorMessage = "Could not create a PulseAudio context."; + } + disconnect(); + return false; + } + + pa_context_set_state_callback(context_, &PulseContextConnection::HandleContextState, mainloop_); + + if (pa_threaded_mainloop_start(mainloop_) < 0) { + if (outErrorMessage != nullptr) { + *outErrorMessage = "Could not start the PulseAudio main loop."; + } + disconnect(); + return false; + } + started_ = true; + + pa_threaded_mainloop_lock(mainloop_); + const int connectResult = pa_context_connect(context_, nullptr, PA_CONTEXT_NOFLAGS, nullptr); + if (connectResult < 0) { + const std::string errorMessage = buildContextErrorMessage( + "Could not connect to PulseAudio.", context_); + pa_threaded_mainloop_unlock(mainloop_); + if (outErrorMessage != nullptr) { + *outErrorMessage = errorMessage; + } + disconnect(); + return false; + } + + const bool ready = waitForContextReadyLocked(outErrorMessage); + pa_threaded_mainloop_unlock(mainloop_); + if (!ready) { + disconnect(); + return false; + } + + return true; + } + + void disconnect() { + if (mainloop_ != nullptr && started_) { + pa_threaded_mainloop_lock(mainloop_); + if (context_ != nullptr) { + pa_context_set_state_callback(context_, nullptr, nullptr); + pa_context_disconnect(context_); + pa_context_unref(context_); + context_ = nullptr; + } + pa_threaded_mainloop_unlock(mainloop_); + pa_threaded_mainloop_stop(mainloop_); + } else if (context_ != nullptr) { + pa_context_unref(context_); + context_ = nullptr; + } + + if (mainloop_ != nullptr) { + pa_threaded_mainloop_free(mainloop_); + mainloop_ = nullptr; + } + + started_ = false; + } + + bool enumerateOutputDevices(std::vector* outDevices, + std::string* outErrorMessage) { + if (outDevices == nullptr) { + if (outErrorMessage != nullptr) { + *outErrorMessage = "Could not store PulseAudio output devices."; + } + return false; + } + + if (context_ == nullptr || mainloop_ == nullptr) { + if (outErrorMessage != nullptr) { + *outErrorMessage = "PulseAudio is not connected."; + } + return false; + } + + pa_threaded_mainloop_lock(mainloop_); + + SinkEnumerationState state; + state.mainloop = mainloop_; + + pa_operation* serverOperation = + pa_context_get_server_info(context_, &HandleServerInfo, &state); + if (!waitForOperationLocked(serverOperation, outErrorMessage)) { + pa_threaded_mainloop_unlock(mainloop_); + return false; + } + + pa_operation* sinkOperation = + pa_context_get_sink_info_list(context_, &HandleSinkInfo, &state); + if (!waitForOperationLocked(sinkOperation, outErrorMessage)) { + pa_threaded_mainloop_unlock(mainloop_); + return false; + } + + pa_threaded_mainloop_unlock(mainloop_); + + for (auto& device : state.devices) { + device.isDefault = device.id == state.defaultSinkName; + } + + if (state.devices.empty()) { + if (outErrorMessage != nullptr) { + *outErrorMessage = "No Linux output devices are available."; + } + return false; + } + + *outDevices = std::move(state.devices); + return true; + } + + pa_threaded_mainloop* mainloop() const { + return mainloop_; + } + + pa_context* context() const { + return context_; + } + + bool waitForOperationLocked(pa_operation* operation, std::string* outErrorMessage) { + return waitForOperationLockedInternal(operation, outErrorMessage); + } + +private: + static void HandleContextState(pa_context*, void* userdata) { + auto* mainloop = static_cast(userdata); + if (mainloop != nullptr) { + pa_threaded_mainloop_signal(mainloop, 0); + } + } + + static std::string buildContextErrorMessage(const char* prefix, pa_context* context) { + const char* pulseError = context != nullptr ? pa_strerror(pa_context_errno(context)) : nullptr; + if (pulseError == nullptr || pulseError[0] == '\0') { + return prefix; + } + return std::string(prefix) + " " + pulseError; + } + + bool waitForContextReadyLocked(std::string* outErrorMessage) const { + while (true) { + const pa_context_state_t state = pa_context_get_state(context_); + switch (state) { + case PA_CONTEXT_READY: + return true; + case PA_CONTEXT_FAILED: + case PA_CONTEXT_TERMINATED: + if (outErrorMessage != nullptr) { + *outErrorMessage = buildContextErrorMessage( + "PulseAudio context failed to initialize.", context_); + } + return false; + default: + pa_threaded_mainloop_wait(mainloop_); + break; + } + } + } + + bool waitForOperationLockedInternal(pa_operation* operation, + std::string* outErrorMessage) const { + if (operation == nullptr) { + if (outErrorMessage != nullptr) { + *outErrorMessage = buildContextErrorMessage( + "PulseAudio request could not be started.", context_); + } + return false; + } + + while (true) { + const pa_operation_state_t state = pa_operation_get_state(operation); + if (state == PA_OPERATION_DONE) { + pa_operation_unref(operation); + return true; + } + if (state == PA_OPERATION_CANCELLED) { + pa_operation_unref(operation); + if (outErrorMessage != nullptr) { + *outErrorMessage = buildContextErrorMessage( + "PulseAudio request was cancelled.", context_); + } + return false; + } + pa_threaded_mainloop_wait(mainloop_); + } + } + + pa_threaded_mainloop* mainloop_ = nullptr; + pa_context* context_ = nullptr; + bool started_ = false; +}; + +class LinuxNativeCaptureEngine { +public: + Napi::Value GetSupport(const Napi::CallbackInfo& info) const { + Napi::Object support = Napi::Object::New(info.Env()); + + PulseContextConnection connection; + std::string errorMessage; + std::vector devices; + const bool available = + connection.connect("Prism Linux Capture Probe", &errorMessage) && + connection.enumerateOutputDevices(&devices, &errorMessage); + + support.Set("available", Napi::Boolean::New(info.Env(), available)); + if (available) { + support.Set("reason", info.Env().Null()); + } else { + const std::string reason = errorMessage.empty() + ? "Native Linux capture is unavailable." + : errorMessage; + support.Set("reason", Napi::String::New(info.Env(), reason)); + } + return support; + } + + Napi::Value ListOutputDevices(const Napi::CallbackInfo& info) const { + Napi::Env env = info.Env(); + Napi::Array devicesArray = Napi::Array::New(env); + + PulseContextConnection connection; + std::string errorMessage; + std::vector devices; + if (!connection.connect("Prism Linux Capture Devices", &errorMessage) || + !connection.enumerateOutputDevices(&devices, &errorMessage)) { + return devicesArray; + } + + 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.id)); + 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, static_cast(device.sampleSpec.rate))); + entry.Set( + "channelCount", + Napi::Number::New(env, static_cast(device.sampleSpec.channels))); + devicesArray.Set(static_cast(index), entry); + } + + return devicesArray; + } + + Napi::Value Start(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + std::string requestedDeviceId; + if (info.Length() > 0 && info[0].IsString()) { + requestedDeviceId = info[0].As().Utf8Value(); + } + + std::string errorMessage; + if (!startInternal(requestedDeviceId, &errorMessage)) { + const std::string reason = errorMessage.empty() + ? "Native Linux monitor capture failed to start." + : errorMessage; + Napi::Error::New(env, reason) + .ThrowAsJavaScriptException(); + return env.Null(); + } + + std::lock_guard lock(stateMutex_); + Napi::Object result = Napi::Object::New(env); + 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, activeDeviceId_)); + result.Set("deviceLabel", Napi::String::New(env, activeDeviceLabel_)); + return result; + } + + Napi::Value Stop(const Napi::CallbackInfo& info) { + stopInternal(); + return info.Env().Undefined(); + } + + Napi::Value Drain(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + const size_t maxChunks = info.Length() > 0 && info[0].IsNumber() + ? std::max(1, info[0].As().Uint32Value()) + : kDefaultDrainChunkLimit; + + std::deque drainedChunks; + size_t overwriteCount = 0; + size_t queueDepth = 0; + + { + std::lock_guard lock(chunkMutex_); + const size_t chunkCount = std::min(maxChunks, chunkQueue_.size()); + for (size_t index = 0; index < chunkCount; ++index) { + drainedChunks.push_back(std::move(chunkQueue_.front())); + chunkQueue_.pop_front(); + } + overwriteCount = overwriteCount_; + overwriteCount_ = 0; + queueDepth = chunkQueue_.size(); + } + + Napi::Array chunks = Napi::Array::New(env, drainedChunks.size()); + for (size_t index = 0; index < drainedChunks.size(); ++index) { + const auto& chunk = drainedChunks[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(queueDepth))); + return result; + } + + Napi::Value NowMilliseconds(const Napi::CallbackInfo& info) const { + return Napi::Number::New(info.Env(), monotonicMilliseconds()); + } + +private: + static void HandleStreamState(pa_stream*, void* userdata) { + auto* mainloop = static_cast(userdata); + if (mainloop != nullptr) { + pa_threaded_mainloop_signal(mainloop, 0); + } + } + + static void HandleStreamRead(pa_stream*, size_t, void* userdata) { + auto* self = static_cast(userdata); + if (self != nullptr) { + self->handleReadableStream(); + } + } + + static std::string buildContextErrorMessage(pa_context* context, const char* prefix) { + const char* pulseError = context != nullptr ? pa_strerror(pa_context_errno(context)) : nullptr; + if (pulseError == nullptr || pulseError[0] == '\0') { + return prefix; + } + return std::string(prefix) + " " + pulseError; + } + + bool startInternal(const std::string& requestedDeviceId, std::string* outErrorMessage) { + stopInternal(); + + if (!connection_.connect("Prism Linux Capture", outErrorMessage)) { + return false; + } + + std::vector devices; + if (!connection_.enumerateOutputDevices(&devices, outErrorMessage)) { + connection_.disconnect(); + return false; + } + + const OutputDeviceInfo* selected = nullptr; + if (!requestedDeviceId.empty()) { + for (const auto& device : devices) { + if (device.id == requestedDeviceId) { + selected = &device; + break; + } + } + if (selected == nullptr) { + if (outErrorMessage != nullptr) { + *outErrorMessage = + "The selected Linux output device is no longer available."; + } + connection_.disconnect(); + return false; + } + } else { + for (const auto& device : devices) { + if (device.isDefault) { + selected = &device; + break; + } + } + if (selected == nullptr) { + selected = &devices.front(); + } + } + + if (!isSupportedSampleFormat(selected->sampleSpec.format)) { + if (outErrorMessage != nullptr) { + *outErrorMessage = + "Unsupported PulseAudio sample format for Linux monitor capture."; + } + connection_.disconnect(); + return false; + } + + pa_threaded_mainloop_lock(connection_.mainloop()); + + stream_ = pa_stream_new( + connection_.context(), + "Prism Output Monitor", + &selected->sampleSpec, + selected->hasChannelMap ? &selected->channelMap : nullptr); + if (stream_ == nullptr) { + const std::string errorMessage = buildContextErrorMessage( + connection_.context(), "Could not create a PulseAudio recording stream."); + pa_threaded_mainloop_unlock(connection_.mainloop()); + if (outErrorMessage != nullptr) { + *outErrorMessage = errorMessage; + } + connection_.disconnect(); + return false; + } + + pa_stream_set_state_callback(stream_, &HandleStreamState, connection_.mainloop()); + pa_stream_set_read_callback(stream_, &HandleStreamRead, this); + + const pa_stream_flags_t flags = static_cast( + PA_STREAM_ADJUST_LATENCY | + PA_STREAM_AUTO_TIMING_UPDATE | + PA_STREAM_INTERPOLATE_TIMING | + PA_STREAM_DONT_MOVE); + const int connectResult = pa_stream_connect_record( + stream_, selected->monitorSourceName.c_str(), nullptr, flags); + if (connectResult < 0) { + const std::string errorMessage = buildContextErrorMessage( + connection_.context(), "Could not start Linux monitor capture."); + pa_stream_set_read_callback(stream_, nullptr, nullptr); + pa_stream_set_state_callback(stream_, nullptr, nullptr); + pa_stream_unref(stream_); + stream_ = nullptr; + pa_threaded_mainloop_unlock(connection_.mainloop()); + if (outErrorMessage != nullptr) { + *outErrorMessage = errorMessage; + } + connection_.disconnect(); + return false; + } + + if (!waitForStreamReadyLocked(outErrorMessage)) { + if (stream_ != nullptr) { + pa_stream_set_read_callback(stream_, nullptr, nullptr); + pa_stream_set_state_callback(stream_, nullptr, nullptr); + pa_stream_disconnect(stream_); + pa_stream_unref(stream_); + stream_ = nullptr; + } + pa_threaded_mainloop_unlock(connection_.mainloop()); + connection_.disconnect(); + return false; + } + + const pa_sample_spec* activeSpec = pa_stream_get_sample_spec(stream_); + if (activeSpec != nullptr) { + sampleSpec_ = *activeSpec; + } else { + sampleSpec_ = selected->sampleSpec; + } + + { + std::lock_guard lock(stateMutex_); + active_ = true; + activeDeviceId_ = selected->id; + activeDeviceLabel_ = selected->label; + sampleRate_ = static_cast(sampleSpec_.rate); + channelCount_ = std::max(1, sampleSpec_.channels); + sequence_ = 0; + } + + pa_threaded_mainloop_unlock(connection_.mainloop()); + return true; + } + + bool waitForStreamReadyLocked(std::string* outErrorMessage) const { + while (stream_ != nullptr) { + const pa_stream_state_t state = pa_stream_get_state(stream_); + switch (state) { + case PA_STREAM_READY: + return true; + case PA_STREAM_FAILED: + case PA_STREAM_TERMINATED: + if (outErrorMessage != nullptr) { + *outErrorMessage = buildContextErrorMessage( + connection_.context(), + "PulseAudio monitor stream failed to initialize."); + } + return false; + default: + pa_threaded_mainloop_wait(connection_.mainloop()); + break; + } + } + + if (outErrorMessage != nullptr) { + *outErrorMessage = "PulseAudio monitor stream is unavailable."; + } + return false; + } + + void stopInternal() { + if (connection_.mainloop() != nullptr) { + pa_threaded_mainloop_lock(connection_.mainloop()); + if (stream_ != nullptr) { + pa_stream_set_read_callback(stream_, nullptr, nullptr); + pa_stream_set_state_callback(stream_, nullptr, nullptr); + pa_stream_disconnect(stream_); + pa_stream_unref(stream_); + stream_ = nullptr; + } + pa_threaded_mainloop_unlock(connection_.mainloop()); + } + + connection_.disconnect(); + + { + std::lock_guard lock(stateMutex_); + active_ = false; + activeDeviceId_.clear(); + activeDeviceLabel_.clear(); + sampleRate_ = 48000.0; + channelCount_ = 2; + sequence_ = 0; + sampleSpec_ = pa_sample_spec{}; + } + + { + std::lock_guard lock(chunkMutex_); + chunkQueue_.clear(); + overwriteCount_ = 0; + } + } + + void handleReadableStream() { + if (stream_ == nullptr) { + return; + } + + while (true) { + const void* data = nullptr; + size_t length = 0; + if (pa_stream_peek(stream_, &data, &length) < 0) { + break; + } + + if (length == 0) { + pa_stream_drop(stream_); + break; + } + + pa_sample_spec sampleSpec{}; + uint32_t channelCount = 2; + uint64_t sequence = 0; + { + std::lock_guard lock(stateMutex_); + if (!active_) { + pa_stream_drop(stream_); + break; + } + sampleSpec = sampleSpec_; + channelCount = channelCount_; + sequence = ++sequence_; + } + + const size_t bytesPerFrame = pa_frame_size(&sampleSpec); + if (bytesPerFrame == 0) { + pa_stream_drop(stream_); + break; + } + + const size_t bytesPerSample = pa_sample_size_of_format(sampleSpec.format); + const size_t frames = length / bytesPerFrame; + if (frames == 0) { + pa_stream_drop(stream_); + break; + } + + CapturedChunk chunk; + chunk.channelCount = channelCount; + chunk.capturedAtMilliseconds = monotonicMilliseconds(); + chunk.sequence = sequence; + chunk.left.resize(frames); + chunk.right.resize(frames); + + if (data != nullptr) { + const auto* rawData = static_cast(data); + for (size_t frameIndex = 0; frameIndex < frames; ++frameIndex) { + const uint8_t* frameData = rawData + (frameIndex * bytesPerFrame); + const float left = readNormalizedSample(frameData, sampleSpec.format); + const float right = channelCount > 1 + ? readNormalizedSample(frameData + bytesPerSample, sampleSpec.format) + : left; + chunk.left[frameIndex] = left; + chunk.right[frameIndex] = right; + } + } else { + std::fill(chunk.left.begin(), chunk.left.end(), 0.0f); + std::fill(chunk.right.begin(), chunk.right.end(), 0.0f); + } + + pa_stream_drop(stream_); + pushChunk(std::move(chunk)); + + if (pa_stream_readable_size(stream_) == 0) { + break; + } + } + } + + void pushChunk(CapturedChunk&& chunk) { + std::lock_guard lock(chunkMutex_); + if (chunkQueue_.size() >= kMaxQueuedChunks) { + chunkQueue_.pop_front(); + ++overwriteCount_; + } + chunkQueue_.push_back(std::move(chunk)); + } + + PulseContextConnection connection_; + pa_stream* stream_ = nullptr; + mutable std::mutex stateMutex_; + mutable std::mutex chunkMutex_; + bool active_ = false; + std::string activeDeviceId_; + std::string activeDeviceLabel_; + double sampleRate_ = 48000.0; + uint32_t channelCount_ = 2; + uint64_t sequence_ = 0; + pa_sample_spec sampleSpec_{}; + std::deque chunkQueue_; + size_t overwriteCount_ = 0; +}; + +LinuxNativeCaptureEngine& GetLinuxNativeCaptureEngine() { + static LinuxNativeCaptureEngine engine; + return engine; +} + +Napi::Value LinuxGetSupport(const Napi::CallbackInfo& info) { + return GetLinuxNativeCaptureEngine().GetSupport(info); +} + +Napi::Value LinuxListOutputDevices(const Napi::CallbackInfo& info) { + return GetLinuxNativeCaptureEngine().ListOutputDevices(info); +} + +Napi::Value LinuxStart(const Napi::CallbackInfo& info) { + return GetLinuxNativeCaptureEngine().Start(info); +} + +Napi::Value LinuxStop(const Napi::CallbackInfo& info) { + return GetLinuxNativeCaptureEngine().Stop(info); +} + +Napi::Value LinuxDrain(const Napi::CallbackInfo& info) { + return GetLinuxNativeCaptureEngine().Drain(info); +} + +Napi::Value LinuxNowMilliseconds(const Napi::CallbackInfo& info) { + return GetLinuxNativeCaptureEngine().NowMilliseconds(info); +} + +} // namespace + +void RegisterLinuxCapture(Napi::Env env, Napi::Object exports) { + Napi::Object captureExports = Napi::Object::New(env); + captureExports.Set("getSupport", Napi::Function::New(env, LinuxGetSupport)); + captureExports.Set( + "listOutputDevices", Napi::Function::New(env, LinuxListOutputDevices)); + captureExports.Set("start", Napi::Function::New(env, LinuxStart)); + captureExports.Set("stop", Napi::Function::New(env, LinuxStop)); + captureExports.Set("drain", Napi::Function::New(env, LinuxDrain)); + captureExports.Set("nowMilliseconds", Napi::Function::New(env, LinuxNowMilliseconds)); + exports.Set("linuxCapture", captureExports); +} + +#endif diff --git a/native/src/linux_capture.h b/native/src/linux_capture.h new file mode 100644 index 0000000..3e38e97 --- /dev/null +++ b/native/src/linux_capture.h @@ -0,0 +1,5 @@ +#pragma once + +#include + +void RegisterLinuxCapture(Napi::Env env, Napi::Object exports); diff --git a/native/src/linux_capture_stub.cpp b/native/src/linux_capture_stub.cpp new file mode 100644 index 0000000..12a0ed2 --- /dev/null +++ b/native/src/linux_capture_stub.cpp @@ -0,0 +1,54 @@ +#include "linux_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 Linux 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 Linux 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 RegisterLinuxCapture(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("linuxCapture", captureExports); +} diff --git a/native/src/main.cpp b/native/src/main.cpp index 6f3dcb0..f0251e0 100644 --- a/native/src/main.cpp +++ b/native/src/main.cpp @@ -1,5 +1,6 @@ #include #include +#include "linux_capture.h" #include "macos_capture.h" #include "windows_capture.h" #include "oscilloscope.h" @@ -391,6 +392,7 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) { RegisterMacOSCapture(env, exports); RegisterWindowsCapture(env, exports); + RegisterLinuxCapture(env, exports); return exports; } diff --git a/package.json b/package.json index 4e0dba4..a390ea5 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,9 @@ "preview": "electron-vite preview", "typecheck": "tsc --noEmit", "test:audio-router": "node scripts/run-audio-router-tests.mjs", + "test:audio-store": "node scripts/run-audio-store-tests.mjs", "test:astra": "node scripts/run-astra-integration-tests.mjs", + "test:capture-support": "node scripts/run-capture-support-tests.mjs", "test:profiles": "node scripts/run-profile-library-tests.mjs", "test:themes": "node scripts/run-theme-library-tests.mjs", "test:window-state": "node scripts/run-window-state-tests.mjs", diff --git a/scripts/run-audio-store-tests.mjs b/scripts/run-audio-store-tests.mjs new file mode 100644 index 0000000..629123f --- /dev/null +++ b/scripts/run-audio-store-tests.mjs @@ -0,0 +1,44 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawn } from 'node:child_process' +import { build } from 'esbuild' + +const rootDir = dirname(dirname(fileURLToPath(import.meta.url))) +const tempDir = await mkdtemp(join(tmpdir(), 'prism-audio-store-tests-')) +const bundledTestPath = join(tempDir, 'audio-store.test.mjs') +const entryPoint = join(rootDir, 'test', 'audio-store.test.ts') + +let exitCode = 1 + +try { + await build({ + entryPoints: [entryPoint], + outfile: bundledTestPath, + bundle: true, + platform: 'node', + format: 'esm', + target: 'node23', + sourcemap: 'inline', + }) + + exitCode = await new Promise((resolve) => { + const child = spawn(process.execPath, ['--test', bundledTestPath], { + stdio: 'inherit', + cwd: rootDir, + }) + + child.on('exit', (code) => { + resolve(code ?? 1) + }) + + child.on('error', () => { + resolve(1) + }) + }) +} finally { + await rm(tempDir, { recursive: true, force: true }) +} + +process.exit(exitCode) diff --git a/scripts/run-capture-support-tests.mjs b/scripts/run-capture-support-tests.mjs new file mode 100644 index 0000000..f05f2e5 --- /dev/null +++ b/scripts/run-capture-support-tests.mjs @@ -0,0 +1,44 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawn } from 'node:child_process' +import { build } from 'esbuild' + +const rootDir = dirname(dirname(fileURLToPath(import.meta.url))) +const tempDir = await mkdtemp(join(tmpdir(), 'prism-capture-support-tests-')) +const bundledTestPath = join(tempDir, 'capture-support.test.mjs') +const entryPoint = join(rootDir, 'test', 'capture-support.test.ts') + +let exitCode = 1 + +try { + await build({ + entryPoints: [entryPoint], + outfile: bundledTestPath, + bundle: true, + platform: 'node', + format: 'esm', + target: 'node23', + sourcemap: 'inline', + }) + + exitCode = await new Promise((resolve) => { + const child = spawn(process.execPath, ['--test', bundledTestPath], { + stdio: 'inherit', + cwd: rootDir, + }) + + child.on('exit', (code) => { + resolve(code ?? 1) + }) + + child.on('error', () => { + resolve(1) + }) + }) +} finally { + await rm(tempDir, { recursive: true, force: true }) +} + +process.exit(exitCode) diff --git a/src/main/index.ts b/src/main/index.ts index 1519546..679d40c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, desktopCapturer, dialog, ipcMain, Menu, nativeTheme, screen, session, shell } from 'electron' +import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, screen, session, shell } from 'electron' import type { BrowserWindowConstructorOptions, MenuItemConstructorOptions, OpenDialogOptions, WebContents } from 'electron' import { extname, join, resolve } from 'path' import type { @@ -6,7 +6,6 @@ import type { AstraIntegrationConfig, AstraIntegrationState, } from '../types/astra' -import type { CaptureBackendSupport, CaptureBackendSupportEntry } from '../types/capture' import type { ScopePopoutAudioBatch, ScopePopoutSessionState, @@ -957,50 +956,9 @@ function syncScopePopouts(nextState: ScopePopoutSyncStateMap): void { } } -function getNativeCaptureSupportEntry(): CaptureBackendSupportEntry { - if (process.platform === 'darwin') { - return { - kind: 'native-macos', - available: false, - reason: 'Native macOS system audio capture is not implemented in this build.', - } - } - - if (process.platform === 'win32') { - return { - kind: 'native-windows', - available: false, - reason: 'Native Windows WASAPI loopback capture is not implemented in this build.', - } - } - - return { - kind: 'native-linux', - available: false, - reason: 'Native Linux monitor capture is not implemented in this build.', - } -} - -function getCaptureBackendSupport(): CaptureBackendSupport { - return { - policyOptions: ['auto', 'native', 'electron'], - nativeBackend: getNativeCaptureSupportEntry(), - electronSystem: { - kind: 'electron-system', - available: true, - reason: null, - }, - electronDevice: { - kind: 'electron-device', - available: true, - reason: null, - }, - } -} - function setupPermissions(): void { session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => { - if (permission === 'media' || permission === 'display-capture') { + if (permission === 'media') { callback(true) } else { callback(false) @@ -1121,15 +1079,6 @@ function setupIPC(): void { return getWindowFromSender(event.sender)?.isAlwaysOnTop() ?? false }) - ipcMain.handle('audio:get-desktop-sources', async () => { - const sources = await desktopCapturer.getSources({ types: ['screen'] }) - return sources.map((s) => ({ id: s.id, name: s.name })) - }) - - ipcMain.handle('capture:get-backend-support', () => { - return getCaptureBackendSupport() - }) - ipcMain.handle('astra:get-config', async () => { return getAstraIntegrationService().getConfig() }) diff --git a/src/preload/captureSupport.ts b/src/preload/captureSupport.ts new file mode 100644 index 0000000..7c6d7e1 --- /dev/null +++ b/src/preload/captureSupport.ts @@ -0,0 +1,70 @@ +import type { CaptureBackendSupport, CaptureBackendSupportEntry } from '../types/capture' +import { resolveNativeBackendKind } from '../types/capture' +import type { NativeCaptureAPI } from '../types/nativeCapture' + +function getUnavailableNativeEntry(platform: string, reason: string): CaptureBackendSupportEntry { + return { + kind: resolveNativeBackendKind(platform), + available: false, + reason, + } +} + +export function resolveNativeCaptureSupport( + platform: string, + nativeCaptureAPI: NativeCaptureAPI | null, +): CaptureBackendSupportEntry { + if (platform === 'darwin') { + const macosCapture = nativeCaptureAPI?.macosCapture + if (!macosCapture) { + return getUnavailableNativeEntry(platform, 'Native capture module is not available in this build.') + } + + const support = macosCapture.getSupport() + return { + kind: 'native-macos', + available: support.available, + reason: support.reason, + } + } + + if (platform === 'win32') { + const windowsCapture = nativeCaptureAPI?.windowsCapture + if (!windowsCapture) { + return getUnavailableNativeEntry(platform, 'Native capture module is not available in this build.') + } + + const support = windowsCapture.getSupport() + return { + kind: 'native-windows', + available: support.available, + reason: support.reason, + } + } + + const linuxCapture = nativeCaptureAPI?.linuxCapture + if (!linuxCapture) { + return getUnavailableNativeEntry(platform, 'Native capture module is not available in this build.') + } + + const support = linuxCapture.getSupport() + return { + kind: 'native-linux', + available: support.available, + reason: support.reason, + } +} + +export function getCaptureBackendSupport( + platform: string, + nativeCaptureAPI: NativeCaptureAPI | null, +): CaptureBackendSupport { + return { + nativeBackend: resolveNativeCaptureSupport(platform, nativeCaptureAPI), + deviceInput: { + kind: 'device-input', + available: true, + reason: null, + }, + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 0658e7c..c1ddf6b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4,7 +4,7 @@ import type { AstraIntegrationConfig, AstraIntegrationState, } from '../types/astra' -import type { CaptureBackendSupport, CaptureBackendSupportEntry } from '../types/capture' +import type { CaptureBackendSupport } from '../types/capture' import type { NativeCaptureAPI } from '../types/nativeCapture' import type { ScopePopoutAudioBatch, @@ -29,6 +29,7 @@ import type { import type { DialogOptions, DialogResult } from '../types/dialog' import type { ResizeDirection } from '../types/windowResize' import type { VisualizerDSP } from '../renderer/audio/native/visualizer-dsp' +import { getCaptureBackendSupport } from './captureSupport' type NativeAddonModule = VisualizerDSP & NativeCaptureAPI @@ -46,14 +47,7 @@ contextBridge.exposeInMainWorld('electronAPI', { repositionWindow: (position: 'top' | 'bottom') => ipcRenderer.send('window:reposition', position), 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: async () => { - const support = await ipcRenderer.invoke('capture:get-backend-support') as CaptureBackendSupport - return { - ...support, - nativeBackend: resolveNativeCaptureSupport(support.nativeBackend), - } satisfies CaptureBackendSupport - }, + getCaptureBackendSupport: async () => getCaptureBackendSupport(process.platform, nativeCaptureAPI) as CaptureBackendSupport, getAstraConfig: () => ipcRenderer.invoke('astra:get-config') as Promise, saveAstraConfig: (config: AstraIntegrationConfig) => ipcRenderer.invoke('astra:save-config', config) as Promise, getAstraState: () => ipcRenderer.invoke('astra:get-state') as Promise, @@ -224,48 +218,6 @@ try { console.warn('Native DSP module not available — using JS fallback') } -function resolveNativeCaptureSupport( - fallbackEntry: CaptureBackendSupportEntry, -): CaptureBackendSupportEntry { - if (process.platform === 'darwin') { - 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, - } - } - - if (process.platform === 'win32') { - const windowsCapture = nativeAddonModule?.windowsCapture - if (!windowsCapture) { - return { - kind: 'native-windows', - available: false, - reason: 'Native capture module is not available in this build.', - } - } - - const support = windowsCapture.getSupport() - return { - kind: 'native-windows', - available: support.available, - reason: support.reason, - } - } - - return fallbackEntry -} - const visualizerAPI = nativeAddonModule ? { oscilloscope: nativeAddonModule.oscilloscope, @@ -278,6 +230,7 @@ const nativeCaptureAPI = nativeAddonModule ? { macosCapture: nativeAddonModule.macosCapture, windowsCapture: nativeAddonModule.windowsCapture, + linuxCapture: nativeAddonModule.linuxCapture, } : null diff --git a/src/renderer/audio/AudioCapture.ts b/src/renderer/audio/AudioCapture.ts index b23a846..6148564 100644 --- a/src/renderer/audio/AudioCapture.ts +++ b/src/renderer/audio/AudioCapture.ts @@ -1,7 +1,7 @@ /** * AudioCapture — backend manager for Prism's live capture pipeline. - * Stage 1 keeps Chromium capture as the working backend, while exposing - * native-backend policy and support plumbing for future low-latency paths. + * System output capture flows through native platform backends while + * input-device capture continues to use browser media devices. */ import { audioRouter } from './AudioRouter' @@ -9,12 +9,12 @@ import { nativeVisualizerTransport } from './NativeVisualizerTransport' import { applyInputGainToStereoSamples, inputGainDbToLinear } from './inputGain' import type { CaptureBackendKind, - CaptureBackendPolicy, CaptureBackendSupport, CaptureBackendSupportEntry, CaptureMode, CaptureSourceDescriptor, } from '../../types/capture' +import { resolveNativeBackendKind } from '../../types/capture' import type { NativeCaptureDrainResult, NativeCaptureStartResult, @@ -64,12 +64,36 @@ function resolveNativeCapturePollDelay(chunkCount: number): number { return chunkCount > 0 ? 0 : 2 } +function resolvePlatform(): string { + if (typeof window !== 'undefined' && typeof window.electronAPI !== 'undefined') { + return window.electronAPI.platform + } + + if (typeof process !== 'undefined' && typeof process.platform === 'string') { + return process.platform + } + + return 'linux' +} + +export function createDefaultBackendSupport(platform = resolvePlatform()): CaptureBackendSupport { + return { + nativeBackend: { + kind: resolveNativeBackendKind(platform), + available: false, + reason: 'Native system audio capture is not available in this build.', + }, + deviceInput: { + kind: 'device-input', + available: true, + reason: null, + }, + } +} export interface CaptureManagerStatus { captureMode: CaptureMode - backendPolicy: CaptureBackendPolicy activeBackendKind: CaptureBackendKind | null - activeBackendReason: string | null backendSupport: CaptureBackendSupport | null sampleRate: number channelCount: number @@ -78,32 +102,8 @@ 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'], - nativeBackend: { - kind: window.electronAPI.platform === 'darwin' - ? 'native-macos' - : window.electronAPI.platform === 'win32' - ? 'native-windows' - : 'native-linux', - available: false, - reason: 'Native system audio capture is not implemented in this build.', - }, - electronSystem: { - kind: 'electron-system', - available: true, - reason: null, - }, - electronDevice: { - kind: 'electron-device', - available: true, - reason: null, - }, -} - function toDeviceSourceDescriptor(device: MediaDeviceInfo): CaptureSourceDescriptor { return { id: device.deviceId, @@ -121,7 +121,7 @@ function getDefaultSystemSourceDescriptor(): CaptureSourceDescriptor { } } -class ElectronCaptureRuntime { +class DeviceInputCaptureRuntime { private audioContext: AudioContext | null = null private stream: MediaStream | null = null private sourceNode: MediaStreamAudioSourceNode | null = null @@ -130,7 +130,7 @@ class ElectronCaptureRuntime { private workletLoaded = false private chunkListeners = new Set<(chunk: CaptureChunk) => void>() private active = false - private currentConfigKey: string | null = null + private currentDeviceId: string | null = null private sequence = 0 private sampleRate = 48000 private channelCount = 2 @@ -148,12 +148,20 @@ class ElectronCaptureRuntime { } } - async startSystem(): Promise { - await this.start({ mode: 'system' }) - } - async startDevice(deviceId?: string): Promise { - await this.start({ mode: 'device', deviceId }) + await this.ensureContext() + + const requestedDeviceId = deviceId ?? null + if (this.currentDeviceId !== requestedDeviceId || !this.stream || !this.sourceNode) { + const nextStream = await this.requestDeviceStream(deviceId) + this.attachStream(nextStream, requestedDeviceId) + } + + this.sequence = 0 + this.active = true + if (this.audioContext && this.audioContext.state !== 'running') { + await this.audioContext.resume() + } } async stop(): Promise { @@ -163,10 +171,6 @@ class ElectronCaptureRuntime { } } - async listSystemSources(): Promise { - return [getDefaultSystemSourceDescriptor()] - } - async listDeviceSources(): Promise { const devices = await navigator.mediaDevices.enumerateDevices() return devices @@ -174,35 +178,17 @@ class ElectronCaptureRuntime { .map((device) => toDeviceSourceDescriptor(device)) } - getStatus(kind: CaptureBackendKind, reason: string | null = null): CaptureBackendStatus { + getStatus(kind: CaptureBackendKind): CaptureBackendStatus { return { kind, active: this.active, available: true, - reason, + reason: null, sampleRate: this.sampleRate, channelCount: this.channelCount, } } - private async start(config: { mode: CaptureMode; deviceId?: string }): Promise { - const configKey = `${config.mode}:${config.deviceId ?? ''}` - await this.ensureContext() - - if (this.currentConfigKey !== configKey || !this.stream || !this.sourceNode) { - const nextStream = config.mode === 'system' - ? await this.requestSystemStream() - : await this.requestDeviceStream(config.deviceId) - this.attachStream(nextStream, configKey) - } - - this.sequence = 0 - this.active = true - if (this.audioContext && this.audioContext.state !== 'running') { - await this.audioContext.resume() - } - } - private async ensureContext(): Promise { if (!this.audioContext) { this.audioContext = new AudioContext({ latencyHint: 'interactive' }) @@ -248,7 +234,7 @@ class ElectronCaptureRuntime { } } - private attachStream(stream: MediaStream, configKey: string): void { + private attachStream(stream: MediaStream, deviceId: string | null): void { if (!this.audioContext || !this.workletNode) return if (this.sourceNode) { @@ -261,6 +247,7 @@ class ElectronCaptureRuntime { } this.stream = stream + this.currentDeviceId = deviceId this.sourceNode = this.audioContext.createMediaStreamSource(stream) if (this.gainNode) { this.sourceNode.connect(this.gainNode) @@ -277,7 +264,6 @@ class ElectronCaptureRuntime { Math.floor(trackSettings?.channelCount ?? this.sourceNode.channelCount ?? 2), ) this.sampleRate = Math.max(1, Math.floor(this.audioContext.sampleRate)) - this.currentConfigKey = configKey } private syncGainNode(): void { @@ -286,30 +272,6 @@ class ElectronCaptureRuntime { } } - private async requestSystemStream(): Promise { - const sources = await window.electronAPI.getDesktopSources() - if (!sources.length) { - throw new Error('No desktop sources available for system audio capture') - } - - const stream = await navigator.mediaDevices.getUserMedia({ - audio: { - mandatory: { - chromeMediaSource: 'desktop', - }, - } as unknown as MediaTrackConstraints, - video: { - mandatory: { - chromeMediaSource: 'desktop', - chromeMediaSourceId: sources[0].id, - }, - } as unknown as MediaTrackConstraints, - }) - - stream.getVideoTracks().forEach((track) => track.stop()) - return stream - } - private async requestDeviceStream(deviceId?: string): Promise { const constraints: MediaStreamConstraints = { audio: { @@ -325,40 +287,12 @@ class ElectronCaptureRuntime { } } -class ElectronSystemCaptureBackend implements CaptureBackend { - readonly kind = 'electron-system' as const +class DeviceInputCaptureBackend implements CaptureBackend { + readonly kind = 'device-input' as const - constructor(private readonly runtime: ElectronCaptureRuntime) {} - - async start(): Promise { - await this.runtime.startSystem() - } - - async stop(): Promise { - await this.runtime.stop() - } - - async listSources(): Promise { - return this.runtime.listSystemSources() - } - - subscribe(listener: (chunk: CaptureChunk) => void): () => void { - return this.runtime.subscribe(listener) - } - - getStatus(): CaptureBackendStatus { - return this.runtime.getStatus(this.kind) - } -} - -class ElectronDeviceCaptureBackend implements CaptureBackend { - readonly kind = 'electron-device' as const - private lastDeviceId: string | undefined - - constructor(private readonly runtime: ElectronCaptureRuntime) {} + constructor(private readonly runtime: DeviceInputCaptureRuntime) {} async start(request?: CaptureBackendStartRequest): Promise { - this.lastDeviceId = request?.deviceId await this.runtime.startDevice(request?.deviceId) } @@ -375,7 +309,7 @@ class ElectronDeviceCaptureBackend implements CaptureBackend { } getStatus(): CaptureBackendStatus { - return this.runtime.getStatus(this.kind, this.lastDeviceId ? null : null) + return this.runtime.getStatus(this.kind) } } @@ -430,20 +364,15 @@ export abstract class NativePolledCaptureBackend implements CaptureBackend { async listSources(): Promise { const nativeCapture = this.getNativeCaptureModule() if (!nativeCapture) { - return [getDefaultSystemSourceDescriptor()] + return [] } const support = nativeCapture.getSupport() if (!support.available) { - return [getDefaultSystemSourceDescriptor()] + return [] } - const sources = nativeCapture.listOutputDevices() - if (!sources.length) { - return [getDefaultSystemSourceDescriptor()] - } - - return sources.map((source) => ({ + return nativeCapture.listOutputDevices().map((source) => ({ id: source.id, label: source.label, kind: 'system', @@ -524,7 +453,7 @@ class NativeMacOSCaptureBackend extends NativePolledCaptureBackend { readonly kind = 'native-macos' as const protected getNativeCaptureModule(): NativeSystemCaptureAPI | null { - return window.nativeCaptureAPI?.macosCapture ?? null + return typeof window !== 'undefined' ? window.nativeCaptureAPI?.macosCapture ?? null : null } protected getBackendLabel(): string { @@ -536,7 +465,7 @@ class NativeWindowsCaptureBackend extends NativePolledCaptureBackend { readonly kind = 'native-windows' as const protected getNativeCaptureModule(): NativeSystemCaptureAPI | null { - return window.nativeCaptureAPI?.windowsCapture ?? null + return typeof window !== 'undefined' ? window.nativeCaptureAPI?.windowsCapture ?? null : null } protected getBackendLabel(): string { @@ -544,6 +473,18 @@ class NativeWindowsCaptureBackend extends NativePolledCaptureBackend { } } +class NativeLinuxCaptureBackend extends NativePolledCaptureBackend { + readonly kind = 'native-linux' as const + + protected getNativeCaptureModule(): NativeSystemCaptureAPI | null { + return typeof window !== 'undefined' ? window.nativeCaptureAPI?.linuxCapture ?? null : null + } + + protected getBackendLabel(): string { + return 'Native Linux' + } +} + class NativeUnavailableCaptureBackend implements CaptureBackend { readonly kind: CaptureBackendKind private readonly reason: string | null @@ -558,7 +499,7 @@ class NativeUnavailableCaptureBackend implements CaptureBackend { } async stop(): Promise { - // No-op stub until native capture backends are implemented. + // No-op when a platform capture backend is unavailable. } async listSources(): Promise { @@ -582,31 +523,29 @@ class NativeUnavailableCaptureBackend implements CaptureBackend { } class AudioCapture { - private readonly electronRuntime = new ElectronCaptureRuntime() - private readonly electronSystemBackend: CaptureBackend - private readonly electronDeviceBackend: CaptureBackend + private readonly deviceInputRuntime = new DeviceInputCaptureRuntime() + private readonly deviceInputBackend: CaptureBackend private backendSupport: CaptureBackendSupport | null = null private backendSupportPromise: Promise | null = null - private nativeBackend: CaptureBackend | null = null + private nativeBackend: CaptureBackend + private nativeBackendUnsubscribe: (() => void) | null = null 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 private sessionId: number | null = null private inputGainDb = 0 private inputGainLinear = 1 private statusListeners = new Set() constructor() { - this.electronSystemBackend = new ElectronSystemCaptureBackend(this.electronRuntime) - this.electronDeviceBackend = new ElectronDeviceCaptureBackend(this.electronRuntime) + this.deviceInputBackend = new DeviceInputCaptureBackend(this.deviceInputRuntime) + this.deviceInputBackend.subscribe((chunk) => this.handleChunk(this.deviceInputBackend.kind, chunk)) - this.electronSystemBackend.subscribe((chunk) => this.handleChunk(this.electronSystemBackend.kind, chunk)) - this.electronDeviceBackend.subscribe((chunk) => this.handleChunk(this.electronDeviceBackend.kind, chunk)) + this.nativeBackend = this.createNativeBackend(createDefaultBackendSupport().nativeBackend) + this.bindNativeBackend(this.nativeBackend) audioRouter.subscribeToDemandChanges((demand) => { nativeVisualizerTransport.setDemand(demand) @@ -650,44 +589,27 @@ class AudioCapture { this.captureMode = 'device' } - const support = await this.ensureBackendSupport() - const requestedMode = this.captureMode - const requestedDeviceId = requestedMode === 'device' - ? this.selectedDeviceId ?? undefined - : this.selectedSystemSourceId ?? DEFAULT_SYSTEM_SOURCE_ID - const candidateBackends = this.resolveCandidateBackends(support, requestedMode) - + await this.ensureBackendSupport() await this.stopActiveCapture() - let lastError: Error | null = null - let nativeFallbackReason: string | null = null + const requestedBackend = this.captureMode === 'device' + ? this.deviceInputBackend + : this.nativeBackend + const requestedDeviceId = this.captureMode === 'device' + ? this.selectedDeviceId ?? undefined + : this.selectedSystemSourceId ?? DEFAULT_SYSTEM_SOURCE_ID - for (const backend of candidateBackends) { - try { - await backend.start({ deviceId: requestedDeviceId }) - this.activeBackend = backend - this.activeBackendReason = nativeFallbackReason - const backendStatus = backend.getStatus() - this.sessionId = audioRouter.beginSession( - backendStatus.sampleRate, - backendStatus.channelCount, - backend.kind, - ) - nativeVisualizerTransport.reset(audioRouter.getSessionState()) - this.emitStatus() - return - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown capture backend failure' - lastError = error instanceof Error ? error : new Error(message) - if (backend.kind.startsWith('native-')) { - nativeFallbackReason = message - } - } - } + await requestedBackend.start({ deviceId: requestedDeviceId }) + this.activeBackend = requestedBackend - this.activeBackendReason = nativeFallbackReason + const backendStatus = requestedBackend.getStatus() + this.sessionId = audioRouter.beginSession( + backendStatus.sampleRate, + backendStatus.channelCount, + backendStatus.kind, + ) + nativeVisualizerTransport.reset(audioRouter.getSessionState()) this.emitStatus() - throw lastError ?? new Error('No capture backend succeeded.') } stop(): void { @@ -698,11 +620,14 @@ class AudioCapture { async listSources(mode: CaptureMode = this.captureMode): Promise { await this.ensureBackendSupport() if (mode === 'device') { - return this.electronDeviceBackend.listSources() + return this.deviceInputBackend.listSources() + } + + const sources = await this.nativeBackend.listSources() + if (!sources.length) { + return [getDefaultSystemSourceDescriptor()] } - const activeSystemBackend = this.resolveCandidateBackends(this.backendSupport ?? DEFAULT_BACKEND_SUPPORT, 'system')[0] - const sources = await activeSystemBackend.listSources() const dedupedSources = sources.filter((source) => source.id !== DEFAULT_SYSTEM_SOURCE_ID) return [getDefaultSystemSourceDescriptor(), ...dedupedSources] } @@ -739,15 +664,6 @@ class AudioCapture { this.emitStatus() } - getBackendPolicy(): CaptureBackendPolicy { - return this.backendPolicy - } - - setBackendPolicy(policy: CaptureBackendPolicy): void { - this.backendPolicy = policy - this.emitStatus() - } - getSampleRate(): number { return this.activeBackend?.getStatus().sampleRate ?? 48000 } @@ -756,9 +672,7 @@ class AudioCapture { const backendStatus = this.activeBackend?.getStatus() return { captureMode: this.captureMode, - backendPolicy: this.backendPolicy, activeBackendKind: this.activeBackend?.kind ?? null, - activeBackendReason: this.activeBackendReason, backendSupport: this.backendSupport, sampleRate: backendStatus?.sampleRate ?? 48000, channelCount: backendStatus?.channelCount ?? 2, @@ -772,11 +686,16 @@ class AudioCapture { } if (!this.backendSupportPromise) { - this.backendSupportPromise = window.electronAPI.getCaptureBackendSupport() - .catch(() => DEFAULT_BACKEND_SUPPORT) + const fallbackSupport = createDefaultBackendSupport() + this.backendSupportPromise = ( + typeof window !== 'undefined' && typeof window.electronAPI !== 'undefined' + ? window.electronAPI.getCaptureBackendSupport() + : Promise.resolve(fallbackSupport) + ) + .catch(() => fallbackSupport) .then((support) => { this.backendSupport = support - this.nativeBackend = this.createNativeBackend(support.nativeBackend) + this.bindNativeBackend(this.createNativeBackend(support.nativeBackend)) this.emitStatus() return support }) @@ -785,51 +704,29 @@ class AudioCapture { return this.backendSupportPromise } - private resolveCandidateBackends( - support: CaptureBackendSupport, - mode: CaptureMode, - ): CaptureBackend[] { - if (mode === 'device') { - return [this.electronDeviceBackend] - } - - const nativeBackend = this.nativeBackend ?? new NativeUnavailableCaptureBackend(support.nativeBackend) - - switch (this.backendPolicy) { - case 'electron': - this.activeBackendReason = null - return [this.electronSystemBackend] - case 'native': - case 'auto': - if (support.nativeBackend.available) { - return [nativeBackend, this.electronSystemBackend] - } - this.activeBackendReason = support.nativeBackend.reason - return [this.electronSystemBackend] + private createNativeBackend(supportEntry: CaptureBackendSupportEntry): CaptureBackend { + switch (supportEntry.kind) { + case 'native-macos': + return supportEntry.available + ? new NativeMacOSCaptureBackend(supportEntry) + : new NativeUnavailableCaptureBackend(supportEntry) + case 'native-windows': + return supportEntry.available + ? new NativeWindowsCaptureBackend(supportEntry) + : new NativeUnavailableCaptureBackend(supportEntry) + case 'native-linux': + return supportEntry.available + ? new NativeLinuxCaptureBackend(supportEntry) + : new NativeUnavailableCaptureBackend(supportEntry) + default: + return new NativeUnavailableCaptureBackend(supportEntry) } } - private createNativeBackend(supportEntry: CaptureBackendSupportEntry): CaptureBackend { - let backend: CaptureBackend - - switch (supportEntry.kind) { - case 'native-macos': - backend = supportEntry.available - ? new NativeMacOSCaptureBackend(supportEntry) - : new NativeUnavailableCaptureBackend(supportEntry) - break - case 'native-windows': - backend = supportEntry.available - ? new NativeWindowsCaptureBackend(supportEntry) - : new NativeUnavailableCaptureBackend(supportEntry) - break - default: - backend = new NativeUnavailableCaptureBackend(supportEntry) - break - } - - backend.subscribe((chunk) => this.handleChunk(backend.kind, chunk)) - return backend + private bindNativeBackend(backend: CaptureBackend): void { + this.nativeBackendUnsubscribe?.() + this.nativeBackend = backend + this.nativeBackendUnsubscribe = backend.subscribe((chunk) => this.handleChunk(backend.kind, chunk)) } private async stopActiveCapture(): Promise { @@ -866,7 +763,7 @@ class AudioCapture { setInputGain(db: number): void { this.inputGainDb = db this.inputGainLinear = inputGainDbToLinear(this.inputGainDb) - this.electronRuntime.setInputGain(this.inputGainDb) + this.deviceInputRuntime.setInputGain(this.inputGainDb) } private emitStatus(): void { diff --git a/src/renderer/env.d.ts b/src/renderer/env.d.ts index 8da0b0e..477766d 100644 --- a/src/renderer/env.d.ts +++ b/src/renderer/env.d.ts @@ -48,7 +48,6 @@ declare global { repositionWindow: (position: 'top' | 'bottom') => void toggleAlwaysOnTop: () => void isAlwaysOnTop: () => Promise - getDesktopSources: () => Promise<{ id: string; name: string }[]> getCaptureBackendSupport: () => Promise getAstraConfig: () => Promise saveAstraConfig: (config: AstraIntegrationConfig) => Promise diff --git a/src/renderer/stores/audioStore.ts b/src/renderer/stores/audioStore.ts index f292c08..41e73a0 100644 --- a/src/renderer/stores/audioStore.ts +++ b/src/renderer/stores/audioStore.ts @@ -2,11 +2,11 @@ import { create } from 'zustand' import { audioCapture, type CaptureManagerStatus } from '../audio/AudioCapture' import type { CaptureBackendKind, - CaptureBackendPolicy, CaptureBackendSupport, CaptureMode, CaptureSourceDescriptor, } from '../../types/capture' +import { useUiStore } from './uiStore' interface AudioState { systemSources: CaptureSourceDescriptor[] @@ -14,9 +14,7 @@ interface AudioState { selectedSystemSourceId: string | null selectedDeviceId: string | null captureMode: CaptureMode - capturePolicy: CaptureBackendPolicy activeBackendKind: CaptureBackendKind | null - activeBackendReason: string | null backendSupport: CaptureBackendSupport | null isCapturing: boolean captureStatus: 'idle' | 'connecting' | 'capturing' | 'error' @@ -33,7 +31,6 @@ interface AudioState { selectSystemSource: (sourceId: string | null) => Promise selectDevice: (deviceId: string | null) => Promise setCaptureMode: (mode: CaptureMode) => void - setCapturePolicy: (policy: CaptureBackendPolicy) => Promise startCapture: () => Promise stopCapture: () => void } @@ -41,9 +38,7 @@ interface AudioState { function applyCaptureStatus(status: CaptureManagerStatus): Partial { return { captureMode: status.captureMode, - capturePolicy: status.backendPolicy, activeBackendKind: status.activeBackendKind, - activeBackendReason: status.activeBackendReason, backendSupport: status.backendSupport, sampleRate: status.sampleRate, channelCount: status.channelCount, @@ -51,6 +46,22 @@ function applyCaptureStatus(status: CaptureManagerStatus): Partial { } } +function buildSystemCaptureFallbackMessage(reason: string | null): string { + if (!reason) { + return 'System output capture is unavailable. Prism switched to Default Input.' + } + + return `System output capture is unavailable: ${reason} Prism switched to Default Input.` +} + +function showSystemCaptureFallbackBanner(message: string): void { + useUiStore.getState().showBanner({ + tone: 'info', + message, + actions: [], + }) +} + function describeInputDevice(deviceId: string, devices: MediaDeviceInfo[]): string { const matchingDevice = devices.find((device) => device.deviceId === deviceId) if (matchingDevice?.label) { @@ -70,9 +81,7 @@ export const useAudioStore = create((set, get) => ({ selectedSystemSourceId: audioCapture.getSelectedSystemSourceId(), selectedDeviceId: null, captureMode: 'system', - capturePolicy: 'auto', activeBackendKind: null, - activeBackendReason: null, backendSupport: null, isCapturing: false, captureStatus: 'idle', @@ -175,30 +184,40 @@ export const useAudioStore = create((set, get) => ({ set({ captureMode: mode }) }, - setCapturePolicy: async (policy: CaptureBackendPolicy) => { - audioCapture.setBackendPolicy(policy) - set({ capturePolicy: policy }) - await get().refreshBackendSupport() - - const { isCapturing, captureMode } = get() - if (isCapturing && captureMode === 'system') { - await get().startCapture() - } - }, - startCapture: async () => { set({ captureStatus: 'connecting', captureError: null }) try { - const { captureMode, capturePolicy } = get() + const { captureMode } = get() audioCapture.setCaptureMode(captureMode) - audioCapture.setBackendPolicy(capturePolicy) await get().refreshBackendSupport() await get().refreshDevices() - const { selectedDeviceId, selectedSystemSourceId } = get() + const { selectedDeviceId, selectedSystemSourceId, backendSupport } = get() + + const startDefaultInputFallback = async (reason: string | null): Promise => { + const message = buildSystemCaptureFallbackMessage(reason) + audioCapture.setSelectedDeviceId(null) + audioCapture.setCaptureMode('device') + set({ + selectedDeviceId: null, + captureMode: 'device', + captureNotice: message, + }) + showSystemCaptureFallbackBanner(message) + await audioCapture.startDevice(undefined) + } if (captureMode === 'system') { - await audioCapture.startSystemAudio(selectedSystemSourceId ?? undefined) + if (!backendSupport?.nativeBackend.available) { + await startDefaultInputFallback(backendSupport?.nativeBackend.reason ?? null) + } else { + try { + await audioCapture.startSystemAudio(selectedSystemSourceId ?? undefined) + } catch (error) { + const reason = error instanceof Error ? error.message : 'Native system capture failed.' + await startDefaultInputFallback(reason) + } + } } else { await audioCapture.startDevice(selectedDeviceId ?? undefined) } diff --git a/src/types/capture.ts b/src/types/capture.ts index 4cc847e..751c233 100644 --- a/src/types/capture.ts +++ b/src/types/capture.ts @@ -1,14 +1,25 @@ export type CaptureMode = 'system' | 'device' -export type CaptureBackendPolicy = 'auto' | 'native' | 'electron' - -export type CaptureBackendKind = - | 'electron-system' - | 'electron-device' +export type NativeSystemCaptureBackendKind = | 'native-macos' | 'native-windows' | 'native-linux' +export type CaptureBackendKind = + | 'device-input' + | NativeSystemCaptureBackendKind + +export function resolveNativeBackendKind(platform: string): NativeSystemCaptureBackendKind { + switch (platform) { + case 'darwin': + return 'native-macos' + case 'win32': + return 'native-windows' + default: + return 'native-linux' + } +} + export interface CaptureSourceDescriptor { id: string label: string @@ -25,8 +36,6 @@ export interface CaptureBackendSupportEntry { } export interface CaptureBackendSupport { - policyOptions: CaptureBackendPolicy[] nativeBackend: CaptureBackendSupportEntry - electronSystem: CaptureBackendSupportEntry - electronDevice: CaptureBackendSupportEntry + deviceInput: CaptureBackendSupportEntry } diff --git a/src/types/nativeCapture.ts b/src/types/nativeCapture.ts index 28e03e0..b071d96 100644 --- a/src/types/nativeCapture.ts +++ b/src/types/nativeCapture.ts @@ -56,7 +56,15 @@ export type NativeWindowsCapturedChunk = NativeCapturedChunk export type NativeWindowsCaptureDrainResult = NativeCaptureDrainResult export type NativeWindowsCaptureAPI = NativeSystemCaptureAPI +export type NativeLinuxCaptureSupport = NativeCaptureSupport +export type NativeLinuxCaptureSource = NativeCaptureSource +export type NativeLinuxCaptureStartResult = NativeCaptureStartResult +export type NativeLinuxCapturedChunk = NativeCapturedChunk +export type NativeLinuxCaptureDrainResult = NativeCaptureDrainResult +export type NativeLinuxCaptureAPI = NativeSystemCaptureAPI + export interface NativeCaptureAPI { macosCapture: NativeMacOSCaptureAPI windowsCapture: NativeWindowsCaptureAPI + linuxCapture: NativeLinuxCaptureAPI } diff --git a/test/audio-router.test.ts b/test/audio-router.test.ts index 99b78f3..c737ec6 100644 --- a/test/audio-router.test.ts +++ b/test/audio-router.test.ts @@ -8,7 +8,7 @@ function createChunk(value: number, length = 4): Float32Array { test('routes chunks only to demanded scopes and prunes queues when demand is removed', () => { const router = new AudioRouter() - const sessionId = router.beginSession(48000, 2, 'electron-system') + const sessionId = router.beginSession(48000, 2, 'native-macos') router.ingestChunk(createChunk(1), createChunk(1), { sessionId, @@ -52,7 +52,7 @@ test('routes chunks only to demanded scopes and prunes queues when demand is rem test('spectrum keeps stereo chunks for the side overlay path and still exposes mono downmixes', () => { const router = new AudioRouter() - const sessionId = router.beginSession(48000, 2, 'electron-system') + const sessionId = router.beginSession(48000, 2, 'native-macos') router.setVisualizerConsumerDemand('test-consumer', { spectrum: true }) router.ingestChunk(createChunk(2), createChunk(4), { @@ -83,7 +83,7 @@ test('spectrum keeps stereo chunks for the side overlay path and still exposes m test('waveform keeps stereo chunks for stereo mode while mono flushes still expose the left channel', () => { const router = new AudioRouter() - const sessionId = router.beginSession(48000, 2, 'electron-system') + const sessionId = router.beginSession(48000, 2, 'native-macos') router.setVisualizerConsumerDemand('test-consumer', { waveform: true }) router.ingestChunk(createChunk(2), createChunk(4), { @@ -114,7 +114,7 @@ test('waveform keeps stereo chunks for stereo mode while mono flushes still expo test('keeps the newest chunks when a fixed-capacity ring overflows', () => { const router = new AudioRouter() - const sessionId = router.beginSession(48000, 2, 'electron-system') + const sessionId = router.beginSession(48000, 2, 'native-macos') router.setVisualizerConsumerDemand('test-consumer', { oscilloscope: true }) for (let sequence = 1; sequence <= 25; sequence += 1) { @@ -138,7 +138,7 @@ test('keeps the newest chunks when a fixed-capacity ring overflows', () => { test('drops stale-session chunks before they reach scope queues', () => { const router = new AudioRouter() - const sessionId = router.beginSession(48000, 1, 'electron-device') + const sessionId = router.beginSession(48000, 1, 'device-input') router.setVisualizerConsumerDemand('test-consumer', { vumeter: true }) router.ingestChunk(createChunk(1), createChunk(1), { diff --git a/test/audio-store.test.ts b/test/audio-store.test.ts new file mode 100644 index 0000000..fccfb58 --- /dev/null +++ b/test/audio-store.test.ts @@ -0,0 +1,198 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { audioCapture } from '../src/renderer/audio/AudioCapture' +import { useAudioStore } from '../src/renderer/stores/audioStore' +import { useUiStore } from '../src/renderer/stores/uiStore' +import type { CaptureBackendSupport } from '../src/types/capture' + +const initialAudioState = { + ...useAudioStore.getState(), +} + +const initialUiState = { + ...useUiStore.getState(), +} + +function createBackendSupport(available: boolean, reason: string | null): CaptureBackendSupport { + return { + nativeBackend: { + kind: 'native-linux', + available, + reason, + }, + deviceInput: { + kind: 'device-input', + available: true, + reason: null, + }, + } +} + +function resetStores(): void { + useAudioStore.setState({ + ...initialAudioState, + systemSources: [], + devices: [], + selectedSystemSourceId: '__default_system_output__', + selectedDeviceId: null, + captureMode: 'system', + activeBackendKind: null, + backendSupport: null, + isCapturing: false, + captureStatus: 'idle', + captureError: null, + captureNotice: null, + sampleRate: 48000, + channelCount: 2, + inputGainDb: 0, + }) + + useUiStore.setState({ + ...initialUiState, + banner: null, + settingsOpen: false, + }) +} + +function installAudioCaptureHarness(options: { + support: CaptureBackendSupport + startSystemAudio?: (deviceId?: string) => Promise +}): { restore: () => void; calls: { startSystemAudio: number; startDevice: number } } { + const originalMethods = { + refreshBackendSupport: audioCapture.refreshBackendSupport, + listSources: audioCapture.listSources, + listDevices: audioCapture.listDevices, + startSystemAudio: audioCapture.startSystemAudio, + startDevice: audioCapture.startDevice, + getStatus: audioCapture.getStatus, + setCaptureMode: audioCapture.setCaptureMode, + setSelectedDeviceId: audioCapture.setSelectedDeviceId, + setSelectedSystemSourceId: audioCapture.setSelectedSystemSourceId, + } + + const calls = { + startSystemAudio: 0, + startDevice: 0, + } + + let captureMode: 'system' | 'device' = 'system' + let selectedDeviceId: string | null = null + let selectedSystemSourceId = '__default_system_output__' + let activeBackendKind: 'device-input' | 'native-linux' | null = null + let isCapturing = false + + audioCapture.refreshBackendSupport = async () => options.support + audioCapture.listSources = async () => [ + { id: '__default_system_output__', label: 'Default Output', kind: 'system', isDefault: true }, + ] + audioCapture.listDevices = async () => [] + audioCapture.startSystemAudio = async (deviceId?: string) => { + calls.startSystemAudio += 1 + if (options.startSystemAudio) { + await options.startSystemAudio(deviceId) + } else { + captureMode = 'system' + activeBackendKind = 'native-linux' + isCapturing = true + } + } + audioCapture.startDevice = async (deviceId?: string) => { + calls.startDevice += 1 + captureMode = 'device' + selectedDeviceId = deviceId ?? null + activeBackendKind = 'device-input' + isCapturing = true + } + audioCapture.getStatus = () => ({ + captureMode, + activeBackendKind, + backendSupport: options.support, + sampleRate: 48000, + channelCount: 2, + isCapturing, + }) + audioCapture.setCaptureMode = (mode) => { + captureMode = mode + } + audioCapture.setSelectedDeviceId = (id) => { + selectedDeviceId = id + } + audioCapture.setSelectedSystemSourceId = (id) => { + selectedSystemSourceId = id ?? '__default_system_output__' + } + + return { + restore: () => { + audioCapture.refreshBackendSupport = originalMethods.refreshBackendSupport + audioCapture.listSources = originalMethods.listSources + audioCapture.listDevices = originalMethods.listDevices + audioCapture.startSystemAudio = originalMethods.startSystemAudio + audioCapture.startDevice = originalMethods.startDevice + audioCapture.getStatus = originalMethods.getStatus + audioCapture.setCaptureMode = originalMethods.setCaptureMode + audioCapture.setSelectedDeviceId = originalMethods.setSelectedDeviceId + audioCapture.setSelectedSystemSourceId = originalMethods.setSelectedSystemSourceId + + void selectedDeviceId + void selectedSystemSourceId + }, + calls, + } +} + +test('audio store auto-switches to device input when native system capture is unavailable on startup', async () => { + resetStores() + const harness = installAudioCaptureHarness({ + support: createBackendSupport(false, 'PulseAudio is unavailable.'), + }) + + try { + await useAudioStore.getState().startCapture() + + const state = useAudioStore.getState() + assert.equal(harness.calls.startSystemAudio, 0) + assert.equal(harness.calls.startDevice, 1) + assert.equal(state.captureMode, 'device') + assert.equal(state.captureStatus, 'capturing') + assert.equal(state.captureError, null) + assert.equal(state.activeBackendKind, 'device-input') + assert.match(state.captureNotice ?? '', /switched to Default Input/i) + + const banner = useUiStore.getState().banner + assert.ok(banner) + assert.equal(banner?.tone, 'info') + assert.match(banner?.message ?? '', /PulseAudio is unavailable/i) + } finally { + harness.restore() + resetStores() + } +}) + +test('audio store falls back to device input when native system capture fails at start time', async () => { + resetStores() + const harness = installAudioCaptureHarness({ + support: createBackendSupport(true, null), + startSystemAudio: async () => { + throw new Error('PulseAudio monitor stream failed.') + }, + }) + + try { + await useAudioStore.getState().startCapture() + + const state = useAudioStore.getState() + assert.equal(harness.calls.startSystemAudio, 1) + assert.equal(harness.calls.startDevice, 1) + assert.equal(state.captureMode, 'device') + assert.equal(state.captureStatus, 'capturing') + assert.equal(state.captureError, null) + assert.match(state.captureNotice ?? '', /PulseAudio monitor stream failed/i) + + const banner = useUiStore.getState().banner + assert.ok(banner) + assert.match(banner?.message ?? '', /PulseAudio monitor stream failed/i) + } finally { + harness.restore() + resetStores() + } +}) diff --git a/test/capture-support.test.ts b/test/capture-support.test.ts new file mode 100644 index 0000000..a693034 --- /dev/null +++ b/test/capture-support.test.ts @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import test from 'node:test' +import { getCaptureBackendSupport, resolveNativeCaptureSupport } from '../src/preload/captureSupport' +import type { NativeCaptureSupport, NativeSystemCaptureAPI } from '../src/types/nativeCapture' + +function createNativeSystemCaptureAPI(support: NativeCaptureSupport): NativeSystemCaptureAPI { + return { + getSupport: () => support, + listOutputDevices: () => [], + start: () => ({ + sampleRate: 48000, + channelCount: 2, + deviceId: 'default', + deviceLabel: 'Default', + }), + stop: () => {}, + drain: () => ({ + chunks: [], + overwriteCount: 0, + queueDepth: 0, + }), + nowMilliseconds: () => 0, + } +} + +test('getCaptureBackendSupport resolves Linux native support from the addon surface', () => { + const support = getCaptureBackendSupport('linux', { + macosCapture: createNativeSystemCaptureAPI({ available: false, reason: 'macOS only' }), + windowsCapture: createNativeSystemCaptureAPI({ available: false, reason: 'Windows only' }), + linuxCapture: createNativeSystemCaptureAPI({ + available: false, + reason: 'PulseAudio connection failed.', + }), + }) + + assert.deepEqual(support, { + nativeBackend: { + kind: 'native-linux', + available: false, + reason: 'PulseAudio connection failed.', + }, + deviceInput: { + kind: 'device-input', + available: true, + reason: null, + }, + }) +}) + +test('resolveNativeCaptureSupport returns a module-unavailable reason when the addon is missing', () => { + assert.deepEqual(resolveNativeCaptureSupport('linux', null), { + kind: 'native-linux', + available: false, + reason: 'Native capture module is not available in this build.', + }) +}) + +test('preload no longer exposes desktop source capture APIs', async () => { + const preloadSource = await readFile(join(process.cwd(), 'src', 'preload', 'index.ts'), 'utf8') + + assert.doesNotMatch(preloadSource, /getDesktopSources/) + assert.doesNotMatch(preloadSource, /capture:get-backend-support/) + assert.doesNotMatch(preloadSource, /audio:get-desktop-sources/) +})