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