UNPKG

pw-client

Version:

Node.js wrapper for developing PipeWire clients

193 lines (192 loc) 7.76 kB
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(); } }