speechflow
Version:
Speech Processing Flow Graph
304 lines (268 loc) • 11.7 kB
text/typescript
/*
** SpeechFlow - Speech Processing Flow Graph
** Copyright (c) 2024-2025 Dr. Ralf S. Engelschall <rse@engelschall.com>
** Licensed under GPL 3.0 <https://spdx.org/licenses/GPL-3.0-only>
*/
/* standard dependencies */
import path from "node:path"
import Stream from "node:stream"
import { EventEmitter } from "node:events"
/* external dependencies */
import { GainNode, AudioWorkletNode } from "node-web-audio-api"
/* internal dependencies */
import SpeechFlowNode, { SpeechFlowChunk } from "./speechflow-node"
import * as util from "./speechflow-util"
/* internal types */
interface AudioCompressorConfig {
thresholdDb?: number
ratio?: number
attackMs?: number
releaseMs?: number
kneeDb?: number
makeupDb?: number
}
/* audio compressor class */
class AudioCompressor extends util.WebAudio {
/* internal state */
private type: "standalone" | "sidechain"
private mode: "compress" | "measure" | "adjust"
private config: Required<AudioCompressorConfig>
private compressorNode: AudioWorkletNode | null = null
private gainNode: GainNode | null = null
/* construct object */
constructor(
sampleRate: number,
channels: number,
type: "standalone" | "sidechain" = "standalone",
mode: "compress" | "measure" | "adjust" = "compress",
config: AudioCompressorConfig = {}
) {
super(sampleRate, channels)
/* store type and mode */
this.type = type
this.mode = mode
/* store configuration */
this.config = {
thresholdDb: config.thresholdDb ?? -23,
ratio: config.ratio ?? 4.0,
attackMs: config.attackMs ?? 10,
releaseMs: config.releaseMs ?? 50,
kneeDb: config.kneeDb ?? 6.0,
makeupDb: config.makeupDb ?? 0
}
}
/* setup object */
public async setup (): Promise<void> {
await super.setup()
/* add audio worklet module */
const url = path.resolve(__dirname, "speechflow-node-a2a-compressor-wt.js")
await this.audioContext.audioWorklet.addModule(url)
/* determine operation modes */
const needsCompressor = (this.type === "standalone" && this.mode === "compress") ||
(this.type === "sidechain" && this.mode === "measure")
const needsGain = (this.type === "standalone" && this.mode === "compress") ||
(this.type === "sidechain" && this.mode === "adjust")
/* create compressor worklet node */
if (needsCompressor) {
this.compressorNode = new AudioWorkletNode(this.audioContext, "compressor", {
numberOfInputs: 1,
numberOfOutputs: 1,
processorOptions: {
sampleRate: this.audioContext.sampleRate
}
})
}
/* create gain node */
if (needsGain)
this.gainNode = this.audioContext.createGain()
/* connect nodes (according to type and mode) */
if (this.type === "standalone" && this.mode === "compress") {
this.sourceNode!.connect(this.compressorNode!)
this.compressorNode!.connect(this.gainNode!)
this.gainNode!.connect(this.captureNode!)
}
else if (this.type === "sidechain" && this.mode === "measure") {
this.sourceNode!.connect(this.compressorNode!)
}
else if (this.type === "sidechain" && this.mode === "adjust") {
this.sourceNode!.connect(this.gainNode!)
this.gainNode!.connect(this.captureNode!)
}
/* configure compressor worklet node */
const currentTime = this.audioContext.currentTime
if (needsCompressor) {
const node = this.compressorNode!
const params = node.parameters as Map<string, AudioParam>
params.get("threshold")!.setValueAtTime(this.config.thresholdDb, currentTime)
params.get("ratio")!.setValueAtTime(this.config.ratio, currentTime)
params.get("attack")!.setValueAtTime(this.config.attackMs / 1000, currentTime)
params.get("release")!.setValueAtTime(this.config.releaseMs / 1000, currentTime)
params.get("knee")!.setValueAtTime(this.config.kneeDb, currentTime)
params.get("makeup")!.setValueAtTime(this.config.makeupDb, currentTime)
}
/* configure gain node */
if (needsGain) {
const gain = Math.pow(10, this.config.makeupDb / 20)
this.gainNode!.gain.setValueAtTime(gain, currentTime)
}
}
/* get the current gain reduction */
public getGainReduction (): number {
const processor = (this.compressorNode as any)?.port?.processor
return processor?.reduction ?? 0
}
/* set the current gain */
public setGain (decibel: number): void {
const gain = Math.pow(10, decibel / 20)
this.gainNode?.gain.setTargetAtTime(gain, this.audioContext.currentTime, 0.002)
}
/* destroy the compressor */
public async destroy (): Promise<void> {
await super.destroy()
/* destroy nodes */
if (this.compressorNode !== null) {
this.compressorNode.disconnect()
this.compressorNode = null
}
if (this.gainNode !== null) {
this.gainNode.disconnect()
this.gainNode = null
}
}
}
/* SpeechFlow node for compression in audio-to-audio passing */
export default class SpeechFlowNodeA2ACompressor extends SpeechFlowNode {
/* declare official node name */
public static name = "a2a-compressor"
/* internal state */
private destroyed = false
private compressor: AudioCompressor | null = null
private bus: EventEmitter | null = null
private intervalId: ReturnType<typeof setInterval> | null = null
/* construct node */
constructor (id: string, cfg: { [ id: string ]: any }, opts: { [ id: string ]: any }, args: any[]) {
super(id, cfg, opts, args)
/* declare node configuration parameters */
this.configure({
type: { type: "string", val: "standalone", match: /^(?:standalone|sidechain)$/ },
mode: { type: "string", val: "compress", match: /^(?:compress|measure|adjust)$/ },
bus: { type: "string", val: "compressor", match: /^.+$/ },
thresholdDb: { type: "number", val: -23, match: (n: number) => n <= 0 && n >= -100 },
ratio: { type: "number", val: 4.0, match: (n: number) => n >= 1 && n <= 20 },
attackMs: { type: "number", val: 10, match: (n: number) => n >= 0 && n <= 1000 },
releaseMs: { type: "number", val: 50, match: (n: number) => n >= 0 && n <= 1000 },
kneeDb: { type: "number", val: 6.0, match: (n: number) => n >= 0 && n <= 40 },
makeupDb: { type: "number", val: 0, match: (n: number) => n >= -24 && n <= 24 }
})
/* sanity check mode and role */
if (this.params.type === "standalone" && this.params.mode !== "compress")
throw new Error("type \"standalone\" implies mode \"compress\"")
if (this.params.type === "sidechain" && this.params.mode === "compress")
throw new Error("type \"sidechain\" implies mode \"measure\" or \"adjust\"")
/* declare node input/output format */
this.input = "audio"
this.output = "audio"
}
/* open node */
async open () {
/* clear destruction flag */
this.destroyed = false
/* setup compressor */
this.compressor = new AudioCompressor(
this.config.audioSampleRate,
this.config.audioChannels,
this.params.type,
this.params.mode, {
thresholdDb: this.params.thresholdDb,
ratio: this.params.ratio,
attackMs: this.params.attackMs,
releaseMs: this.params.releaseMs,
kneeDb: this.params.kneeDb,
makeupDb: this.params.makeupDb
}
)
await this.compressor.setup()
/* optionally establish sidechain processing */
if (this.params.type === "sidechain") {
this.bus = this.accessBus(this.params.bus)
if (this.params.mode === "measure") {
this.intervalId = setInterval(() => {
const decibel = this.compressor?.getGainReduction()
this.bus?.emit("sidechain-decibel", decibel)
}, 10)
}
else if (this.params.mode === "adjust") {
this.bus.on("sidechain-decibel", (decibel: number) => {
this.compressor?.setGain(decibel)
})
}
}
/* establish a transform stream */
const self = this
this.stream = new Stream.Transform({
readableObjectMode: true,
writableObjectMode: true,
decodeStrings: false,
transform (chunk: SpeechFlowChunk & { payload: Buffer }, encoding, callback) {
if (self.destroyed) {
callback(new Error("stream already destroyed"))
return
}
if (!Buffer.isBuffer(chunk.payload))
callback(new Error("invalid chunk payload type"))
else {
/* compress chunk */
const payload = util.convertBufToI16(chunk.payload)
self.compressor?.process(payload).then((result) => {
if (self.destroyed)
throw new Error("stream already destroyed")
if ((self.params.type === "standalone" && self.params.mode === "compress") ||
(self.params.type === "sidechain" && self.params.mode === "adjust") ) {
/* take over compressed data */
const payload = util.convertI16ToBuf(result)
chunk.payload = payload
}
this.push(chunk)
callback()
}).catch((error: unknown) => {
if (!self.destroyed)
callback(util.ensureError(error, "compression failed"))
})
}
},
final (callback) {
if (self.destroyed) {
callback()
return
}
this.push(null)
callback()
}
})
}
/* close node */
async close () {
/* indicate destruction */
this.destroyed = true
/* clear interval */
if (this.intervalId !== null) {
clearInterval(this.intervalId)
this.intervalId = null
}
/* destroy bus */
if (this.bus !== null) {
this.bus.removeAllListeners()
this.bus = null
}
/* destroy compressor */
if (this.compressor !== null) {
await this.compressor.destroy()
this.compressor = null
}
/* close stream */
if (this.stream !== null) {
this.stream.destroy()
this.stream = null
}
}
}