initial commit for linux support

This commit is contained in:
Boof2015
2026-04-05 23:11:56 -04:00
parent 4738045c9c
commit 0a9e032e85
19 changed files with 1608 additions and 377 deletions
+10 -4
View File
@@ -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"
]
}]
]
}
+906
View File
@@ -0,0 +1,906 @@
#include "linux_capture.h"
#if defined(__linux__)
#include <pulse/pulseaudio.h>
#include <algorithm>
#include <chrono>
#include <cstdint>
#include <cstring>
#include <deque>
#include <mutex>
#include <string>
#include <vector>
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<float> left;
std::vector<float> 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<double, std::milli>(now).count();
}
uint32_t readUint24LE(const uint8_t* data) {
return static_cast<uint32_t>(data[0]) |
(static_cast<uint32_t>(data[1]) << 8) |
(static_cast<uint32_t>(data[2]) << 16);
}
uint32_t readUint24BE(const uint8_t* data) {
return static_cast<uint32_t>(data[2]) |
(static_cast<uint32_t>(data[1]) << 8) |
(static_cast<uint32_t>(data[0]) << 16);
}
uint32_t readUint32LE(const uint8_t* data) {
return static_cast<uint32_t>(data[0]) |
(static_cast<uint32_t>(data[1]) << 8) |
(static_cast<uint32_t>(data[2]) << 16) |
(static_cast<uint32_t>(data[3]) << 24);
}
uint32_t readUint32BE(const uint8_t* data) {
return static_cast<uint32_t>(data[3]) |
(static_cast<uint32_t>(data[2]) << 8) |
(static_cast<uint32_t>(data[1]) << 16) |
(static_cast<uint32_t>(data[0]) << 24);
}
int32_t signExtend24(uint32_t value) {
if ((value & 0x00800000U) != 0) {
value |= 0xFF000000U;
}
return static_cast<int32_t>(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<int>(data[0]) - 128) / 128.0f;
case PA_SAMPLE_S16LE:
return static_cast<int16_t>(
static_cast<uint16_t>(data[0]) |
(static_cast<uint16_t>(data[1]) << 8)) /
32768.0f;
case PA_SAMPLE_S16BE:
return static_cast<int16_t>(
static_cast<uint16_t>(data[1]) |
(static_cast<uint16_t>(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<int32_t>(readUint32LE(data)) / 2147483648.0f;
case PA_SAMPLE_S32BE:
return static_cast<int32_t>(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<OutputDeviceInfo> devices;
};
void HandleServerInfo(pa_context*, const pa_server_info* info, void* userdata) {
auto* state = static_cast<SinkEnumerationState*>(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<SinkEnumerationState*>(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<OutputDeviceInfo>* 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<pa_threaded_mainloop*>(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<OutputDeviceInfo> 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<OutputDeviceInfo> 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<double>(device.sampleSpec.rate)));
entry.Set(
"channelCount",
Napi::Number::New(env, static_cast<double>(device.sampleSpec.channels)));
devicesArray.Set(static_cast<uint32_t>(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<Napi::String>().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<std::mutex> 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<double>(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<size_t>(1, info[0].As<Napi::Number>().Uint32Value())
: kDefaultDrainChunkLimit;
std::deque<CapturedChunk> drainedChunks;
size_t overwriteCount = 0;
size_t queueDepth = 0;
{
std::lock_guard<std::mutex> 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<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>(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<pa_threaded_mainloop*>(userdata);
if (mainloop != nullptr) {
pa_threaded_mainloop_signal(mainloop, 0);
}
}
static void HandleStreamRead(pa_stream*, size_t, void* userdata) {
auto* self = static_cast<LinuxNativeCaptureEngine*>(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<OutputDeviceInfo> 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_flags_t>(
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<std::mutex> lock(stateMutex_);
active_ = true;
activeDeviceId_ = selected->id;
activeDeviceLabel_ = selected->label;
sampleRate_ = static_cast<double>(sampleSpec_.rate);
channelCount_ = std::max<uint32_t>(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<std::mutex> lock(stateMutex_);
active_ = false;
activeDeviceId_.clear();
activeDeviceLabel_.clear();
sampleRate_ = 48000.0;
channelCount_ = 2;
sequence_ = 0;
sampleSpec_ = pa_sample_spec{};
}
{
std::lock_guard<std::mutex> 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<std::mutex> 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<const uint8_t*>(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<std::mutex> 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<CapturedChunk> 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
+5
View File
@@ -0,0 +1,5 @@
#pragma once
#include <napi.h>
void RegisterLinuxCapture(Napi::Env env, Napi::Object exports);
+54
View File
@@ -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);
}
+2
View File
@@ -1,5 +1,6 @@
#include <napi.h>
#include <cstring>
#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;
}
+2
View File
@@ -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",
+44
View File
@@ -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)
+44
View File
@@ -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)
+2 -53
View File
@@ -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()
})
+70
View File
@@ -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,
},
}
}
+4 -51
View File
@@ -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<AstraIntegrationConfig>,
saveAstraConfig: (config: AstraIntegrationConfig) => ipcRenderer.invoke('astra:save-config', config) as Promise<AstraIntegrationConfig>,
getAstraState: () => ipcRenderer.invoke('astra:get-state') as Promise<AstraIntegrationState>,
@@ -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
+129 -232
View File
@@ -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<void> {
await this.start({ mode: 'system' })
}
async startDevice(deviceId?: string): Promise<void> {
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<void> {
@@ -163,10 +171,6 @@ class ElectronCaptureRuntime {
}
}
async listSystemSources(): Promise<CaptureSourceDescriptor[]> {
return [getDefaultSystemSourceDescriptor()]
}
async listDeviceSources(): Promise<CaptureSourceDescriptor[]> {
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<void> {
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<void> {
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<MediaStream> {
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<MediaStream> {
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<void> {
await this.runtime.startSystem()
}
async stop(): Promise<void> {
await this.runtime.stop()
}
async listSources(): Promise<CaptureSourceDescriptor[]> {
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<void> {
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<CaptureSourceDescriptor[]> {
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<void> {
// No-op stub until native capture backends are implemented.
// No-op when a platform capture backend is unavailable.
}
async listSources(): Promise<CaptureSourceDescriptor[]> {
@@ -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<CaptureBackendSupport> | 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<StatusListener>()
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<CaptureSourceDescriptor[]> {
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<void> {
@@ -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 {
-1
View File
@@ -48,7 +48,6 @@ declare global {
repositionWindow: (position: 'top' | 'bottom') => void
toggleAlwaysOnTop: () => void
isAlwaysOnTop: () => Promise<boolean>
getDesktopSources: () => Promise<{ id: string; name: string }[]>
getCaptureBackendSupport: () => Promise<CaptureBackendSupport>
getAstraConfig: () => Promise<AstraIntegrationConfig>
saveAstraConfig: (config: AstraIntegrationConfig) => Promise<AstraIntegrationConfig>
+42 -23
View File
@@ -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<void>
selectDevice: (deviceId: string | null) => Promise<void>
setCaptureMode: (mode: CaptureMode) => void
setCapturePolicy: (policy: CaptureBackendPolicy) => Promise<void>
startCapture: () => Promise<void>
stopCapture: () => void
}
@@ -41,9 +38,7 @@ interface AudioState {
function applyCaptureStatus(status: CaptureManagerStatus): Partial<AudioState> {
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<AudioState> {
}
}
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<AudioState>((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<AudioState>((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<void> => {
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)
}
+17 -8
View File
@@ -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
}
+8
View File
@@ -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
}
+5 -5
View File
@@ -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), {
+198
View File
@@ -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<void>
}): { 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()
}
})
+66
View File
@@ -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/)
})