pw-client
Version:
Node.js wrapper for developing PipeWire clients
193 lines (192 loc) • 7.76 kB
JavaScript
import EventEmitter, { once } from "node:events";
import { AudioFormat } from "./audio-format.mjs";
import { AudioQuality, getFormatPreferences, getRatePreferences, } from "./audio-quality.mjs";
import * as Props from "./props.mjs";
import { streamStateToName, } from "./stream.mjs";
import { adaptSamples } from "./format-negotiation.mjs";
import { BufferStrategy, getBufferConfigForQuality, } from "./buffer-config.mjs";
export class AudioOutputStreamImpl extends EventEmitter {
static async create(session, opts) {
const stream = new AudioOutputStreamImpl();
await stream.#init(session, opts);
return stream;
}
#nativeStream;
#connectionConfig;
#isConnected = false;
#autoConnect = false;
#negotiatedFormat;
#negotiatedChannels = 2;
#negotiatedRate = 48_000;
constructor() {
super();
}
async #init(session, opts = {}) {
const { name = "PipeWireStream", rate = 48_000, channels = 2, role, quality = AudioQuality.Standard, preferredFormats, preferredRates, autoConnect = false, buffering, enableMonitoring: _enableMonitoring = false, } = opts;
this.#autoConnect = autoConnect;
this.#connectionConfig = { quality, preferredFormats, preferredRates };
this.#nativeStream = await this.#createNativeStream(session, {
name,
rate,
channels,
buffering: this.#buildBufferingConfig(buffering, quality),
props: this.#buildMediaProps(role),
});
}
#buildMediaProps(role) {
const props = {
[Props.Media.Type]: "Audio",
[Props.Media.Category]: "Playback",
};
if (role) {
props[Props.Media.Role] = role;
}
return props;
}
#buildBufferingConfig(bufferConfig, quality = AudioQuality.Standard) {
const effectiveBufferConfig = bufferConfig ?? getBufferConfigForQuality(quality);
switch (effectiveBufferConfig.strategy) {
case BufferStrategy.MinimalLatency:
return { requestedQuanta: 1 };
case BufferStrategy.LowLatency:
return { requestedQuanta: 2 };
case BufferStrategy.Balanced:
return { requestedQuanta: 4 };
case BufferStrategy.Smooth:
return { requestedQuanta: 8 };
case BufferStrategy.QuantumMultiplier:
return { requestedQuanta: effectiveBufferConfig.multiplier };
case BufferStrategy.MaxSize:
return { requestedBytes: effectiveBufferConfig.bytes };
case BufferStrategy.MaxLatency:
return { requestedMs: effectiveBufferConfig.milliseconds };
}
}
async #createNativeStream(session, config) {
return await session.createAudioOutputStream({
name: config.name,
format: AudioFormat.Float64.enumValue,
bytesPerSample: AudioFormat.Float64.byteSize,
rate: config.rate,
channels: config.channels,
props: config.props,
buffering: config.buffering,
onStateChange: (state, error) => {
const streamState = streamStateToName[state];
if (streamState) {
this.emit("stateChange", streamState);
}
if (error) {
this.emit("error", new Error(error));
}
},
onLatencyChange: (latency) => this.emit("latencyChange", latency),
onPropsChange: (props) => this.emit("propsChange", props),
onUnknownParamChange: (param) => this.emit("unknownParamChange", param),
onFormatChange: (format) => this.#handleFormatChange(format),
});
}
#handleFormatChange(format) {
const newFormat = AudioFormat.fromEnum(format.format);
if (!newFormat) {
throw new Error(`Unknown format: ${format.format}`);
}
// Update negotiated format info
this.#negotiatedFormat = newFormat;
this.#negotiatedChannels = format.channels;
this.#negotiatedRate = format.rate;
// Emit format change event with AudioFormat object
this.emit("formatChange", {
format: newFormat,
channels: format.channels,
rate: format.rate,
});
}
async connect() {
if (this.#isConnected) {
return; // Already connected
}
const preferredFormats = this.#connectionConfig.preferredFormats ??
getFormatPreferences(this.#connectionConfig.quality);
const preferredRates = this.#connectionConfig.preferredRates ??
getRatePreferences(this.#connectionConfig.quality);
// If format already negotiated, we can reuse it
const formatNegotiation = !this.#negotiatedFormat && once(this, "formatChange");
await this.#nativeStream.connect({
preferredFormats: preferredFormats.map((f) => f.enumValue),
preferredRates,
});
await formatNegotiation;
this.#isConnected = true;
}
async disconnect() {
if (!this.#isConnected) {
return; // Already disconnected
}
await this.#nativeStream.disconnect();
this.#isConnected = false;
}
get isConnected() {
return this.#isConnected;
}
async write(samples) {
if (!this.#isConnected && this.#autoConnect) {
await this.connect();
}
if (!this.#isConnected) {
throw new Error("Stream must be connected before writing audio data. Call await stream.connect() first.");
}
if (this.#negotiatedFormat !== AudioFormat.Float64) {
samples = adaptSamples(samples, this.#negotiatedFormat);
}
const framesPerQuantum = this.#nativeStream.framesPerQuantum;
const samplesPerQuantum = framesPerQuantum * this.#negotiatedChannels;
let quantumBuffer = this.#negotiatedFormat.BufferClass(samplesPerQuantum);
let quantumOffset = 0;
let availableFrames = this.#nativeStream.writableFrames;
for (const sample of samples) {
quantumBuffer.set(quantumOffset++, sample);
if (quantumOffset >= samplesPerQuantum) {
if (availableFrames < framesPerQuantum) {
availableFrames = await this.#nativeStream.waitForBuffer();
}
this.#nativeStream.write(quantumBuffer.buffer);
availableFrames -= framesPerQuantum;
// Reset for next quantum
quantumBuffer = this.#negotiatedFormat.BufferClass(samplesPerQuantum);
quantumOffset = 0;
}
}
// Send any remaining partial quantum
if (quantumOffset > 0) {
const partialFrames = Math.ceil(quantumOffset / this.#negotiatedChannels);
// If no buffer space available for the partial quantum, wait for it
if (availableFrames < partialFrames) {
await this.#nativeStream.waitForBuffer();
}
this.#nativeStream.write(quantumBuffer.subarray(0, quantumOffset).buffer);
}
}
isFinished() {
return this.#nativeStream.isFinished();
}
get format() {
return this.#negotiatedFormat;
}
get channels() {
return this.#negotiatedChannels;
}
get rate() {
return this.#negotiatedRate;
}
get bufferSize() {
return this.#nativeStream.bufferSize;
}
async dispose() {
await this.#nativeStream.destroy();
this.#isConnected = false;
}
[Symbol.asyncDispose]() {
return this.dispose();
}
}