speechflow 0.9.5 → 0.9.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/README.md +220 -53
- package/dst/speechflow-node-a2a-ffmpeg.d.ts +13 -0
- package/dst/speechflow-node-a2a-ffmpeg.js +152 -0
- package/dst/speechflow-node-a2a-wav.d.ts +11 -0
- package/dst/speechflow-node-a2a-wav.js +170 -0
- package/dst/speechflow-node-a2t-deepgram.d.ts +12 -0
- package/dst/speechflow-node-a2t-deepgram.js +220 -0
- package/dst/speechflow-node-deepgram.d.ts +3 -1
- package/dst/speechflow-node-deepgram.js +86 -22
- package/dst/speechflow-node-deepl.d.ts +3 -1
- package/dst/speechflow-node-deepl.js +25 -20
- package/dst/speechflow-node-device.d.ts +3 -1
- package/dst/speechflow-node-device.js +53 -2
- package/dst/speechflow-node-elevenlabs.d.ts +3 -1
- package/dst/speechflow-node-elevenlabs.js +37 -42
- package/dst/speechflow-node-ffmpeg.d.ts +3 -1
- package/dst/speechflow-node-ffmpeg.js +42 -4
- package/dst/speechflow-node-file.d.ts +3 -1
- package/dst/speechflow-node-file.js +84 -13
- package/dst/speechflow-node-format.d.ts +11 -0
- package/dst/speechflow-node-format.js +80 -0
- package/dst/speechflow-node-gemma.d.ts +3 -1
- package/dst/speechflow-node-gemma.js +84 -23
- package/dst/speechflow-node-mqtt.d.ts +13 -0
- package/dst/speechflow-node-mqtt.js +181 -0
- package/dst/speechflow-node-opus.d.ts +12 -0
- package/dst/speechflow-node-opus.js +135 -0
- package/dst/speechflow-node-subtitle.d.ts +12 -0
- package/dst/speechflow-node-subtitle.js +96 -0
- package/dst/speechflow-node-t2a-elevenlabs.d.ts +13 -0
- package/dst/speechflow-node-t2a-elevenlabs.js +182 -0
- package/dst/speechflow-node-t2t-deepl.d.ts +12 -0
- package/dst/speechflow-node-t2t-deepl.js +133 -0
- package/dst/speechflow-node-t2t-format.d.ts +11 -0
- package/dst/speechflow-node-t2t-format.js +80 -0
- package/dst/speechflow-node-t2t-gemma.d.ts +13 -0
- package/dst/speechflow-node-t2t-gemma.js +213 -0
- package/dst/speechflow-node-t2t-opus.d.ts +12 -0
- package/dst/speechflow-node-t2t-opus.js +135 -0
- package/dst/speechflow-node-t2t-subtitle.d.ts +12 -0
- package/dst/speechflow-node-t2t-subtitle.js +96 -0
- package/dst/speechflow-node-trace.d.ts +11 -0
- package/dst/speechflow-node-trace.js +88 -0
- package/dst/speechflow-node-wav.d.ts +11 -0
- package/dst/speechflow-node-wav.js +170 -0
- package/dst/speechflow-node-websocket.d.ts +3 -1
- package/dst/speechflow-node-websocket.js +149 -49
- package/dst/speechflow-node-whisper-common.d.ts +34 -0
- package/dst/speechflow-node-whisper-common.js +7 -0
- package/dst/speechflow-node-whisper-ggml.d.ts +1 -0
- package/dst/speechflow-node-whisper-ggml.js +97 -0
- package/dst/speechflow-node-whisper-onnx.d.ts +1 -0
- package/dst/speechflow-node-whisper-onnx.js +131 -0
- package/dst/speechflow-node-whisper-worker-ggml.d.ts +1 -0
- package/dst/speechflow-node-whisper-worker-ggml.js +97 -0
- package/dst/speechflow-node-whisper-worker-onnx.d.ts +1 -0
- package/dst/speechflow-node-whisper-worker-onnx.js +131 -0
- package/dst/speechflow-node-whisper-worker.d.ts +1 -0
- package/dst/speechflow-node-whisper-worker.js +116 -0
- package/dst/speechflow-node-whisper-worker2.d.ts +1 -0
- package/dst/speechflow-node-whisper-worker2.js +82 -0
- package/dst/speechflow-node-whisper.d.ts +19 -0
- package/dst/speechflow-node-whisper.js +604 -0
- package/dst/speechflow-node-x2x-trace.d.ts +11 -0
- package/dst/speechflow-node-x2x-trace.js +88 -0
- package/dst/speechflow-node-xio-device.d.ts +13 -0
- package/dst/speechflow-node-xio-device.js +205 -0
- package/dst/speechflow-node-xio-file.d.ts +11 -0
- package/dst/speechflow-node-xio-file.js +176 -0
- package/dst/speechflow-node-xio-mqtt.d.ts +13 -0
- package/dst/speechflow-node-xio-mqtt.js +181 -0
- package/dst/speechflow-node-xio-websocket.d.ts +13 -0
- package/dst/speechflow-node-xio-websocket.js +275 -0
- package/dst/speechflow-node.d.ts +24 -6
- package/dst/speechflow-node.js +63 -6
- package/dst/speechflow-utils.d.ts +23 -0
- package/dst/speechflow-utils.js +194 -0
- package/dst/speechflow.js +146 -43
- package/etc/biome.jsonc +12 -4
- package/etc/speechflow.bat +6 -0
- package/etc/speechflow.sh +5 -0
- package/etc/speechflow.yaml +71 -0
- package/etc/stx.conf +65 -0
- package/package.d/@ericedouard+vad-node-realtime+0.2.0.patch +18 -0
- package/package.json +49 -31
- package/src/lib.d.ts +6 -1
- package/src/{speechflow-node-ffmpeg.ts → speechflow-node-a2a-ffmpeg.ts} +10 -4
- package/src/speechflow-node-a2a-wav.ts +143 -0
- package/src/speechflow-node-a2t-deepgram.ts +199 -0
- package/src/{speechflow-node-elevenlabs.ts → speechflow-node-t2a-elevenlabs.ts} +38 -45
- package/src/{speechflow-node-deepl.ts → speechflow-node-t2t-deepl.ts} +36 -25
- package/src/speechflow-node-t2t-format.ts +85 -0
- package/src/{speechflow-node-gemma.ts → speechflow-node-t2t-gemma.ts} +89 -25
- package/src/speechflow-node-t2t-opus.ts +111 -0
- package/src/speechflow-node-t2t-subtitle.ts +101 -0
- package/src/speechflow-node-x2x-trace.ts +92 -0
- package/src/{speechflow-node-device.ts → speechflow-node-xio-device.ts} +25 -3
- package/src/speechflow-node-xio-file.ts +153 -0
- package/src/speechflow-node-xio-mqtt.ts +154 -0
- package/src/speechflow-node-xio-websocket.ts +248 -0
- package/src/speechflow-node.ts +63 -6
- package/src/speechflow-utils.ts +212 -0
- package/src/speechflow.ts +150 -43
- package/etc/nps.yaml +0 -40
- package/sample.yaml +0 -39
- package/src/speechflow-node-deepgram.ts +0 -133
- package/src/speechflow-node-file.ts +0 -108
- package/src/speechflow-node-websocket.ts +0 -179
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/*
|
|
2
|
+
** SpeechFlow - Speech Processing Flow Graph
|
|
3
|
+
** Copyright (c) 2024-2025 Dr. Ralf S. Engelschall <rse@engelschall.com>
|
|
4
|
+
** Licensed under GPL 3.0 <https://spdx.org/licenses/GPL-3.0-only>
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/* standard dependencies */
|
|
8
|
+
import Stream from "node:stream"
|
|
9
|
+
|
|
10
|
+
/* internal dependencies */
|
|
11
|
+
import SpeechFlowNode, { SpeechFlowChunk } from "./speechflow-node"
|
|
12
|
+
|
|
13
|
+
/* SpeechFlow node for subtitle (text-to-text) "translations" */
|
|
14
|
+
export default class SpeechFlowNodeSubtitle extends SpeechFlowNode {
|
|
15
|
+
/* declare official node name */
|
|
16
|
+
public static name = "subtitle"
|
|
17
|
+
|
|
18
|
+
/* internal state */
|
|
19
|
+
private sequenceNo = 1
|
|
20
|
+
|
|
21
|
+
/* construct node */
|
|
22
|
+
constructor (id: string, cfg: { [ id: string ]: any }, opts: { [ id: string ]: any }, args: any[]) {
|
|
23
|
+
super(id, cfg, opts, args)
|
|
24
|
+
|
|
25
|
+
/* declare node configuration parameters */
|
|
26
|
+
this.configure({
|
|
27
|
+
format: { type: "string", pos: 0, val: "srt", match: /^(?:srt|vtt)$/ }
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
/* declare node input/output format */
|
|
31
|
+
this.input = "text"
|
|
32
|
+
this.output = "text"
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* open node */
|
|
36
|
+
async open () {
|
|
37
|
+
this.sequenceNo = 1
|
|
38
|
+
|
|
39
|
+
/* provide text-to-subtitle conversion */
|
|
40
|
+
const convert = async (chunk: SpeechFlowChunk) => {
|
|
41
|
+
if (typeof chunk.payload !== "string")
|
|
42
|
+
throw new Error("chunk payload type must be string")
|
|
43
|
+
let text = chunk.payload
|
|
44
|
+
if (this.params.format === "srt") {
|
|
45
|
+
const start = chunk.timestampStart.toFormat("hh:mm:ss,SSS")
|
|
46
|
+
const end = chunk.timestampEnd.toFormat("hh:mm:ss,SSS")
|
|
47
|
+
text = `${this.sequenceNo++}\n` +
|
|
48
|
+
`${start} --> ${end}\n` +
|
|
49
|
+
`${text}\n\n`
|
|
50
|
+
}
|
|
51
|
+
else if (this.params.format === "vtt") {
|
|
52
|
+
const start = chunk.timestampStart.toFormat("hh:mm:ss.SSS")
|
|
53
|
+
const end = chunk.timestampEnd.toFormat("hh:mm:ss.SSS")
|
|
54
|
+
text = `${this.sequenceNo++}\n` +
|
|
55
|
+
`${start} --> ${end}\n` +
|
|
56
|
+
`${text}\n\n`
|
|
57
|
+
}
|
|
58
|
+
return text
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* establish a duplex stream */
|
|
62
|
+
this.stream = new Stream.Transform({
|
|
63
|
+
readableObjectMode: true,
|
|
64
|
+
writableObjectMode: true,
|
|
65
|
+
decodeStrings: false,
|
|
66
|
+
transform (chunk: SpeechFlowChunk, encoding, callback) {
|
|
67
|
+
if (Buffer.isBuffer(chunk.payload))
|
|
68
|
+
callback(new Error("invalid chunk payload type"))
|
|
69
|
+
else {
|
|
70
|
+
if (chunk.payload === "") {
|
|
71
|
+
this.push(chunk)
|
|
72
|
+
callback()
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
convert(chunk).then((payload) => {
|
|
76
|
+
const chunkNew = chunk.clone()
|
|
77
|
+
chunkNew.payload = payload
|
|
78
|
+
this.push(chunkNew)
|
|
79
|
+
callback()
|
|
80
|
+
}).catch((err) => {
|
|
81
|
+
callback(err)
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
final (callback) {
|
|
87
|
+
this.push(null)
|
|
88
|
+
callback()
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* open node */
|
|
94
|
+
async close () {
|
|
95
|
+
/* close stream */
|
|
96
|
+
if (this.stream !== null) {
|
|
97
|
+
this.stream.destroy()
|
|
98
|
+
this.stream = null
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/*
|
|
2
|
+
** SpeechFlow - Speech Processing Flow Graph
|
|
3
|
+
** Copyright (c) 2024-2025 Dr. Ralf S. Engelschall <rse@engelschall.com>
|
|
4
|
+
** Licensed under GPL 3.0 <https://spdx.org/licenses/GPL-3.0-only>
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/* standard dependencies */
|
|
8
|
+
import Stream from "node:stream"
|
|
9
|
+
import { Duration } from "luxon"
|
|
10
|
+
|
|
11
|
+
/* internal dependencies */
|
|
12
|
+
import SpeechFlowNode, { SpeechFlowChunk } from "./speechflow-node"
|
|
13
|
+
|
|
14
|
+
/* SpeechFlow node for data flow tracing */
|
|
15
|
+
export default class SpeechFlowNodeTrace extends SpeechFlowNode {
|
|
16
|
+
/* declare official node name */
|
|
17
|
+
public static name = "trace"
|
|
18
|
+
|
|
19
|
+
/* construct node */
|
|
20
|
+
constructor (id: string, cfg: { [ id: string ]: any }, opts: { [ id: string ]: any }, args: any[]) {
|
|
21
|
+
super(id, cfg, opts, args)
|
|
22
|
+
|
|
23
|
+
/* declare node configuration parameters */
|
|
24
|
+
this.configure({
|
|
25
|
+
type: { type: "string", pos: 0, val: "audio", match: /^(?:audio|text)$/ },
|
|
26
|
+
name: { type: "string", pos: 1 }
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
/* declare node input/output format */
|
|
30
|
+
this.input = this.params.type
|
|
31
|
+
this.output = this.params.type
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* open node */
|
|
35
|
+
async open () {
|
|
36
|
+
/* wrapper for local logging */
|
|
37
|
+
const log = (level: string, msg: string) => {
|
|
38
|
+
if (this.params.name !== undefined)
|
|
39
|
+
this.log(level, `[${this.params.name}]: ${msg}`)
|
|
40
|
+
else
|
|
41
|
+
this.log(level, msg)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* provide Duplex stream and internally attach to Deepgram API */
|
|
45
|
+
const type = this.params.type
|
|
46
|
+
this.stream = new Stream.Transform({
|
|
47
|
+
writableObjectMode: true,
|
|
48
|
+
readableObjectMode: true,
|
|
49
|
+
decodeStrings: false,
|
|
50
|
+
transform (chunk: SpeechFlowChunk, encoding, callback) {
|
|
51
|
+
let error: Error | undefined
|
|
52
|
+
const fmt = (t: Duration) => t.toFormat("hh:mm:ss.SSS")
|
|
53
|
+
if (Buffer.isBuffer(chunk.payload)) {
|
|
54
|
+
if (type === "audio")
|
|
55
|
+
log("info", `writing ${type} chunk: start=${fmt(chunk.timestampStart)} ` +
|
|
56
|
+
`end=${fmt(chunk.timestampEnd)} kind=${chunk.kind} type=${chunk.type} ` +
|
|
57
|
+
`payload-type=Buffer payload-bytes=${chunk.payload.byteLength}`)
|
|
58
|
+
else
|
|
59
|
+
error = new Error(`writing ${type} chunk: seen Buffer instead of String chunk type`)
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
if (type === "text")
|
|
63
|
+
log("info", `writing ${type} chunk: start=${fmt(chunk.timestampStart)} ` +
|
|
64
|
+
`end=${fmt(chunk.timestampEnd)} kind=${chunk.kind} type=${chunk.type}` +
|
|
65
|
+
`payload-type=String payload-length=${chunk.payload.length} ` +
|
|
66
|
+
`payload-encoding=${encoding} payload-content="${chunk.payload.toString()}"`)
|
|
67
|
+
else
|
|
68
|
+
error = new Error(`writing ${type} chunk: seen String instead of Buffer chunk type`)
|
|
69
|
+
}
|
|
70
|
+
if (error !== undefined)
|
|
71
|
+
callback(error)
|
|
72
|
+
else {
|
|
73
|
+
this.push(chunk, encoding)
|
|
74
|
+
callback()
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
final (callback) {
|
|
78
|
+
this.push(null)
|
|
79
|
+
callback()
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* close node */
|
|
85
|
+
async close () {
|
|
86
|
+
/* close stream */
|
|
87
|
+
if (this.stream !== null) {
|
|
88
|
+
this.stream.destroy()
|
|
89
|
+
this.stream = null
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -12,6 +12,7 @@ import PortAudio from "@gpeng/naudiodon"
|
|
|
12
12
|
|
|
13
13
|
/* internal dependencies */
|
|
14
14
|
import SpeechFlowNode from "./speechflow-node"
|
|
15
|
+
import * as utils from "./speechflow-utils"
|
|
15
16
|
|
|
16
17
|
/* SpeechFlow node for device access */
|
|
17
18
|
export default class SpeechFlowNodeDevice extends SpeechFlowNode {
|
|
@@ -19,11 +20,14 @@ export default class SpeechFlowNodeDevice extends SpeechFlowNode {
|
|
|
19
20
|
public static name = "device"
|
|
20
21
|
|
|
21
22
|
/* internal state */
|
|
22
|
-
private io: PortAudio.IoStreamRead
|
|
23
|
+
private io: PortAudio.IoStreamRead
|
|
24
|
+
| PortAudio.IoStreamWrite
|
|
25
|
+
| PortAudio.IoStreamDuplex
|
|
26
|
+
| null = null
|
|
23
27
|
|
|
24
28
|
/* construct node */
|
|
25
|
-
constructor (id: string, opts: { [ id: string ]: any }, args: any[]) {
|
|
26
|
-
super(id, opts, args)
|
|
29
|
+
constructor (id: string, cfg: { [ id: string ]: any }, opts: { [ id: string ]: any }, args: any[]) {
|
|
30
|
+
super(id, cfg, opts, args)
|
|
27
31
|
|
|
28
32
|
/* declare node configuration parameters */
|
|
29
33
|
this.configure({
|
|
@@ -62,6 +66,9 @@ export default class SpeechFlowNodeDevice extends SpeechFlowNode {
|
|
|
62
66
|
|
|
63
67
|
/* determine device of audio API */
|
|
64
68
|
const devices = PortAudio.getDevices()
|
|
69
|
+
for (const device of devices)
|
|
70
|
+
this.log("info", `found audio device "${device.name}" ` +
|
|
71
|
+
`(inputs: ${device.maxInputChannels}, outputs: ${device.maxOutputChannels}`)
|
|
65
72
|
const device = devices.find((device) => {
|
|
66
73
|
return (
|
|
67
74
|
( ( mode === "r" && device.maxInputChannels > 0)
|
|
@@ -115,6 +122,11 @@ export default class SpeechFlowNodeDevice extends SpeechFlowNode {
|
|
|
115
122
|
}
|
|
116
123
|
})
|
|
117
124
|
this.stream = this.io as unknown as Stream.Duplex
|
|
125
|
+
|
|
126
|
+
/* convert regular stream into object-mode stream */
|
|
127
|
+
const wrapper1 = utils.createTransformStreamForWritableSide()
|
|
128
|
+
const wrapper2 = utils.createTransformStreamForReadableSide("audio", () => this.timeZero)
|
|
129
|
+
this.stream = Stream.compose(wrapper1, this.stream, wrapper2)
|
|
118
130
|
}
|
|
119
131
|
else if (this.params.mode === "r") {
|
|
120
132
|
/* input device */
|
|
@@ -130,6 +142,11 @@ export default class SpeechFlowNodeDevice extends SpeechFlowNode {
|
|
|
130
142
|
}
|
|
131
143
|
})
|
|
132
144
|
this.stream = this.io as unknown as Stream.Readable
|
|
145
|
+
|
|
146
|
+
/* convert regular stream into object-mode stream */
|
|
147
|
+
const wrapper = utils.createTransformStreamForReadableSide("audio", () => this.timeZero)
|
|
148
|
+
this.stream.pipe(wrapper)
|
|
149
|
+
this.stream = wrapper
|
|
133
150
|
}
|
|
134
151
|
else if (this.params.mode === "w") {
|
|
135
152
|
/* output device */
|
|
@@ -145,6 +162,11 @@ export default class SpeechFlowNodeDevice extends SpeechFlowNode {
|
|
|
145
162
|
}
|
|
146
163
|
})
|
|
147
164
|
this.stream = this.io as unknown as Stream.Writable
|
|
165
|
+
|
|
166
|
+
/* convert regular stream into object-mode stream */
|
|
167
|
+
const wrapper = utils.createTransformStreamForWritableSide()
|
|
168
|
+
wrapper.pipe(this.stream)
|
|
169
|
+
this.stream = wrapper
|
|
148
170
|
}
|
|
149
171
|
else
|
|
150
172
|
throw new Error(`device "${device.id}" does not have any input or output channels`)
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/*
|
|
2
|
+
** SpeechFlow - Speech Processing Flow Graph
|
|
3
|
+
** Copyright (c) 2024-2025 Dr. Ralf S. Engelschall <rse@engelschall.com>
|
|
4
|
+
** Licensed under GPL 3.0 <https://spdx.org/licenses/GPL-3.0-only>
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/* standard dependencies */
|
|
8
|
+
import fs from "node:fs"
|
|
9
|
+
import Stream from "node:stream"
|
|
10
|
+
|
|
11
|
+
/* internal dependencies */
|
|
12
|
+
import SpeechFlowNode from "./speechflow-node"
|
|
13
|
+
import * as utils from "./speechflow-utils"
|
|
14
|
+
|
|
15
|
+
/* SpeechFlow node for file access */
|
|
16
|
+
export default class SpeechFlowNodeFile extends SpeechFlowNode {
|
|
17
|
+
/* declare official node name */
|
|
18
|
+
public static name = "file"
|
|
19
|
+
|
|
20
|
+
/* construct node */
|
|
21
|
+
constructor (id: string, cfg: { [ id: string ]: any }, opts: { [ id: string ]: any }, args: any[]) {
|
|
22
|
+
super(id, cfg, opts, args)
|
|
23
|
+
|
|
24
|
+
/* declare node configuration parameters */
|
|
25
|
+
this.configure({
|
|
26
|
+
path: { type: "string", pos: 0 },
|
|
27
|
+
mode: { type: "string", pos: 1, val: "r", match: /^(?:r|w|rw)$/ },
|
|
28
|
+
type: { type: "string", pos: 2, val: "audio", match: /^(?:audio|text)$/ }
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
/* declare node input/output format */
|
|
32
|
+
if (this.params.mode === "rw") {
|
|
33
|
+
this.input = this.params.type
|
|
34
|
+
this.output = this.params.type
|
|
35
|
+
}
|
|
36
|
+
else if (this.params.mode === "r") {
|
|
37
|
+
this.input = "none"
|
|
38
|
+
this.output = this.params.type
|
|
39
|
+
}
|
|
40
|
+
else if (this.params.mode === "w") {
|
|
41
|
+
this.input = this.params.type
|
|
42
|
+
this.output = "none"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* open node */
|
|
47
|
+
async open () {
|
|
48
|
+
if (this.params.mode === "rw") {
|
|
49
|
+
if (this.params.path === "-") {
|
|
50
|
+
/* standard I/O */
|
|
51
|
+
if (this.params.type === "audio") {
|
|
52
|
+
process.stdin.setEncoding()
|
|
53
|
+
process.stdout.setEncoding()
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
process.stdin.setEncoding(this.config.textEncoding)
|
|
57
|
+
process.stdout.setEncoding(this.config.textEncoding)
|
|
58
|
+
}
|
|
59
|
+
this.stream = Stream.Duplex.from({
|
|
60
|
+
readable: process.stdin,
|
|
61
|
+
writable: process.stdout
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
/* file I/O */
|
|
66
|
+
if (this.params.type === "audio") {
|
|
67
|
+
this.stream = Stream.Duplex.from({
|
|
68
|
+
readable: fs.createReadStream(this.params.path),
|
|
69
|
+
writable: fs.createWriteStream(this.params.path)
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
this.stream = Stream.Duplex.from({
|
|
74
|
+
readable: fs.createReadStream(this.params.path,
|
|
75
|
+
{ encoding: this.config.textEncoding }),
|
|
76
|
+
writable: fs.createWriteStream(this.params.path,
|
|
77
|
+
{ encoding: this.config.textEncoding })
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* convert regular stream into object-mode stream */
|
|
83
|
+
const wrapper1 = utils.createTransformStreamForWritableSide()
|
|
84
|
+
const wrapper2 = utils.createTransformStreamForReadableSide(this.params.type, () => this.timeZero)
|
|
85
|
+
this.stream = Stream.compose(wrapper1, this.stream, wrapper2)
|
|
86
|
+
}
|
|
87
|
+
else if (this.params.mode === "r") {
|
|
88
|
+
if (this.params.path === "-") {
|
|
89
|
+
/* standard I/O */
|
|
90
|
+
if (this.params.type === "audio")
|
|
91
|
+
process.stdin.setEncoding()
|
|
92
|
+
else
|
|
93
|
+
process.stdin.setEncoding(this.config.textEncoding)
|
|
94
|
+
this.stream = process.stdin
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
/* file I/O */
|
|
98
|
+
if (this.params.type === "audio")
|
|
99
|
+
this.stream = fs.createReadStream(this.params.path)
|
|
100
|
+
else
|
|
101
|
+
this.stream = fs.createReadStream(this.params.path,
|
|
102
|
+
{ encoding: this.config.textEncoding })
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/* convert regular stream into object-mode stream */
|
|
106
|
+
const wrapper = utils.createTransformStreamForReadableSide(this.params.type, () => this.timeZero)
|
|
107
|
+
this.stream.pipe(wrapper)
|
|
108
|
+
this.stream = wrapper
|
|
109
|
+
}
|
|
110
|
+
else if (this.params.mode === "w") {
|
|
111
|
+
if (this.params.path === "-") {
|
|
112
|
+
/* standard I/O */
|
|
113
|
+
if (this.params.type === "audio")
|
|
114
|
+
process.stdout.setEncoding()
|
|
115
|
+
else
|
|
116
|
+
process.stdout.setEncoding(this.config.textEncoding)
|
|
117
|
+
this.stream = process.stdout
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
/* file I/O */
|
|
121
|
+
if (this.params.type === "audio")
|
|
122
|
+
this.stream = fs.createWriteStream(this.params.path)
|
|
123
|
+
else
|
|
124
|
+
this.stream = fs.createWriteStream(this.params.path,
|
|
125
|
+
{ encoding: this.config.textEncoding })
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/* convert regular stream into object-mode stream */
|
|
129
|
+
const wrapper = utils.createTransformStreamForWritableSide()
|
|
130
|
+
wrapper.pipe(this.stream as Stream.Writable)
|
|
131
|
+
this.stream = wrapper
|
|
132
|
+
}
|
|
133
|
+
else
|
|
134
|
+
throw new Error(`invalid file mode "${this.params.mode}"`)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/* close node */
|
|
138
|
+
async close () {
|
|
139
|
+
/* shutdown stream */
|
|
140
|
+
if (this.stream !== null) {
|
|
141
|
+
await new Promise<void>((resolve) => {
|
|
142
|
+
if (this.stream instanceof Stream.Writable || this.stream instanceof Stream.Duplex)
|
|
143
|
+
this.stream.end(() => { resolve() })
|
|
144
|
+
else
|
|
145
|
+
resolve()
|
|
146
|
+
})
|
|
147
|
+
if (this.params.path !== "-")
|
|
148
|
+
this.stream.destroy()
|
|
149
|
+
this.stream = null
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/*
|
|
2
|
+
** SpeechFlow - Speech Processing Flow Graph
|
|
3
|
+
** Copyright (c) 2024-2025 Dr. Ralf S. Engelschall <rse@engelschall.com>
|
|
4
|
+
** Licensed under GPL 3.0 <https://spdx.org/licenses/GPL-3.0-only>
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/* standard dependencies */
|
|
8
|
+
import Stream from "node:stream"
|
|
9
|
+
|
|
10
|
+
/* external dependencies */
|
|
11
|
+
import MQTT from "mqtt"
|
|
12
|
+
import UUID from "pure-uuid"
|
|
13
|
+
|
|
14
|
+
/* internal dependencies */
|
|
15
|
+
import SpeechFlowNode, { SpeechFlowChunk } from "./speechflow-node"
|
|
16
|
+
import * as utils from "./speechflow-utils"
|
|
17
|
+
|
|
18
|
+
/* SpeechFlow node for MQTT networking */
|
|
19
|
+
export default class SpeechFlowNodeMQTT extends SpeechFlowNode {
|
|
20
|
+
/* declare official node name */
|
|
21
|
+
public static name = "mqtt"
|
|
22
|
+
|
|
23
|
+
/* internal state */
|
|
24
|
+
private broker: MQTT.MqttClient | null = null
|
|
25
|
+
private clientId: string = (new UUID(1)).format()
|
|
26
|
+
|
|
27
|
+
/* construct node */
|
|
28
|
+
constructor (id: string, cfg: { [ id: string ]: any }, opts: { [ id: string ]: any }, args: any[]) {
|
|
29
|
+
super(id, cfg, opts, args)
|
|
30
|
+
|
|
31
|
+
/* declare node configuration parameters */
|
|
32
|
+
this.configure({
|
|
33
|
+
url: { type: "string", pos: 0, val: "", match: /^(?:|(?:ws|mqtt):\/\/(.+?):(\d+)(?:\/.*)?)$/ },
|
|
34
|
+
username: { type: "string", pos: 1, val: "", match: /^.+$/ },
|
|
35
|
+
password: { type: "string", pos: 2, val: "", match: /^.+$/ },
|
|
36
|
+
topicRead: { type: "string", pos: 3, val: "", match: /^.+$/ },
|
|
37
|
+
topicWrite: { type: "string", pos: 4, val: "", match: /^.+$/ },
|
|
38
|
+
mode: { type: "string", pos: 5, val: "w", match: /^(?:r|w|rw)$/ },
|
|
39
|
+
type: { type: "string", pos: 6, val: "text", match: /^(?:audio|text)$/ }
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
/* logical parameter sanity check */
|
|
43
|
+
if ((this.params.mode === "w" || this.params.mode === "rw") && this.params.topicWrite === "")
|
|
44
|
+
throw new Error("writing to MQTT requires a topicWrite parameter")
|
|
45
|
+
if ((this.params.mode === "r" || this.params.mode === "rw") && this.params.topicRead === "")
|
|
46
|
+
throw new Error("reading from MQTT requires a topicRead parameter")
|
|
47
|
+
|
|
48
|
+
/* declare node input/output format */
|
|
49
|
+
if (this.params.mode === "rw") {
|
|
50
|
+
this.input = this.params.type
|
|
51
|
+
this.output = this.params.type
|
|
52
|
+
}
|
|
53
|
+
else if (this.params.mode === "r") {
|
|
54
|
+
this.input = "none"
|
|
55
|
+
this.output = this.params.type
|
|
56
|
+
}
|
|
57
|
+
else if (this.params.mode === "w") {
|
|
58
|
+
this.input = this.params.type
|
|
59
|
+
this.output = "none"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* open node */
|
|
64
|
+
async open () {
|
|
65
|
+
/* connect remotely to a MQTT broker */
|
|
66
|
+
this.broker = MQTT.connect(this.params.url, {
|
|
67
|
+
protocolId: "MQTT",
|
|
68
|
+
protocolVersion: 5,
|
|
69
|
+
username: this.params.username,
|
|
70
|
+
password: this.params.password,
|
|
71
|
+
clientId: this.clientId,
|
|
72
|
+
clean: true,
|
|
73
|
+
resubscribe: true,
|
|
74
|
+
keepalive: 60, /* 60s */
|
|
75
|
+
reconnectPeriod: 2 * 1000, /* 2s */
|
|
76
|
+
connectTimeout: 30 * 1000 /* 30s */
|
|
77
|
+
})
|
|
78
|
+
this.broker.on("error", (error: Error) => {
|
|
79
|
+
this.log("error", `error on MQTT ${this.params.url}: ${error.message}`)
|
|
80
|
+
})
|
|
81
|
+
this.broker.on("connect", (packet: MQTT.IConnackPacket) => {
|
|
82
|
+
this.log("info", `connection opened to MQTT ${this.params.url}`)
|
|
83
|
+
if (this.params.mode !== "w" && !packet.sessionPresent)
|
|
84
|
+
this.broker!.subscribe([ this.params.topicRead ], () => {})
|
|
85
|
+
})
|
|
86
|
+
this.broker.on("reconnect", () => {
|
|
87
|
+
this.log("info", `connection re-opened to MQTT ${this.params.url}`)
|
|
88
|
+
})
|
|
89
|
+
this.broker.on("disconnect", (packet: MQTT.IDisconnectPacket) => {
|
|
90
|
+
this.log("info", `connection closed to MQTT ${this.params.url}`)
|
|
91
|
+
})
|
|
92
|
+
const chunkQueue = new utils.SingleQueue<SpeechFlowChunk>()
|
|
93
|
+
this.broker.on("message", (topic: string, payload: Buffer, packet: MQTT.IPublishPacket) => {
|
|
94
|
+
if (topic !== this.params.topicRead)
|
|
95
|
+
return
|
|
96
|
+
try {
|
|
97
|
+
const chunk = utils.streamChunkDecode(payload)
|
|
98
|
+
chunkQueue.write(chunk)
|
|
99
|
+
}
|
|
100
|
+
catch (_err: any) {
|
|
101
|
+
this.log("warning", `received invalid CBOR chunk from MQTT ${this.params.url}`)
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
const broker = this.broker
|
|
105
|
+
const topicWrite = this.params.topicWrite
|
|
106
|
+
const type = this.params.type
|
|
107
|
+
const mode = this.params.mode
|
|
108
|
+
this.stream = new Stream.Duplex({
|
|
109
|
+
writableObjectMode: true,
|
|
110
|
+
readableObjectMode: true,
|
|
111
|
+
decodeStrings: false,
|
|
112
|
+
write (chunk: SpeechFlowChunk, encoding, callback) {
|
|
113
|
+
if (mode === "r")
|
|
114
|
+
callback(new Error("write operation on read-only node"))
|
|
115
|
+
else if (chunk.type !== type)
|
|
116
|
+
callback(new Error(`written chunk is not of ${type} type`))
|
|
117
|
+
else if (!broker.connected)
|
|
118
|
+
callback(new Error("still no MQTT connection available"))
|
|
119
|
+
else {
|
|
120
|
+
const data = Buffer.from(utils.streamChunkEncode(chunk))
|
|
121
|
+
broker.publish(topicWrite, data, { qos: 2, retain: false }, (err) => {
|
|
122
|
+
if (err)
|
|
123
|
+
callback(new Error(`failed to publish to MQTT topic "${topicWrite}": ${err}`))
|
|
124
|
+
else
|
|
125
|
+
callback()
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
read (size: number) {
|
|
130
|
+
if (mode === "w")
|
|
131
|
+
throw new Error("read operation on write-only node")
|
|
132
|
+
chunkQueue.read().then((chunk) => {
|
|
133
|
+
this.push(chunk, "binary")
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* close node */
|
|
140
|
+
async close () {
|
|
141
|
+
/* close Websocket server */
|
|
142
|
+
if (this.broker !== null) {
|
|
143
|
+
if (this.broker.connected)
|
|
144
|
+
this.broker.end()
|
|
145
|
+
this.broker = null
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/* close stream */
|
|
149
|
+
if (this.stream !== null) {
|
|
150
|
+
this.stream.destroy()
|
|
151
|
+
this.stream = null
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|