speechflow 1.3.1 → 1.4.0

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 (156) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/etc/stx.conf +54 -58
  3. package/package.json +25 -106
  4. package/{etc → speechflow-cli/etc}/eslint.mjs +1 -2
  5. package/speechflow-cli/etc/stx.conf +77 -0
  6. package/speechflow-cli/package.json +116 -0
  7. package/{src → speechflow-cli/src}/speechflow-node-a2a-gender.ts +148 -64
  8. package/speechflow-cli/src/speechflow-node-a2a-meter.ts +217 -0
  9. package/{src → speechflow-cli/src}/speechflow-node-a2a-mute.ts +39 -11
  10. package/speechflow-cli/src/speechflow-node-a2a-vad.ts +384 -0
  11. package/{src → speechflow-cli/src}/speechflow-node-a2a-wav.ts +27 -11
  12. package/speechflow-cli/src/speechflow-node-a2t-deepgram.ts +313 -0
  13. package/{src → speechflow-cli/src}/speechflow-node-t2a-elevenlabs.ts +59 -12
  14. package/{src → speechflow-cli/src}/speechflow-node-t2a-kokoro.ts +11 -4
  15. package/{src → speechflow-cli/src}/speechflow-node-t2t-deepl.ts +9 -4
  16. package/{src → speechflow-cli/src}/speechflow-node-t2t-format.ts +2 -2
  17. package/{src → speechflow-cli/src}/speechflow-node-t2t-ollama.ts +1 -1
  18. package/{src → speechflow-cli/src}/speechflow-node-t2t-openai.ts +1 -1
  19. package/{src → speechflow-cli/src}/speechflow-node-t2t-sentence.ts +37 -20
  20. package/speechflow-cli/src/speechflow-node-t2t-subtitle.ts +276 -0
  21. package/{src → speechflow-cli/src}/speechflow-node-t2t-transformers.ts +4 -3
  22. package/{src → speechflow-cli/src}/speechflow-node-x2x-filter.ts +9 -5
  23. package/{src → speechflow-cli/src}/speechflow-node-x2x-trace.ts +16 -8
  24. package/{src → speechflow-cli/src}/speechflow-node-xio-device.ts +12 -8
  25. package/{src → speechflow-cli/src}/speechflow-node-xio-file.ts +9 -3
  26. package/{src → speechflow-cli/src}/speechflow-node-xio-mqtt.ts +5 -2
  27. package/{src → speechflow-cli/src}/speechflow-node-xio-websocket.ts +12 -12
  28. package/{src → speechflow-cli/src}/speechflow-node.ts +7 -0
  29. package/{src → speechflow-cli/src}/speechflow-utils.ts +78 -44
  30. package/{src → speechflow-cli/src}/speechflow.ts +188 -53
  31. package/speechflow-ui-db/etc/eslint.mjs +106 -0
  32. package/speechflow-ui-db/etc/htmllint.json +55 -0
  33. package/speechflow-ui-db/etc/stx.conf +79 -0
  34. package/speechflow-ui-db/etc/stylelint.js +46 -0
  35. package/speechflow-ui-db/etc/stylelint.yaml +33 -0
  36. package/speechflow-ui-db/etc/tsc-client.json +30 -0
  37. package/speechflow-ui-db/etc/tsc.node.json +9 -0
  38. package/speechflow-ui-db/etc/vite-client.mts +63 -0
  39. package/speechflow-ui-db/package.d/htmllint-cli+0.0.7.patch +20 -0
  40. package/speechflow-ui-db/package.json +75 -0
  41. package/speechflow-ui-db/src/app-icon.ai +1989 -4
  42. package/speechflow-ui-db/src/app-icon.svg +26 -0
  43. package/speechflow-ui-db/src/app.styl +64 -0
  44. package/speechflow-ui-db/src/app.vue +221 -0
  45. package/speechflow-ui-db/src/index.html +23 -0
  46. package/speechflow-ui-db/src/index.ts +26 -0
  47. package/{dst/speechflow.d.ts → speechflow-ui-db/src/lib.d.ts} +5 -3
  48. package/speechflow-ui-db/src/tsconfig.json +3 -0
  49. package/speechflow-ui-st/etc/eslint.mjs +106 -0
  50. package/speechflow-ui-st/etc/htmllint.json +55 -0
  51. package/speechflow-ui-st/etc/stx.conf +79 -0
  52. package/speechflow-ui-st/etc/stylelint.js +46 -0
  53. package/speechflow-ui-st/etc/stylelint.yaml +33 -0
  54. package/speechflow-ui-st/etc/tsc-client.json +30 -0
  55. package/speechflow-ui-st/etc/tsc.node.json +9 -0
  56. package/speechflow-ui-st/etc/vite-client.mts +63 -0
  57. package/speechflow-ui-st/package.d/htmllint-cli+0.0.7.patch +20 -0
  58. package/speechflow-ui-st/package.json +79 -0
  59. package/speechflow-ui-st/src/app-icon.ai +1989 -4
  60. package/speechflow-ui-st/src/app-icon.svg +26 -0
  61. package/speechflow-ui-st/src/app.styl +64 -0
  62. package/speechflow-ui-st/src/app.vue +142 -0
  63. package/speechflow-ui-st/src/index.html +23 -0
  64. package/speechflow-ui-st/src/index.ts +26 -0
  65. package/speechflow-ui-st/src/lib.d.ts +9 -0
  66. package/speechflow-ui-st/src/tsconfig.json +3 -0
  67. package/dst/speechflow-node-a2a-ffmpeg.d.ts +0 -13
  68. package/dst/speechflow-node-a2a-ffmpeg.js +0 -153
  69. package/dst/speechflow-node-a2a-ffmpeg.js.map +0 -1
  70. package/dst/speechflow-node-a2a-gender.d.ts +0 -18
  71. package/dst/speechflow-node-a2a-gender.js +0 -271
  72. package/dst/speechflow-node-a2a-gender.js.map +0 -1
  73. package/dst/speechflow-node-a2a-meter.d.ts +0 -12
  74. package/dst/speechflow-node-a2a-meter.js +0 -155
  75. package/dst/speechflow-node-a2a-meter.js.map +0 -1
  76. package/dst/speechflow-node-a2a-mute.d.ts +0 -16
  77. package/dst/speechflow-node-a2a-mute.js +0 -91
  78. package/dst/speechflow-node-a2a-mute.js.map +0 -1
  79. package/dst/speechflow-node-a2a-vad.d.ts +0 -16
  80. package/dst/speechflow-node-a2a-vad.js +0 -285
  81. package/dst/speechflow-node-a2a-vad.js.map +0 -1
  82. package/dst/speechflow-node-a2a-wav.d.ts +0 -11
  83. package/dst/speechflow-node-a2a-wav.js +0 -195
  84. package/dst/speechflow-node-a2a-wav.js.map +0 -1
  85. package/dst/speechflow-node-a2t-deepgram.d.ts +0 -15
  86. package/dst/speechflow-node-a2t-deepgram.js +0 -255
  87. package/dst/speechflow-node-a2t-deepgram.js.map +0 -1
  88. package/dst/speechflow-node-t2a-elevenlabs.d.ts +0 -16
  89. package/dst/speechflow-node-t2a-elevenlabs.js +0 -195
  90. package/dst/speechflow-node-t2a-elevenlabs.js.map +0 -1
  91. package/dst/speechflow-node-t2a-kokoro.d.ts +0 -13
  92. package/dst/speechflow-node-t2a-kokoro.js +0 -149
  93. package/dst/speechflow-node-t2a-kokoro.js.map +0 -1
  94. package/dst/speechflow-node-t2t-deepl.d.ts +0 -15
  95. package/dst/speechflow-node-t2t-deepl.js +0 -142
  96. package/dst/speechflow-node-t2t-deepl.js.map +0 -1
  97. package/dst/speechflow-node-t2t-format.d.ts +0 -11
  98. package/dst/speechflow-node-t2t-format.js +0 -82
  99. package/dst/speechflow-node-t2t-format.js.map +0 -1
  100. package/dst/speechflow-node-t2t-ollama.d.ts +0 -13
  101. package/dst/speechflow-node-t2t-ollama.js +0 -247
  102. package/dst/speechflow-node-t2t-ollama.js.map +0 -1
  103. package/dst/speechflow-node-t2t-openai.d.ts +0 -13
  104. package/dst/speechflow-node-t2t-openai.js +0 -227
  105. package/dst/speechflow-node-t2t-openai.js.map +0 -1
  106. package/dst/speechflow-node-t2t-sentence.d.ts +0 -17
  107. package/dst/speechflow-node-t2t-sentence.js +0 -234
  108. package/dst/speechflow-node-t2t-sentence.js.map +0 -1
  109. package/dst/speechflow-node-t2t-subtitle.d.ts +0 -13
  110. package/dst/speechflow-node-t2t-subtitle.js +0 -278
  111. package/dst/speechflow-node-t2t-subtitle.js.map +0 -1
  112. package/dst/speechflow-node-t2t-transformers.d.ts +0 -14
  113. package/dst/speechflow-node-t2t-transformers.js +0 -265
  114. package/dst/speechflow-node-t2t-transformers.js.map +0 -1
  115. package/dst/speechflow-node-x2x-filter.d.ts +0 -11
  116. package/dst/speechflow-node-x2x-filter.js +0 -117
  117. package/dst/speechflow-node-x2x-filter.js.map +0 -1
  118. package/dst/speechflow-node-x2x-trace.d.ts +0 -11
  119. package/dst/speechflow-node-x2x-trace.js +0 -111
  120. package/dst/speechflow-node-x2x-trace.js.map +0 -1
  121. package/dst/speechflow-node-xio-device.d.ts +0 -13
  122. package/dst/speechflow-node-xio-device.js +0 -226
  123. package/dst/speechflow-node-xio-device.js.map +0 -1
  124. package/dst/speechflow-node-xio-file.d.ts +0 -11
  125. package/dst/speechflow-node-xio-file.js +0 -210
  126. package/dst/speechflow-node-xio-file.js.map +0 -1
  127. package/dst/speechflow-node-xio-mqtt.d.ts +0 -13
  128. package/dst/speechflow-node-xio-mqtt.js +0 -185
  129. package/dst/speechflow-node-xio-mqtt.js.map +0 -1
  130. package/dst/speechflow-node-xio-websocket.d.ts +0 -13
  131. package/dst/speechflow-node-xio-websocket.js +0 -278
  132. package/dst/speechflow-node-xio-websocket.js.map +0 -1
  133. package/dst/speechflow-node.d.ts +0 -65
  134. package/dst/speechflow-node.js +0 -180
  135. package/dst/speechflow-node.js.map +0 -1
  136. package/dst/speechflow-utils.d.ts +0 -69
  137. package/dst/speechflow-utils.js +0 -486
  138. package/dst/speechflow-utils.js.map +0 -1
  139. package/dst/speechflow.js +0 -768
  140. package/dst/speechflow.js.map +0 -1
  141. package/src/speechflow-node-a2a-meter.ts +0 -130
  142. package/src/speechflow-node-a2a-vad.ts +0 -285
  143. package/src/speechflow-node-a2t-deepgram.ts +0 -234
  144. package/src/speechflow-node-t2t-subtitle.ts +0 -149
  145. /package/{etc → speechflow-cli/etc}/biome.jsonc +0 -0
  146. /package/{etc → speechflow-cli/etc}/oxlint.jsonc +0 -0
  147. /package/{etc → speechflow-cli/etc}/speechflow.bat +0 -0
  148. /package/{etc → speechflow-cli/etc}/speechflow.sh +0 -0
  149. /package/{etc → speechflow-cli/etc}/speechflow.yaml +0 -0
  150. /package/{etc → speechflow-cli/etc}/tsconfig.json +0 -0
  151. /package/{package.d → speechflow-cli/package.d}/@ericedouard+vad-node-realtime+0.2.0.patch +0 -0
  152. /package/{src → speechflow-cli/src}/lib.d.ts +0 -0
  153. /package/{src → speechflow-cli/src}/speechflow-logo.ai +0 -0
  154. /package/{src → speechflow-cli/src}/speechflow-logo.svg +0 -0
  155. /package/{src → speechflow-cli/src}/speechflow-node-a2a-ffmpeg.ts +0 -0
  156. /package/{tsconfig.json → speechflow-cli/tsconfig.json} +0 -0
