speechflow
Version:
Speech Processing Flow Graph
221 lines (193 loc) • 8.77 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 Stream from "node:stream"
/* external dependencies */
import { getLUFS, getRMS, AudioData } from "audio-inspect"
/* internal dependencies */
import SpeechFlowNode, { SpeechFlowChunk } from "./speechflow-node"
import * as util from "./speechflow-util"
/* SpeechFlow node for audio metering */
export default class SpeechFlowNodeA2AMeter extends SpeechFlowNode {
/* declare official node name */
public static name = "a2a-meter"
/* internal state */
private emitInterval: ReturnType<typeof setInterval> | null = null
private calcInterval: ReturnType<typeof setInterval> | null = null
private silenceTimer: ReturnType<typeof setTimeout> | null = null
private chunkBuffer = new Float32Array(0)
private destroyed = false
/* 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({
interval: { type: "number", pos: 0, val: 100 },
mode: { type: "string", pos: 1, val: "filter", match: /^(?:filter|sink)$/ },
dashboard: { type: "string", val: "" }
})
/* declare node input/output format */
this.input = "audio"
if (this.params.mode === "filter")
this.output = "audio"
else if (this.params.mode === "sink")
this.output = "none"
}
/* open node */
async open () {
/* sanity check situation */
if (this.config.audioBitDepth !== 16 || !this.config.audioLittleEndian)
throw new Error("meter node currently supports PCM-S16LE audio only")
/* clear destruction flag */
this.destroyed = false
/* internal state */
let lufsm = -60
let rms = -60
/* chunk processing state for LUFS-M */
const sampleWindowDuration = 0.4 /* LUFS-M requires 400ms */
const sampleWindowSize = Math.floor(this.config.audioSampleRate * sampleWindowDuration)
const sampleWindow = new Float32Array(sampleWindowSize)
sampleWindow.fill(0, 0, sampleWindowSize)
/* chunk processing state for RMS */
const chunkDuration = 0.050 /* meter update frequency is about 50ms */
const samplesPerChunk = Math.floor(this.config.audioSampleRate * chunkDuration)
this.chunkBuffer = new Float32Array(0)
/* setup chunking interval */
this.calcInterval = setInterval(() => {
/* short-circuit during destruction */
if (this.destroyed)
return
/* short-circuit if still not enough chunk data */
if (this.chunkBuffer.length < samplesPerChunk)
return
/* grab the accumulated chunk data */
const chunkData = this.chunkBuffer
this.chunkBuffer = new Float32Array(0)
/* update internal audio sample sliding window for LUFS-S */
if (chunkData.length > sampleWindow.length)
sampleWindow.set(chunkData.subarray(chunkData.length - sampleWindow.length), 0)
else {
sampleWindow.set(sampleWindow.subarray(chunkData.length), 0)
sampleWindow.set(chunkData, sampleWindow.length - chunkData.length)
}
/* calculate the LUFS-M metric */
const audioDataLUFS = {
sampleRate: this.config.audioSampleRate,
numberOfChannels: this.config.audioChannels,
channelData: [ sampleWindow ],
duration: sampleWindowDuration,
length: sampleWindow.length
} satisfies AudioData
const lufs = getLUFS(audioDataLUFS, {
channelMode: this.config.audioChannels === 1 ? "mono" : "stereo",
calculateShortTerm: false,
calculateMomentary: true,
calculateLoudnessRange: false,
calculateTruePeak: false
})
lufsm = lufs.momentary ? Math.max(-60, lufs.momentary[0]) : -60
/* calculate the RMS metric */
const totalSamples = chunkData.length / this.config.audioChannels
const duration = totalSamples / this.config.audioSampleRate
const audioDataRMS = {
sampleRate: this.config.audioSampleRate,
numberOfChannels: this.config.audioChannels,
channelData: [ chunkData ],
duration,
length: chunkData.length
} satisfies AudioData
rms = Math.max(-60, getRMS(audioDataRMS, {
asDB: true
}))
/* automatically clear measurement (in case no new measurements happen) */
if (this.silenceTimer !== null)
clearTimeout(this.silenceTimer)
this.silenceTimer = setTimeout(() => {
lufsm = -60
rms = -60
}, 500)
}, chunkDuration * 1000)
/* setup loudness emitting interval */
this.emitInterval = setInterval(() => {
if (this.destroyed)
return
this.log("debug", `LUFS-M: ${lufsm.toFixed(1)} dB, RMS: ${rms.toFixed(1)} dB`)
this.sendResponse([ "meter", "LUFS-M", lufsm ])
this.sendResponse([ "meter", "RMS", rms ])
if (this.params.dashboard !== "")
this.sendDashboard("audio", this.params.dashboard, "final", lufsm)
}, this.params.interval)
/* provide Duplex stream and internally attach to meter */
const self = this
this.stream = new Stream.Transform({
writableObjectMode: true,
readableObjectMode: true,
decodeStrings: false,
highWaterMark: 1,
/* transform audio chunk */
transform (chunk: SpeechFlowChunk, encoding, callback) {
if (self.destroyed) {
callback(new Error("stream already destroyed"))
return
}
if (!Buffer.isBuffer(chunk.payload))
callback(new Error("expected audio input as Buffer chunks"))
else if (chunk.payload.byteLength === 0)
callback()
else {
try {
/* convert audio samples from PCM/I16 to PCM/F32 */
const data = util.convertBufToF32(chunk.payload, self.config.audioLittleEndian)
/* append new data to buffer */
const combinedLength = self.chunkBuffer.length + data.length
const newBuffer = new Float32Array(combinedLength)
newBuffer.set(self.chunkBuffer, 0)
newBuffer.set(data, self.chunkBuffer.length)
self.chunkBuffer = newBuffer
/* pass-through original audio chunk */
if (self.params.mode === "filter")
this.push(chunk)
callback()
}
catch (error) {
callback(error instanceof Error ? error : new Error("meter processing failed"))
}
}
},
final (callback) {
if (self.destroyed || self.params.mode === "sink") {
callback()
return
}
this.push(null)
callback()
}
})
}
/* close node */
async close () {
/* indicate destruction immediately to stop any ongoing operations */
this.destroyed = true
/* stop intervals */
if (this.emitInterval !== null) {
clearInterval(this.emitInterval)
this.emitInterval = null
}
if (this.calcInterval !== null) {
clearInterval(this.calcInterval)
this.calcInterval = null
}
if (this.silenceTimer !== null) {
clearTimeout(this.silenceTimer)
this.silenceTimer = null
}
/* close stream */
if (this.stream !== null) {
this.stream.destroy()
this.stream = null
}
}
}