speechflow 0.9.5 → 0.9.7

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.
Files changed (106) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +221 -53
  3. package/dst/speechflow-node-a2a-ffmpeg.d.ts +13 -0
  4. package/dst/speechflow-node-a2a-ffmpeg.js +152 -0
  5. package/dst/speechflow-node-a2a-wav.d.ts +11 -0
  6. package/dst/speechflow-node-a2a-wav.js +170 -0
  7. package/dst/speechflow-node-a2t-deepgram.d.ts +12 -0
  8. package/dst/speechflow-node-a2t-deepgram.js +220 -0
  9. package/dst/speechflow-node-deepgram.d.ts +3 -1
  10. package/dst/speechflow-node-deepgram.js +86 -22
  11. package/dst/speechflow-node-deepl.d.ts +3 -1
  12. package/dst/speechflow-node-deepl.js +25 -20
  13. package/dst/speechflow-node-device.d.ts +3 -1
  14. package/dst/speechflow-node-device.js +53 -2
  15. package/dst/speechflow-node-elevenlabs.d.ts +3 -1
  16. package/dst/speechflow-node-elevenlabs.js +37 -42
  17. package/dst/speechflow-node-ffmpeg.d.ts +3 -1
  18. package/dst/speechflow-node-ffmpeg.js +42 -4
  19. package/dst/speechflow-node-file.d.ts +3 -1
  20. package/dst/speechflow-node-file.js +84 -13
  21. package/dst/speechflow-node-format.d.ts +11 -0
  22. package/dst/speechflow-node-format.js +80 -0
  23. package/dst/speechflow-node-gemma.d.ts +3 -1
  24. package/dst/speechflow-node-gemma.js +84 -23
  25. package/dst/speechflow-node-mqtt.d.ts +13 -0
  26. package/dst/speechflow-node-mqtt.js +181 -0
  27. package/dst/speechflow-node-opus.d.ts +12 -0
  28. package/dst/speechflow-node-opus.js +135 -0
  29. package/dst/speechflow-node-subtitle.d.ts +12 -0
  30. package/dst/speechflow-node-subtitle.js +96 -0
  31. package/dst/speechflow-node-t2a-elevenlabs.d.ts +13 -0
  32. package/dst/speechflow-node-t2a-elevenlabs.js +182 -0
  33. package/dst/speechflow-node-t2t-deepl.d.ts +12 -0
  34. package/dst/speechflow-node-t2t-deepl.js +133 -0
  35. package/dst/speechflow-node-t2t-format.d.ts +11 -0
  36. package/dst/speechflow-node-t2t-format.js +80 -0
  37. package/dst/speechflow-node-t2t-gemma.d.ts +13 -0
  38. package/dst/speechflow-node-t2t-gemma.js +213 -0
  39. package/dst/speechflow-node-t2t-opus.d.ts +12 -0
  40. package/dst/speechflow-node-t2t-opus.js +135 -0
  41. package/dst/speechflow-node-t2t-subtitle.d.ts +12 -0
  42. package/dst/speechflow-node-t2t-subtitle.js +96 -0
  43. package/dst/speechflow-node-trace.d.ts +11 -0
  44. package/dst/speechflow-node-trace.js +88 -0
  45. package/dst/speechflow-node-wav.d.ts +11 -0
  46. package/dst/speechflow-node-wav.js +170 -0
  47. package/dst/speechflow-node-websocket.d.ts +3 -1
  48. package/dst/speechflow-node-websocket.js +149 -49
  49. package/dst/speechflow-node-whisper-common.d.ts +34 -0
  50. package/dst/speechflow-node-whisper-common.js +7 -0
  51. package/dst/speechflow-node-whisper-ggml.d.ts +1 -0
  52. package/dst/speechflow-node-whisper-ggml.js +97 -0
  53. package/dst/speechflow-node-whisper-onnx.d.ts +1 -0
  54. package/dst/speechflow-node-whisper-onnx.js +131 -0
  55. package/dst/speechflow-node-whisper-worker-ggml.d.ts +1 -0
  56. package/dst/speechflow-node-whisper-worker-ggml.js +97 -0
  57. package/dst/speechflow-node-whisper-worker-onnx.d.ts +1 -0
  58. package/dst/speechflow-node-whisper-worker-onnx.js +131 -0
  59. package/dst/speechflow-node-whisper-worker.d.ts +1 -0
  60. package/dst/speechflow-node-whisper-worker.js +116 -0
  61. package/dst/speechflow-node-whisper-worker2.d.ts +1 -0
  62. package/dst/speechflow-node-whisper-worker2.js +82 -0
  63. package/dst/speechflow-node-whisper.d.ts +19 -0
  64. package/dst/speechflow-node-whisper.js +604 -0
  65. package/dst/speechflow-node-x2x-trace.d.ts +11 -0
  66. package/dst/speechflow-node-x2x-trace.js +88 -0
  67. package/dst/speechflow-node-xio-device.d.ts +13 -0
  68. package/dst/speechflow-node-xio-device.js +205 -0
  69. package/dst/speechflow-node-xio-file.d.ts +11 -0
  70. package/dst/speechflow-node-xio-file.js +176 -0
  71. package/dst/speechflow-node-xio-mqtt.d.ts +13 -0
  72. package/dst/speechflow-node-xio-mqtt.js +181 -0
  73. package/dst/speechflow-node-xio-websocket.d.ts +13 -0
  74. package/dst/speechflow-node-xio-websocket.js +275 -0
  75. package/dst/speechflow-node.d.ts +24 -6
  76. package/dst/speechflow-node.js +63 -6
  77. package/dst/speechflow-utils.d.ts +23 -0
  78. package/dst/speechflow-utils.js +194 -0
  79. package/dst/speechflow.js +146 -43
  80. package/etc/biome.jsonc +12 -4
  81. package/etc/stx.conf +65 -0
  82. package/package.d/@ericedouard+vad-node-realtime+0.2.0.patch +18 -0
  83. package/package.json +49 -31
  84. package/sample.yaml +59 -27
  85. package/src/lib.d.ts +6 -1
  86. package/src/{speechflow-node-ffmpeg.ts → speechflow-node-a2a-ffmpeg.ts} +10 -4
  87. package/src/speechflow-node-a2a-wav.ts +143 -0
  88. package/src/speechflow-node-a2t-deepgram.ts +199 -0
  89. package/src/{speechflow-node-elevenlabs.ts → speechflow-node-t2a-elevenlabs.ts} +38 -45
  90. package/src/{speechflow-node-deepl.ts → speechflow-node-t2t-deepl.ts} +36 -25
  91. package/src/speechflow-node-t2t-format.ts +85 -0
  92. package/src/{speechflow-node-gemma.ts → speechflow-node-t2t-gemma.ts} +89 -25
  93. package/src/speechflow-node-t2t-opus.ts +111 -0
  94. package/src/speechflow-node-t2t-subtitle.ts +101 -0
  95. package/src/speechflow-node-x2x-trace.ts +92 -0
  96. package/src/{speechflow-node-device.ts → speechflow-node-xio-device.ts} +25 -3
  97. package/src/speechflow-node-xio-file.ts +153 -0
  98. package/src/speechflow-node-xio-mqtt.ts +154 -0
  99. package/src/speechflow-node-xio-websocket.ts +248 -0
  100. package/src/speechflow-node.ts +63 -6
  101. package/src/speechflow-utils.ts +212 -0
  102. package/src/speechflow.ts +150 -43
  103. package/etc/nps.yaml +0 -40
  104. package/src/speechflow-node-deepgram.ts +0 -133
  105. package/src/speechflow-node-file.ts +0 -108
  106. 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 | PortAudio.IoStreamWrite | PortAudio.IoStreamDuplex | null = null
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
+ }