@@ -39,6 +39,8 @@ export default class SpeechFlowNodeGender extends SpeechFlowNode {
39
39
  private queueAC = this.queue.pointerUse("ac")
40
40
  private queueSend = this.queue.pointerUse("send")
41
41
  private shutdown = false
42
+ private workingOffTimer: ReturnType<typeof setTimeout> | null = null
43
+ private progressInterval: ReturnType<typeof setInterval> | null = null
42
44
 
43
45
  /* construct node */
44
46
  constructor (id: string, cfg: { [ id: string ]: any }, opts: { [ id: string ]: any }, args: any[]) {
@@ -60,6 +62,9 @@ export default class SpeechFlowNodeGender extends SpeechFlowNode {
60
62
  if (this.config.audioBitDepth !== 16 || !this.config.audioLittleEndian)
61
63
  throw new Error("Gender node currently supports PCM-S16LE audio only")
62
64
 
65
+ /* clear shutdown flag */
66
+ this.shutdown = false
67
+
63
68
  /* pass-through logging */
64
69
  const log = (level: string, msg: string) => { this.log(level, msg) }
65
70
 
@@ -69,6 +74,8 @@ export default class SpeechFlowNodeGender extends SpeechFlowNode {
69
74
  /* track download progress when instantiating Transformers engine and model */
70
75
  const progressState = new Map<string, number>()
71
76
  const progressCallback: Transformers.ProgressCallback = (progress: any) => {
77
+ if (this.shutdown)
78
+ return
72
79
  let artifact = model
73
80
  if (typeof progress.file === "string")
74
81
  artifact += `:${progress.file}`
@@ -80,31 +87,54 @@ export default class SpeechFlowNodeGender extends SpeechFlowNode {
80
87
  if (percent > 0)
81
88
  progressState.set(artifact, percent)
82
89
  }
83
- const interval = setInterval(() => {
90
+ this.progressInterval = setInterval(() => {
91
+ if (this.shutdown)
92
+ return
84
93
  for (const [ artifact, percent ] of progressState) {
85
94
  this.log("info", `downloaded ${percent.toFixed(2)}% of artifact "${artifact}"`)
86
95
  if (percent >= 1.0)
87
96
  progressState.delete(artifact)
88
97
  }
89
98
  }, 1000)
90
-
91
- /* instantiate Transformers engine and model */
92
- const pipeline = Transformers.pipeline("audio-classification", model, {
93
- cache_dir: path.join(this.config.cacheDir, "gender"),
94
- dtype: "q4",
95
- device: "auto",
96
- progress_callback: progressCallback
97
- })
98
- this.classifier = await pipeline
99
- clearInterval(interval)
99
+ try {
100
+ const pipelinePromise = Transformers.pipeline("audio-classification", model, {
101
+ cache_dir: path.join(this.config.cacheDir, "gender"),
102
+ dtype: "q4",
103
+ device: "auto",
104
+ progress_callback: progressCallback
105
+ })
106
+ const timeoutPromise = new Promise((resolve, reject) => setTimeout(() =>
107
+ reject(new Error("model initialization timeout")), 30 * 1000))
108
+ this.classifier = await Promise.race([
109
+ pipelinePromise, timeoutPromise
110
+ ]) as Transformers.AudioClassificationPipeline
111
+ }
112
+ catch (error) {
113
+ if (this.progressInterval) {
114
+ clearInterval(this.progressInterval)
115
+ this.progressInterval = null
116
+ }
117
+ throw new Error(`failed to initialize classifier pipeline: ${error}`)
118
+ }
119
+ if (this.progressInterval) {
120
+ clearInterval(this.progressInterval)
121
+ this.progressInterval = null
122
+ }
100
123
  if (this.classifier === null)
101
124
  throw new Error("failed to instantiate classifier pipeline")
102
125
 
103
126
  /* classify a single large-enough concatenated audio frame */
104
127
  const classify = async (data: Float32Array) => {
105
- const result = await this.classifier!(data)
106
- const classified: Transformers.AudioClassificationOutput =
107
- Array.isArray(result) ? result as Transformers.AudioClassificationOutput : [ result ]
128
+ if (this.shutdown || this.classifier === null)
129
+ throw new Error("classifier shutdown during operation")
130
+ const classifyPromise = this.classifier(data)
131
+ const timeoutPromise = new Promise((resolve, reject) => setTimeout(() =>
132
+ reject(new Error("classification timeout")), 30 * 1000))
133
+ const result = await Promise.race([ classifyPromise, timeoutPromise ]) as
134
+ Transformers.AudioClassificationOutput | Transformers.AudioClassificationOutput[]
135
+ const classified = Array.isArray(result) ?
136
+ result as Transformers.AudioClassificationOutput :
137
+ [ result ]
108
138
  const c1 = classified.find((c: any) => c.label === "male")
109
139
  const c2 = classified.find((c: any) => c.label === "female")
110
140
  const male = c1 ? c1.score : 0.0
@@ -119,57 +149,65 @@ export default class SpeechFlowNodeGender extends SpeechFlowNode {
119
149
  const frameWindowDuration = 0.5
120
150
  const frameWindowSamples = frameWindowDuration * sampleRateTarget
121
151
  let lastGender = ""
122
- let workingOffTimer: ReturnType<typeof setTimeout> | null = null
123
152
  let workingOff = false
124
153
  const workOffQueue = async () => {
125
154
  /* control working off round */
126
155
  if (workingOff || this.shutdown)
127
156
  return
128
157
  workingOff = true
129
- if (workingOffTimer !== null) {
130
- clearTimeout(workingOffTimer)
131
- workingOffTimer = null
158
+ if (this.workingOffTimer !== null) {
159
+ clearTimeout(this.workingOffTimer)
160
+ this.workingOffTimer = null
132
161
  }
133
162
  this.queue.off("write", workOffQueue)
134
163
 
135
- let pos0 = this.queueAC.position()
136
- const posL = this.queueAC.maxPosition()
137
- const data = new Float32Array(frameWindowSamples)
138
- data.fill(0)
139
- let samples = 0
140
- let pos = pos0
141
- while (pos < posL && samples < frameWindowSamples) {
142
- const element = this.queueAC.peek(pos)
143
- if (element === undefined || element.type !== "audio-frame")
144
- break
145
- if ((samples + element.data.length) < frameWindowSamples) {
146
- data.set(element.data, samples)
147
- samples += element.data.length
148
- }
149
- pos++
150
- }
151
- if (pos0 < pos && samples > frameWindowSamples * 0.75) {
152
- const gender = await classify(data)
153
- const posM = pos0 + Math.trunc((pos - pos0) * 0.25)
154
- while (pos0 < posM && pos0 < posL) {
155
- const element = this.queueAC.peek(pos0)
164
+ try {
165
+ let pos0 = this.queueAC.position()
166
+ const posL = this.queueAC.maxPosition()
167
+ const data = new Float32Array(frameWindowSamples)
168
+ data.fill(0)
169
+ let samples = 0
170
+ let pos = pos0
171
+ while (pos < posL && samples < frameWindowSamples && !this.shutdown) {
172
+ const element = this.queueAC.peek(pos)
156
173
  if (element === undefined || element.type !== "audio-frame")
157
174
  break
158
- element.gender = gender
159
- this.queueAC.touch()
160
- this.queueAC.walk(+1)
161
- pos0++
175
+ if ((samples + element.data.length) < frameWindowSamples) {
176
+ data.set(element.data, samples)
177
+ samples += element.data.length
178
+ }
179
+ pos++
162
180
  }
163
- if (lastGender !== gender) {
164
- log("info", `gender now recognized as <${gender}>`)
165
- lastGender = gender
181
+ if (pos0 < pos && samples > frameWindowSamples * 0.75 && !this.shutdown) {
182
+ const gender = await classify(data)
183
+ if (this.shutdown)
184
+ return
185
+ const posM = pos0 + Math.trunc((pos - pos0) * 0.25)
186
+ while (pos0 < posM && pos0 < posL && !this.shutdown) {
187
+ const element = this.queueAC.peek(pos0)
188
+ if (element === undefined || element.type !== "audio-frame")
189
+ break
190
+ element.gender = gender
191
+ this.queueAC.touch()
192
+ this.queueAC.walk(+1)
193
+ pos0++
194
+ }
195
+ if (lastGender !== gender && !this.shutdown) {
196
+ log("info", `gender now recognized as <${gender}>`)
197
+ lastGender = gender
198
+ }
166
199
  }
167
200
  }
201
+ catch (error) {
202
+ log("error", `gender classification error: ${error}`)
203
+ }
168
204
 
169
205
  /* re-initiate working off round */
170
206
  workingOff = false
171
- workingOffTimer = setTimeout(workOffQueue, 100)
172
- this.queue.once("write", workOffQueue)
207
+ if (!this.shutdown) {
208
+ this.workingOffTimer = setTimeout(workOffQueue, 100)
209
+ this.queue.once("write", workOffQueue)
210
+ }
173
211
  }
174
212
  this.queue.once("write", workOffQueue)
175
213
 
@@ -183,28 +221,41 @@ export default class SpeechFlowNodeGender extends SpeechFlowNode {
183
221
 
184
222
  /* receive audio chunk (writable side of stream) */
185
223
  write (chunk: SpeechFlowChunk, encoding, callback) {
224
+ if (self.shutdown) {
225
+ callback(new Error("stream already destroyed"))
226
+ return
227
+ }
186
228
  if (!Buffer.isBuffer(chunk.payload))
187
229
  callback(new Error("expected audio input as Buffer chunks"))
188
230
  else if (chunk.payload.byteLength === 0)
189
231
  callback()
190
232
  else {
191
- /* convert audio samples from PCM/I16/48KHz to PCM/F32/16KHz */
192
- let data = utils.convertBufToF32(chunk.payload, self.config.audioLittleEndian)
193
- const wav = new WaveFile()
194
- wav.fromScratch(self.config.audioChannels, self.config.audioSampleRate, "32f", data)
195
- wav.toSampleRate(sampleRateTarget, { method: "cubic" })
196
- data = wav.getSamples(false, Float32Array<ArrayBuffer>) as
197
- any as Float32Array<ArrayBuffer>
198
-
199
- /* queue chunk and converted data */
200
- self.queueRecv.append({ type: "audio-frame", chunk, data })
233
+ try {
234
+ /* convert audio samples from PCM/I16/48KHz to PCM/F32/16KHz */
235
+ let data = utils.convertBufToF32(chunk.payload, self.config.audioLittleEndian)
236
+ const wav = new WaveFile()
237
+ wav.fromScratch(self.config.audioChannels, self.config.audioSampleRate, "32f", data)
238
+ wav.toSampleRate(sampleRateTarget, { method: "cubic" })
239
+ data = wav.getSamples(false, Float32Array<ArrayBuffer>) as
240
+ any as Float32Array<ArrayBuffer>
201
241
 
202
- callback()
242
+ /* queue chunk and converted data */
243
+ self.queueRecv.append({ type: "audio-frame", chunk, data })
244
+ callback()
245
+ }
246
+ catch (error) {
247
+ callback(error instanceof Error ? error : new Error("audio processing failed"))
248
+ }
203
249
  }
204
250
  },
205
251
 
206
252
  /* receive no more audio chunks (writable side of stream) */
207
253
  final (callback) {
254
+ if (self.shutdown) {
255
+ callback()
256
+ return
257
+ }
258
+
208
259
  /* signal end of file */
209
260
  self.queueRecv.append({ type: "audio-eof" })
210
261
  callback()
@@ -214,8 +265,10 @@ export default class SpeechFlowNodeGender extends SpeechFlowNode {
214
265
  read (_size) {
215
266
  /* flush pending audio chunks */
216
267
  const flushPendingChunks = () => {
217
- if (self.shutdown)
268
+ if (self.shutdown) {
269
+ this.push(null)
218
270
  return
271
+ }
219
272
  const element = self.queueSend.peek()
220
273
  if (element !== undefined
221
274
  && element.type === "audio-eof")
@@ -224,6 +277,10 @@ export default class SpeechFlowNodeGender extends SpeechFlowNode {
224
277
  && element.type === "audio-frame"
225
278
  && element.gender !== undefined) {
226
279
  while (true) {
280
+ if (self.shutdown) {
281
+ this.push(null)
282
+ return
283
+ }
227
284
  const element = self.queueSend.peek()
228
285
  if (element === undefined)
229
286
  break
@@ -242,7 +299,7 @@ export default class SpeechFlowNodeGender extends SpeechFlowNode {
242
299
  self.queue.trim()
243
300
  }
244
301
  }
245
- else
302
+ else if (!self.shutdown)
246
303
  self.queue.once("write", flushPendingChunks)
247
304
  }
248
305
  flushPendingChunks()
@@ -255,16 +312,43 @@ export default class SpeechFlowNodeGender extends SpeechFlowNode {
255
312
  /* indicate shutdown */
256
313
  this.shutdown = true
257
314
 
315
+ /* cleanup working-off timer */
316
+ if (this.workingOffTimer !== null) {
317
+ clearTimeout(this.workingOffTimer)
318
+ this.workingOffTimer = null
319
+ }
320
+
321
+ /* cleanup progress interval */
322
+ if (this.progressInterval !== null) {
323
+ clearInterval(this.progressInterval)
324
+ this.progressInterval = null
325
+ }
326
+
327
+ /* remove all event listeners */
328
+ this.queue.removeAllListeners("write")
329
+
258
330
  /* close stream */
259
331
  if (this.stream !== null) {
260
332
  this.stream.destroy()
261
333
  this.stream = null
262
334
  }
263
335
 
264
- /* close classifier */
336
+ /* cleanup classifier */
265
337
  if (this.classifier !== null) {
266
- this.classifier.dispose()
338
+ try {
339
+ const disposePromise = this.classifier.dispose()
340
+ const timeoutPromise = new Promise((resolve) => setTimeout(resolve, 5000))
341
+ await Promise.race([ disposePromise, timeoutPromise ])
342
+ }
343
+ catch (error) {
344
+ this.log("warning", `error during classifier cleanup: ${error}`)
345
+ }
267
346
  this.classifier = null
268
347
  }
348
+
349
+ /* cleanup queue pointers */
350
+ this.queue.pointerDelete("recv")
351
+ this.queue.pointerDelete("ac")
352
+ this.queue.pointerDelete("send")
269
353
  }
270
- }
354
+ }
@@ -0,0 +1,217 @@
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 { getLUFS, getRMS, AudioData } from "audio-inspect"
12
+
13
+ /* internal dependencies */
14
+ import SpeechFlowNode, { SpeechFlowChunk } from "./speechflow-node"
15
+ import * as utils from "./speechflow-utils"
16
+
17
+ /* SpeechFlow node for audio metering */
18
+ export default class SpeechFlowNodeMeter extends SpeechFlowNode {
19
+ /* declare official node name */
20
+ public static name = "meter"
21
+
22
+ /* internal state */
23
+ private emitInterval: ReturnType<typeof setInterval> | null = null
24
+ private calcInterval: ReturnType<typeof setInterval> | null = null
25
+ private pendingCalculations = new Set<ReturnType<typeof setTimeout>>()
26
+ private chunkBuffer = new Float32Array(0)
27
+ private destroyed = false
28
+
29
+ /* construct node */
30
+ constructor (id: string, cfg: { [ id: string ]: any }, opts: { [ id: string ]: any }, args: any[]) {
31
+ super(id, cfg, opts, args)
32
+
33
+ /* declare node configuration parameters */
34
+ this.configure({
35
+ interval: { type: "number", pos: 0, val: 250 },
36
+ dashboard: { type: "string", val: "" }
37
+ })
38
+
39
+ /* declare node input/output format */
40
+ this.input = "audio"
41
+ this.output = "audio"
42
+ }
43
+
44
+ /* open node */
45
+ async open () {
46
+ /* sanity check situation */
47
+ if (this.config.audioBitDepth !== 16 || !this.config.audioLittleEndian)
48
+ throw new Error("meter node currently supports PCM-S16LE audio only")
49
+
50
+ /* clear destruction flag */
51
+ this.destroyed = false
52
+
53
+ /* internal state */
54
+ const sampleWindowDuration = 3 /* LUFS-S requires 3s */
55
+ const sampleWindowSize = Math.floor(this.config.audioSampleRate * sampleWindowDuration)
56
+ let sampleWindow = new Float32Array(sampleWindowSize)
57
+ sampleWindow.fill(0, 0, sampleWindowSize)
58
+ let lufss = -60
59
+ let rms = -60
60
+
61
+ /* chunk processing state */
62
+ const chunkDuration = 0.050 /* meter update frequency is about 50ms */
63
+ const samplesPerChunk = Math.floor(this.config.audioSampleRate * chunkDuration)
64
+ this.chunkBuffer = new Float32Array(0)
65
+
66
+ /* define chunk processing function */
67
+ const processChunk = (chunkData: Float32Array) => {
68
+ /* update internal audio sample sliding window */
69
+ const newWindow = new Float32Array(sampleWindowSize)
70
+ const keepSize = sampleWindowSize - chunkData.length
71
+ newWindow.set(sampleWindow.slice(sampleWindow.length - keepSize), 0)
72
+ newWindow.set(chunkData, keepSize)
73
+ sampleWindow = newWindow
74
+
75
+ /* asynchronously calculate the LUFS-S metric */
76
+ const calculator = setTimeout(() => {
77
+ if (this.destroyed)
78
+ return
79
+ try {
80
+ this.pendingCalculations.delete(calculator)
81
+ const audioData = {
82
+ sampleRate: this.config.audioSampleRate,
83
+ numberOfChannels: this.config.audioChannels,
84
+ channelData: [ sampleWindow ],
85
+ duration: sampleWindowDuration,
86
+ length: sampleWindow.length
87
+ } satisfies AudioData
88
+ const lufs = getLUFS(audioData, {
89
+ channelMode: this.config.audioChannels === 1 ? "mono" : "stereo",
90
+ calculateShortTerm: true,
91
+ calculateMomentary: false,
92
+ calculateLoudnessRange: false,
93
+ calculateTruePeak: false
94
+ })
95
+ if (!this.destroyed) {
96
+ if (timer !== null) {
97
+ clearTimeout(timer)
98
+ timer = null
99
+ }
100
+ lufss = lufs.shortTerm ? lufs.shortTerm[0] : 0
101
+ rms = getRMS(audioData, { asDB: true })
102
+ timer = setTimeout(() => {
103
+ lufss = -60
104
+ rms = -60
105
+ }, 500)
106
+ }
107
+ }
108
+ catch (error) {
109
+ if (!this.destroyed)
110
+ this.log("warning", `meter calculation error: ${error}`)
111
+ }
112
+ }, 0)
113
+ this.pendingCalculations.add(calculator)
114
+ }
115
+
116
+ /* setup chunking interval */
117
+ this.calcInterval = setInterval(() => {
118
+ if (this.destroyed)
119
+ return
120
+
121
+ /* process one single 50ms chunk if available */
122
+ if (this.chunkBuffer.length >= samplesPerChunk) {
123
+ const chunkData = this.chunkBuffer.slice(0, samplesPerChunk)
124
+ processChunk(chunkData)
125
+ this.chunkBuffer = this.chunkBuffer.slice(samplesPerChunk)
126
+ }
127
+ }, chunkDuration * 1000)
128
+
129
+ /* setup loudness emitting interval */
130
+ this.emitInterval = setInterval(() => {
131
+ if (this.destroyed)
132
+ return
133
+ this.log("debug", `LUFS-S: ${lufss.toFixed(1)} dB, RMS: ${rms.toFixed(1)} dB`)
134
+ this.sendResponse([ "meter", "LUFS-S", lufss ])
135
+ this.sendResponse([ "meter", "RMS", rms ])
136
+ if (this.params.dashboard !== "")
137
+ this.dashboardInfo("audio", this.params.dashboard, "final", lufss)
138
+ }, this.params.interval)
139
+
140
+ /* provide Duplex stream and internally attach to meter */
141
+ const self = this
142
+ let timer: ReturnType<typeof setTimeout> | null = null
143
+ this.stream = new Stream.Transform({
144
+ writableObjectMode: true,
145
+ readableObjectMode: true,
146
+ decodeStrings: false,
147
+ highWaterMark: 1,
148
+
149
+ /* transform audio chunk */
150
+ transform (chunk: SpeechFlowChunk, encoding, callback) {
151
+ if (self.destroyed) {
152
+ callback(new Error("stream already destroyed"))
153
+ return
154
+ }
155
+ if (!Buffer.isBuffer(chunk.payload))
156
+ callback(new Error("expected audio input as Buffer chunks"))
157
+ else if (chunk.payload.byteLength === 0)
158
+ callback()
159
+ else {
160
+ try {
161
+ /* convert audio samples from PCM/I16 to PCM/F32 */
162
+ const data = utils.convertBufToF32(chunk.payload, self.config.audioLittleEndian)
163
+
164
+ /* append new data to buffer */
165
+ const combinedLength = self.chunkBuffer.length + data.length
166
+ const newBuffer = new Float32Array(combinedLength)
167
+ newBuffer.set(self.chunkBuffer, 0)
168
+ newBuffer.set(data, self.chunkBuffer.length)
169
+ self.chunkBuffer = newBuffer
170
+
171
+ /* pass-through original audio chunk */
172
+ this.push(chunk)
173
+ callback()
174
+ }
175
+ catch (error) {
176
+ callback(error instanceof Error ? error : new Error("Meter processing failed"))
177
+ }
178
+ }
179
+ },
180
+ final (callback) {
181
+ if (self.destroyed) {
182
+ callback()
183
+ return
184
+ }
185
+ this.push(null)
186
+ callback()
187
+ }
188
+ })
189
+ }
190
+
191
+ /* close node */
192
+ async close () {
193
+ /* indicate destruction */
194
+ this.destroyed = true
195
+
196
+ /* clear all pending calculations */
197
+ for (const timeout of this.pendingCalculations)
198
+ clearTimeout(timeout)
199
+ this.pendingCalculations.clear()
200
+
201
+ /* stop intervals */
202
+ if (this.emitInterval !== null) {
203
+ clearInterval(this.emitInterval)
204
+ this.emitInterval = null
205
+ }
206
+ if (this.calcInterval !== null) {
207
+ clearInterval(this.calcInterval)
208
+ this.calcInterval = null
209
+ }
210
+
211
+ /* close stream */
212
+ if (this.stream !== null) {
213
+ this.stream.destroy()
214
+ this.stream = null
215
+ }
216
+ }
217
+ }
@@ -23,6 +23,7 @@ export default class SpeechFlowNodeMute extends SpeechFlowNode {
23
23
 
24
24
  /* internal state */
25
25
  private muteMode: MuteMode = "none"
26
+ private destroyed = false
26
27
 
27
28
  /* construct node */
28
29
  constructor (id: string, cfg: { [ id: string ]: any }, opts: { [ id: string ]: any }, args: any[]) {
@@ -38,25 +39,40 @@ export default class SpeechFlowNodeMute extends SpeechFlowNode {
38
39
 
39
40
  /* receive external request */
40
41
  async receiveRequest (params: any[]) {
41
- if (params.length === 2 && params[0] === "mode") {
42
- if (!params[1].match(/^(?:none|silenced|unplugged)$/))
43
- throw new Error("mute: invalid mode argument in external request")
44
- const muteMode: MuteMode = params[1] as MuteMode
45
- this.setMuteMode(muteMode)
46
- this.sendResponse([ "mute", "mode", muteMode ])
42
+ if (this.destroyed)
43
+ throw new Error("mute: node already destroyed")
44
+ try {
45
+ if (params.length === 2 && params[0] === "mode") {
46
+ if (!params[1].match(/^(?:none|silenced|unplugged)$/))
47
+ throw new Error("mute: invalid mode argument in external request")
48
+ const muteMode: MuteMode = params[1] as MuteMode
49
+ this.setMuteMode(muteMode)
50
+ this.sendResponse([ "mute", "mode", muteMode ])
51
+ }
52
+ else
53
+ throw new Error("mute: invalid arguments in external request")
54
+ }
55
+ catch (error) {
56
+ this.log("error", `receive request error: ${error}`)
57
+ throw error
47
58
  }
48
- else
49
- throw new Error("mute: invalid arguments in external request")
50
59
  }
51
60
 
52
61
  /* change mute mode */
53
62
  setMuteMode (mode: MuteMode) {
63
+ if (this.destroyed) {
64
+ this.log("warning", "attempted to set mute mode on destroyed node")
65
+ return
66
+ }
54
67
  this.log("info", `setting mute mode to "${mode}"`)
55
68
  this.muteMode = mode
56
69
  }
57
70
 
58
71
  /* open node */
59
72
  async open () {
73
+ /* clear destruction flag */
74
+ this.destroyed = false
75
+
60
76
  /* establish a transform stream */
61
77
  const self = this
62
78
  this.stream = new Stream.Transform({
@@ -64,6 +80,10 @@ export default class SpeechFlowNodeMute extends SpeechFlowNode {
64
80
  writableObjectMode: true,
65
81
  decodeStrings: false,
66
82
  transform (chunk: SpeechFlowChunk, encoding, callback) {
83
+ if (self.destroyed) {
84
+ callback(new Error("stream already destroyed"))
85
+ return
86
+ }
67
87
  if (!Buffer.isBuffer(chunk.payload))
68
88
  callback(new Error("invalid chunk payload type"))
69
89
  else if (self.muteMode === "unplugged")
@@ -71,10 +91,11 @@ export default class SpeechFlowNodeMute extends SpeechFlowNode {
71
91
  callback()
72
92
  else if (self.muteMode === "silenced") {
73
93
  /* pass-through a silenced chunk */
74
- chunk = chunk.clone()
75
- chunk.meta.set("muted", true)
76
- const buffer = chunk.payload as Buffer
94
+ const chunkSilenced = chunk.clone()
95
+ chunkSilenced.meta.set("muted", true)
96
+ const buffer = chunkSilenced.payload as Buffer
77
97
  buffer.fill(0)
98
+ this.push(chunkSilenced)
78
99
  callback()
79
100
  }
80
101
  else {
@@ -84,6 +105,10 @@ export default class SpeechFlowNodeMute extends SpeechFlowNode {
84
105
  }
85
106
  },
86
107
  final (callback) {
108
+ if (self.destroyed) {
109
+ callback()
110
+ return
111
+ }
87
112
  this.push(null)
88
113
  callback()
89
114
  }
@@ -92,6 +117,9 @@ export default class SpeechFlowNodeMute extends SpeechFlowNode {
92
117
 
93
118
  /* close node */
94
119
  async close () {
120
+ /* indicate destruction */
121
+ this.destroyed = true
122
+
95
123
  /* close stream */
96
124
  if (this.stream !== null) {
97
125
  this.stream.destroy()