speechflow
Version:
Speech Processing Flow Graph
251 lines (236 loc) • 11 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 ws from "ws"
import ReconnWebSocket, { ErrorEvent } from "@opensumi/reconnecting-websocket"
/* internal dependencies */
import SpeechFlowNode, { SpeechFlowChunk } from "./speechflow-node"
import * as util from "./speechflow-util"
/* SpeechFlow node for Websocket networking */
export default class SpeechFlowNodeXIOWebSocket extends SpeechFlowNode {
/* declare official node name */
public static name = "xio-websocket"
/* internal state */
private server: ws.WebSocketServer | null = null
private client: ReconnWebSocket | 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({
listen: { type: "string", val: "", match: /^(?:|ws:\/\/(.+?):(\d+))$/ },
connect: { type: "string", val: "", match: /^(?:|ws:\/\/(.+?):(\d+)(?:\/.*)?)$/ },
mode: { type: "string", val: "r", match: /^(?:r|w|rw)$/ },
type: { type: "string", 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 () {
/* sanity check usage */
if (this.params.listen !== "" && this.params.connect !== "")
throw new Error("Websocket node cannot listen and connect at the same time")
else if (this.params.listen === "" && this.params.connect === "")
throw new Error("Websocket node requires either listen or connect mode")
if (this.params.listen !== "") {
/* listen locally on a Websocket port */
const url = new URL(this.params.listen)
const websockets = new Set<ws.WebSocket>()
const chunkQueue = new util.SingleQueue<SpeechFlowChunk>()
this.server = new ws.WebSocketServer({
host: url.hostname,
port: Number.parseInt(url.port, 10),
path: url.pathname
})
this.server.on("listening", () => {
this.log("info", `listening on URL ${this.params.listen}`)
})
this.server.on("connection", (ws, request) => {
const peer = `${request.socket.remoteAddress}:${request.socket.remotePort}`
this.log("info", `connection opened on URL ${this.params.listen} by peer ${peer}`)
websockets.add(ws)
ws.on("close", () => {
this.log("info", `connection closed on URL ${this.params.listen} by peer ${peer}`)
websockets.delete(ws)
})
ws.on("error", (error) => {
this.log("error", `error of connection on URL ${this.params.listen} for peer ${peer}: ${error.message}`)
})
ws.on("message", (data, isBinary) => {
if (this.params.mode === "w") {
this.log("warning", `connection on URL ${this.params.listen} by peer ${peer}: ` +
"received remote data on write-only node")
return
}
if (!isBinary) {
this.log("warning", `connection on URL ${this.params.listen} by peer ${peer}: ` +
"received non-binary message")
return
}
let buffer: Buffer
if (Buffer.isBuffer(data))
buffer = data
else if (data instanceof ArrayBuffer)
buffer = Buffer.from(data)
else
buffer = Buffer.concat(data)
const chunk = util.streamChunkDecode(buffer)
chunkQueue.write(chunk)
})
})
this.server.on("error", (error) => {
this.log("error", `error of some connection on URL ${this.params.listen}: ${error.message}`)
})
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 (websockets.size === 0)
callback(new Error("still no Websocket connections available"))
else {
const data = util.streamChunkEncode(chunk)
const results: Promise<void>[] = []
for (const websocket of websockets.values()) {
results.push(new Promise<void>((resolve, reject) => {
websocket.send(data, (error) => {
if (error)
reject(error)
else
resolve()
})
}))
}
Promise.all(results).then(() => {
callback()
}).catch((error: Error) => {
callback(error)
})
}
},
read (size: number) {
if (self.params.mode === "w")
throw new Error("read operation on write-only node")
chunkQueue.read().then((chunk) => {
this.push(chunk, "binary")
}).catch((err: Error) => {
self.log("warning", `read on chunk queue operation failed: ${err}`)
})
}
})
}
else if (this.params.connect !== "") {
/* connect remotely to a Websocket port */
this.client = new ReconnWebSocket(this.params.connect, [], {
WebSocket: ws,
WebSocketOptions: {},
reconnectionDelayGrowFactor: 1.3,
maxReconnectionDelay: 4000,
minReconnectionDelay: 1000,
connectionTimeout: 4000,
minUptime: 5000
})
this.client.addEventListener("open", (ev) => {
this.log("info", `connection opened to URL ${this.params.connect}`)
})
this.client.addEventListener("close", (ev) => {
this.log("info", `connection closed to URL ${this.params.connect}`)
})
this.client.addEventListener("error", (ev: ErrorEvent) => {
this.log("error", `error of connection on URL ${this.params.connect}: ${ev.error.message}`)
})
const chunkQueue = new util.SingleQueue<SpeechFlowChunk>()
this.client.addEventListener("message", (ev: MessageEvent) => {
if (this.params.mode === "w") {
this.log("warning", `connection to URL ${this.params.connect}: ` +
"received remote data on write-only node")
return
}
if (!(ev.data instanceof ArrayBuffer)) {
this.log("warning", `connection to URL ${this.params.connect}: ` +
"received non-binary message")
return
}
const buffer = Buffer.from(ev.data)
const chunk = util.streamChunkDecode(buffer)
chunkQueue.write(chunk)
})
this.client.binaryType = "arraybuffer"
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.client!.OPEN)
callback(new Error("still no Websocket connection available"))
else {
const data = util.streamChunkEncode(chunk)
self.client!.send(data)
callback()
}
},
read (size: number) {
if (self.params.mode === "w")
throw new Error("read operation on write-only node")
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 () {
/* close Websocket server */
if (this.server !== null) {
await new Promise<void>((resolve, reject) => {
this.server!.close((error) => {
if (error) reject(error)
else resolve()
})
})
this.server = null
}
/* close Websocket client */
if (this.client !== null) {
this.client.close()
this.client = null
}
/* close stream */
if (this.stream !== null) {
this.stream.destroy()
this.stream = null
}
}
}