speechflow
Version:
Speech Processing Flow Graph
168 lines (154 loc) • 7.18 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 MQTT from "mqtt"
import UUID from "pure-uuid"
/* internal dependencies */
import SpeechFlowNode, { SpeechFlowChunk } from "./speechflow-node"
import * as util from "./speechflow-util"
/* SpeechFlow node for MQTT networking */
export default class SpeechFlowNodeXIOMQTT extends SpeechFlowNode {
/* declare official node name */
public static name = "xio-mqtt"
/* internal state */
private broker: MQTT.MqttClient | null = null
private clientId: string = (new UUID(1)).format()
private chunkQueue: util.SingleQueue<SpeechFlowChunk> | 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({
url: { type: "string", pos: 0, val: "", match: /^(?:|(?:ws|mqtt):\/\/(.+?):(\d+)(?:\/.*)?)$/ },
username: { type: "string", pos: 1, val: "", match: /^.+$/ },
password: { type: "string", pos: 2, val: "", match: /^.+$/ },
topicRead: { type: "string", pos: 3, val: "", match: /^.+$/ },
topicWrite: { type: "string", pos: 4, val: "", match: /^.+$/ },
mode: { type: "string", pos: 5, val: "w", match: /^(?:r|w|rw)$/ },
type: { type: "string", pos: 6, val: "text", match: /^(?:audio|text)$/ }
})
/* declare node input/output format */
if (this.params.mode === "rw") {
this.input = this.params.type
this.output = this.params.type
}
else if (this.params.mode === "r") {
this.input = "none"
this.output = this.params.type
}
else if (this.params.mode === "w") {
this.input = this.params.type
this.output = "none"
}
}
/* open node */
async open () {
/* logical parameter sanity check */
if (this.params.url === "")
throw new Error("required parameter \"url\" has to be given")
if ((this.params.mode === "w" || this.params.mode === "rw") && this.params.topicWrite === "")
throw new Error("writing to MQTT requires a topicWrite parameter")
if ((this.params.mode === "r" || this.params.mode === "rw") && this.params.topicRead === "")
throw new Error("reading from MQTT requires a topicRead parameter")
if (this.params.username !== "" && this.params.password === "")
throw new Error("username provided but password is missing")
if (this.params.username === "" && this.params.password !== "")
throw new Error("password provided but username is missing")
/* connect remotely to a MQTT broker */
this.broker = MQTT.connect(this.params.url, {
protocolId: "MQTT",
protocolVersion: 5,
username: this.params.username,
password: this.params.password,
clientId: this.clientId,
clean: true,
resubscribe: true,
keepalive: 60, /* 60s */
reconnectPeriod: 2 * 1000, /* 2s */
connectTimeout: 30 * 1000 /* 30s */
})
this.broker.on("error", (error: Error) => {
this.log("error", `error on MQTT ${this.params.url}: ${error.message}`)
})
this.broker.on("connect", (packet: MQTT.IConnackPacket) => {
this.log("info", `connection opened to MQTT ${this.params.url}`)
if (this.params.mode !== "w" && !packet.sessionPresent)
this.broker!.subscribe([ this.params.topicRead ], (err) => {
if (err)
this.log("warning", `failed to subscribe to MQTT topic "${this.params.topicRead}": ${err.message}`)
})
})
this.broker.on("reconnect", () => {
this.log("info", `connection re-opened to MQTT ${this.params.url}`)
})
this.broker.on("disconnect", (packet: MQTT.IDisconnectPacket) => {
this.log("info", `connection closed to MQTT ${this.params.url}`)
})
this.chunkQueue = new util.SingleQueue<SpeechFlowChunk>()
this.broker.on("message", (topic: string, payload: Buffer, packet: MQTT.IPublishPacket) => {
if (topic !== this.params.topicRead || this.params.mode === "w")
return
try {
const chunk = util.streamChunkDecode(payload)
this.chunkQueue!.write(chunk)
}
catch (_err: any) {
this.log("warning", `received invalid CBOR chunk from MQTT ${this.params.url}`)
}
})
const self = this
this.stream = new Stream.Duplex({
writableObjectMode: true,
readableObjectMode: true,
decodeStrings: false,
highWaterMark: 1,
write (chunk: SpeechFlowChunk, encoding, callback) {
if (self.params.mode === "r")
callback(new Error("write operation on read-only node"))
else if (chunk.type !== self.params.type)
callback(new Error(`written chunk is not of ${self.params.type} type`))
else if (!self.broker!.connected)
callback(new Error("still no MQTT connection available"))
else {
const data = Buffer.from(util.streamChunkEncode(chunk))
self.broker!.publish(self.params.topicWrite, data, { qos: 2, retain: false }, (err) => {
if (err)
callback(new Error(`failed to publish to MQTT topic "${self.params.topicWrite}": ${err}`))
else
callback()
})
}
},
read (size: number) {
if (self.params.mode === "w")
throw new Error("read operation on write-only node")
self.chunkQueue!.read().then((chunk) => {
this.push(chunk, "binary")
}).catch((err: Error) => {
self.log("warning", `read on chunk queue operation failed: ${err}`)
})
}
})
}
/* close node */
async close () {
/* clear chunk queue reference */
this.chunkQueue = null
/* close MQTT broker */
if (this.broker !== null) {
if (this.broker.connected)
this.broker.end()
this.broker = null
}
/* close stream */
if (this.stream !== null) {
this.stream.destroy()
this.stream = null
}
}
}