speechflow 1.6.4 → 1.6.6

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 (178) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +28 -3
  3. package/etc/speechflow.yaml +15 -13
  4. package/etc/stx.conf +5 -0
  5. package/package.json +5 -5
  6. package/speechflow-cli/dst/speechflow-main-api.js +3 -7
  7. package/speechflow-cli/dst/speechflow-main-api.js.map +1 -1
  8. package/speechflow-cli/dst/speechflow-main-graph.js +1 -1
  9. package/speechflow-cli/dst/speechflow-main.js +6 -0
  10. package/speechflow-cli/dst/speechflow-main.js.map +1 -1
  11. package/speechflow-cli/dst/speechflow-node-a2a-compressor-wt.js +1 -21
  12. package/speechflow-cli/dst/speechflow-node-a2a-compressor-wt.js.map +1 -1
  13. package/speechflow-cli/dst/speechflow-node-a2a-compressor.d.ts +1 -1
  14. package/speechflow-cli/dst/speechflow-node-a2a-compressor.js +12 -11
  15. package/speechflow-cli/dst/speechflow-node-a2a-compressor.js.map +1 -1
  16. package/speechflow-cli/dst/speechflow-node-a2a-expander-wt.js +1 -21
  17. package/speechflow-cli/dst/speechflow-node-a2a-expander-wt.js.map +1 -1
  18. package/speechflow-cli/dst/speechflow-node-a2a-expander.d.ts +1 -1
  19. package/speechflow-cli/dst/speechflow-node-a2a-expander.js +12 -11
  20. package/speechflow-cli/dst/speechflow-node-a2a-expander.js.map +1 -1
  21. package/speechflow-cli/dst/speechflow-node-a2a-ffmpeg.js +4 -10
  22. package/speechflow-cli/dst/speechflow-node-a2a-ffmpeg.js.map +1 -1
  23. package/speechflow-cli/dst/speechflow-node-a2a-filler.d.ts +1 -1
  24. package/speechflow-cli/dst/speechflow-node-a2a-filler.js +18 -16
  25. package/speechflow-cli/dst/speechflow-node-a2a-filler.js.map +1 -1
  26. package/speechflow-cli/dst/speechflow-node-a2a-gain.d.ts +1 -1
  27. package/speechflow-cli/dst/speechflow-node-a2a-gain.js +8 -8
  28. package/speechflow-cli/dst/speechflow-node-a2a-gain.js.map +1 -1
  29. package/speechflow-cli/dst/speechflow-node-a2a-gender.d.ts +1 -1
  30. package/speechflow-cli/dst/speechflow-node-a2a-gender.js +70 -60
  31. package/speechflow-cli/dst/speechflow-node-a2a-gender.js.map +1 -1
  32. package/speechflow-cli/dst/speechflow-node-a2a-meter.d.ts +1 -1
  33. package/speechflow-cli/dst/speechflow-node-a2a-meter.js +58 -42
  34. package/speechflow-cli/dst/speechflow-node-a2a-meter.js.map +1 -1
  35. package/speechflow-cli/dst/speechflow-node-a2a-mute.d.ts +1 -1
  36. package/speechflow-cli/dst/speechflow-node-a2a-mute.js +44 -10
  37. package/speechflow-cli/dst/speechflow-node-a2a-mute.js.map +1 -1
  38. package/speechflow-cli/dst/speechflow-node-a2a-pitch.d.ts +13 -0
  39. package/speechflow-cli/dst/speechflow-node-a2a-pitch.js +213 -0
  40. package/speechflow-cli/dst/speechflow-node-a2a-pitch.js.map +1 -0
  41. package/speechflow-cli/dst/speechflow-node-a2a-pitch2-wt.js +149 -0
  42. package/speechflow-cli/dst/speechflow-node-a2a-pitch2-wt.js.map +1 -0
  43. package/speechflow-cli/dst/speechflow-node-a2a-pitch2.d.ts +13 -0
  44. package/speechflow-cli/dst/speechflow-node-a2a-pitch2.js +202 -0
  45. package/speechflow-cli/dst/speechflow-node-a2a-pitch2.js.map +1 -0
  46. package/speechflow-cli/dst/speechflow-node-a2a-rnnoise.d.ts +1 -1
  47. package/speechflow-cli/dst/speechflow-node-a2a-rnnoise.js +13 -11
  48. package/speechflow-cli/dst/speechflow-node-a2a-rnnoise.js.map +1 -1
  49. package/speechflow-cli/dst/speechflow-node-a2a-speex.d.ts +1 -1
  50. package/speechflow-cli/dst/speechflow-node-a2a-speex.js +13 -12
  51. package/speechflow-cli/dst/speechflow-node-a2a-speex.js.map +1 -1
  52. package/speechflow-cli/dst/speechflow-node-a2a-vad.d.ts +1 -1
  53. package/speechflow-cli/dst/speechflow-node-a2a-vad.js +26 -25
  54. package/speechflow-cli/dst/speechflow-node-a2a-vad.js.map +1 -1
  55. package/speechflow-cli/dst/speechflow-node-a2a-wav.js +35 -7
  56. package/speechflow-cli/dst/speechflow-node-a2a-wav.js.map +1 -1
  57. package/speechflow-cli/dst/speechflow-node-a2t-amazon.d.ts +1 -1
  58. package/speechflow-cli/dst/speechflow-node-a2t-amazon.js +16 -16
  59. package/speechflow-cli/dst/speechflow-node-a2t-amazon.js.map +1 -1
  60. package/speechflow-cli/dst/speechflow-node-a2t-deepgram.d.ts +1 -1
  61. package/speechflow-cli/dst/speechflow-node-a2t-deepgram.js +16 -16
  62. package/speechflow-cli/dst/speechflow-node-a2t-deepgram.js.map +1 -1
  63. package/speechflow-cli/dst/speechflow-node-a2t-openai.d.ts +1 -2
  64. package/speechflow-cli/dst/speechflow-node-a2t-openai.js +15 -21
  65. package/speechflow-cli/dst/speechflow-node-a2t-openai.js.map +1 -1
  66. package/speechflow-cli/dst/speechflow-node-t2a-amazon.d.ts +1 -2
  67. package/speechflow-cli/dst/speechflow-node-t2a-amazon.js +9 -15
  68. package/speechflow-cli/dst/speechflow-node-t2a-amazon.js.map +1 -1
  69. package/speechflow-cli/dst/speechflow-node-t2a-elevenlabs.d.ts +1 -2
  70. package/speechflow-cli/dst/speechflow-node-t2a-elevenlabs.js +13 -18
  71. package/speechflow-cli/dst/speechflow-node-t2a-elevenlabs.js.map +1 -1
  72. package/speechflow-cli/dst/speechflow-node-t2a-kokoro.d.ts +0 -1
  73. package/speechflow-cli/dst/speechflow-node-t2a-kokoro.js +4 -10
  74. package/speechflow-cli/dst/speechflow-node-t2a-kokoro.js.map +1 -1
  75. package/speechflow-cli/dst/speechflow-node-t2t-amazon.js +3 -3
  76. package/speechflow-cli/dst/speechflow-node-t2t-amazon.js.map +1 -1
  77. package/speechflow-cli/dst/speechflow-node-t2t-deepl.js +2 -2
  78. package/speechflow-cli/dst/speechflow-node-t2t-deepl.js.map +1 -1
  79. package/speechflow-cli/dst/speechflow-node-t2t-format.js +36 -2
  80. package/speechflow-cli/dst/speechflow-node-t2t-format.js.map +1 -1
  81. package/speechflow-cli/dst/speechflow-node-t2t-google.js +2 -2
  82. package/speechflow-cli/dst/speechflow-node-t2t-google.js.map +1 -1
  83. package/speechflow-cli/dst/speechflow-node-t2t-modify.js +5 -5
  84. package/speechflow-cli/dst/speechflow-node-t2t-modify.js.map +1 -1
  85. package/speechflow-cli/dst/speechflow-node-t2t-ollama.js +3 -3
  86. package/speechflow-cli/dst/speechflow-node-t2t-ollama.js.map +1 -1
  87. package/speechflow-cli/dst/speechflow-node-t2t-openai.js +2 -2
  88. package/speechflow-cli/dst/speechflow-node-t2t-openai.js.map +1 -1
  89. package/speechflow-cli/dst/speechflow-node-t2t-sentence.d.ts +1 -1
  90. package/speechflow-cli/dst/speechflow-node-t2t-sentence.js +13 -13
  91. package/speechflow-cli/dst/speechflow-node-t2t-sentence.js.map +1 -1
  92. package/speechflow-cli/dst/speechflow-node-t2t-subtitle.js +8 -8
  93. package/speechflow-cli/dst/speechflow-node-t2t-subtitle.js.map +1 -1
  94. package/speechflow-cli/dst/speechflow-node-t2t-transformers.js +2 -2
  95. package/speechflow-cli/dst/speechflow-node-t2t-transformers.js.map +1 -1
  96. package/speechflow-cli/dst/speechflow-node-x2x-filter.js +2 -2
  97. package/speechflow-cli/dst/speechflow-node-x2x-filter.js.map +1 -1
  98. package/speechflow-cli/dst/speechflow-node-x2x-trace.d.ts +1 -1
  99. package/speechflow-cli/dst/speechflow-node-x2x-trace.js +42 -8
  100. package/speechflow-cli/dst/speechflow-node-x2x-trace.js.map +1 -1
  101. package/speechflow-cli/dst/speechflow-node-xio-device.js +6 -4
  102. package/speechflow-cli/dst/speechflow-node-xio-device.js.map +1 -1
  103. package/speechflow-cli/dst/speechflow-node-xio-file.js +19 -18
  104. package/speechflow-cli/dst/speechflow-node-xio-file.js.map +1 -1
  105. package/speechflow-cli/dst/speechflow-node-xio-mqtt.js +13 -13
  106. package/speechflow-cli/dst/speechflow-node-xio-mqtt.js.map +1 -1
  107. package/speechflow-cli/dst/speechflow-node-xio-websocket.js +8 -8
  108. package/speechflow-cli/dst/speechflow-node-xio-websocket.js.map +1 -1
  109. package/speechflow-cli/dst/speechflow-node.js +6 -6
  110. package/speechflow-cli/dst/speechflow-node.js.map +1 -1
  111. package/speechflow-cli/dst/speechflow-util-audio.d.ts +1 -0
  112. package/speechflow-cli/dst/speechflow-util-audio.js +22 -1
  113. package/speechflow-cli/dst/speechflow-util-audio.js.map +1 -1
  114. package/speechflow-cli/dst/speechflow-util-error.d.ts +1 -1
  115. package/speechflow-cli/dst/speechflow-util-error.js +7 -1
  116. package/speechflow-cli/dst/speechflow-util-error.js.map +1 -1
  117. package/speechflow-cli/dst/speechflow-util-stream.d.ts +2 -1
  118. package/speechflow-cli/dst/speechflow-util-stream.js +23 -3
  119. package/speechflow-cli/dst/speechflow-util-stream.js.map +1 -1
  120. package/speechflow-cli/etc/oxlint.jsonc +2 -1
  121. package/speechflow-cli/etc/tsconfig.json +1 -0
  122. package/speechflow-cli/package.json +20 -20
  123. package/speechflow-cli/src/speechflow-main-api.ts +6 -13
  124. package/speechflow-cli/src/speechflow-main-graph.ts +1 -1
  125. package/speechflow-cli/src/speechflow-main.ts +4 -0
  126. package/speechflow-cli/src/speechflow-node-a2a-compressor-wt.ts +1 -29
  127. package/speechflow-cli/src/speechflow-node-a2a-compressor.ts +13 -12
  128. package/speechflow-cli/src/speechflow-node-a2a-expander-wt.ts +1 -29
  129. package/speechflow-cli/src/speechflow-node-a2a-expander.ts +13 -12
  130. package/speechflow-cli/src/speechflow-node-a2a-ffmpeg.ts +4 -10
  131. package/speechflow-cli/src/speechflow-node-a2a-filler.ts +19 -17
  132. package/speechflow-cli/src/speechflow-node-a2a-gain.ts +8 -8
  133. package/speechflow-cli/src/speechflow-node-a2a-gender.ts +83 -72
  134. package/speechflow-cli/src/speechflow-node-a2a-meter.ts +66 -46
  135. package/speechflow-cli/src/speechflow-node-a2a-mute.ts +11 -10
  136. package/speechflow-cli/src/speechflow-node-a2a-pitch.ts +221 -0
  137. package/speechflow-cli/src/speechflow-node-a2a-rnnoise.ts +14 -12
  138. package/speechflow-cli/src/speechflow-node-a2a-speex.ts +14 -13
  139. package/speechflow-cli/src/speechflow-node-a2a-vad.ts +26 -25
  140. package/speechflow-cli/src/speechflow-node-a2a-wav.ts +2 -7
  141. package/speechflow-cli/src/speechflow-node-a2t-amazon.ts +16 -16
  142. package/speechflow-cli/src/speechflow-node-a2t-deepgram.ts +16 -16
  143. package/speechflow-cli/src/speechflow-node-a2t-openai.ts +15 -21
  144. package/speechflow-cli/src/speechflow-node-t2a-amazon.ts +9 -15
  145. package/speechflow-cli/src/speechflow-node-t2a-elevenlabs.ts +13 -18
  146. package/speechflow-cli/src/speechflow-node-t2a-kokoro.ts +4 -10
  147. package/speechflow-cli/src/speechflow-node-t2t-amazon.ts +3 -3
  148. package/speechflow-cli/src/speechflow-node-t2t-deepl.ts +2 -2
  149. package/speechflow-cli/src/speechflow-node-t2t-format.ts +3 -2
  150. package/speechflow-cli/src/speechflow-node-t2t-google.ts +2 -2
  151. package/speechflow-cli/src/speechflow-node-t2t-modify.ts +6 -6
  152. package/speechflow-cli/src/speechflow-node-t2t-ollama.ts +3 -3
  153. package/speechflow-cli/src/speechflow-node-t2t-openai.ts +2 -2
  154. package/speechflow-cli/src/speechflow-node-t2t-sentence.ts +13 -13
  155. package/speechflow-cli/src/speechflow-node-t2t-subtitle.ts +12 -16
  156. package/speechflow-cli/src/speechflow-node-t2t-transformers.ts +2 -2
  157. package/speechflow-cli/src/speechflow-node-x2x-filter.ts +2 -2
  158. package/speechflow-cli/src/speechflow-node-x2x-trace.ts +10 -9
  159. package/speechflow-cli/src/speechflow-node-xio-device.ts +7 -5
  160. package/speechflow-cli/src/speechflow-node-xio-file.ts +20 -19
  161. package/speechflow-cli/src/speechflow-node-xio-mqtt.ts +14 -14
  162. package/speechflow-cli/src/speechflow-node-xio-websocket.ts +11 -11
  163. package/speechflow-cli/src/speechflow-node.ts +6 -6
  164. package/speechflow-cli/src/speechflow-util-audio.ts +31 -1
  165. package/speechflow-cli/src/speechflow-util-error.ts +9 -3
  166. package/speechflow-cli/src/speechflow-util-stream.ts +31 -6
  167. package/speechflow-ui-db/dst/index.js +25 -25
  168. package/speechflow-ui-db/package.json +11 -11
  169. package/speechflow-ui-db/src/app.vue +14 -5
  170. package/speechflow-ui-st/dst/index.js +460 -25
  171. package/speechflow-ui-st/package.json +13 -13
  172. package/speechflow-ui-st/src/app.vue +8 -3
  173. package/speechflow-cli/dst/speechflow-util-webaudio-wt.js +0 -124
  174. package/speechflow-cli/dst/speechflow-util-webaudio-wt.js.map +0 -1
  175. package/speechflow-cli/dst/speechflow-util-webaudio.d.ts +0 -13
  176. package/speechflow-cli/dst/speechflow-util-webaudio.js +0 -137
  177. package/speechflow-cli/dst/speechflow-util-webaudio.js.map +0 -1
  178. /package/speechflow-cli/dst/{speechflow-util-webaudio-wt.d.ts → speechflow-node-a2a-pitch2-wt.d.ts} +0 -0
@@ -22,9 +22,9 @@ export default class SpeechFlowNodeA2AMeter extends SpeechFlowNode {
22
22
  /* internal state */
23
23
  private emitInterval: ReturnType<typeof setInterval> | null = null
24
24
  private calcInterval: ReturnType<typeof setInterval> | null = null
25
- private silenceTimer: ReturnType<typeof setTimeout> | null = null
25
+ private silenceTimer: ReturnType<typeof setTimeout> | null = null
26
26
  private chunkBuffer = new Float32Array(0)
27
- private destroyed = false
27
+ private closing = false
28
28
 
29
29
  /* construct node */
30
30
  constructor (id: string, cfg: { [ id: string ]: any }, opts: { [ id: string ]: any }, args: any[]) {
@@ -32,7 +32,7 @@ export default class SpeechFlowNodeA2AMeter extends SpeechFlowNode {
32
32
 
33
33
  /* declare node configuration parameters */
34
34
  this.configure({
35
- interval: { type: "number", pos: 0, val: 250 },
35
+ interval: { type: "number", pos: 0, val: 100 },
36
36
  mode: { type: "string", pos: 1, val: "filter", match: /^(?:filter|sink)$/ },
37
37
  dashboard: { type: "string", val: "" }
38
38
  })
@@ -52,74 +52,94 @@ export default class SpeechFlowNodeA2AMeter extends SpeechFlowNode {
52
52
  throw new Error("meter node currently supports PCM-S16LE audio only")
53
53
 
54
54
  /* clear destruction flag */
55
- this.destroyed = false
55
+ this.closing = false
56
56
 
57
57
  /* internal state */
58
- const sampleWindowDuration = 3 /* LUFS-S requires 3s */
58
+ let lufsm = -60
59
+ let rms = -60
60
+
61
+ /* chunk processing state for LUFS-M */
62
+ const sampleWindowDuration = 0.4 /* LUFS-M requires 400ms */
59
63
  const sampleWindowSize = Math.floor(this.config.audioSampleRate * sampleWindowDuration)
60
64
  const sampleWindow = new Float32Array(sampleWindowSize)
61
65
  sampleWindow.fill(0, 0, sampleWindowSize)
62
- let lufss = -60
63
- let rms = -60
64
66
 
65
- /* chunk processing state */
67
+ /* chunk processing state for RMS */
66
68
  const chunkDuration = 0.050 /* meter update frequency is about 50ms */
67
69
  const samplesPerChunk = Math.floor(this.config.audioSampleRate * chunkDuration)
68
70
  this.chunkBuffer = new Float32Array(0)
69
71
 
70
- /* define chunk processing function */
71
- const processChunk = (chunkData: Float32Array) => {
72
- /* update internal audio sample sliding window */
73
- sampleWindow.set(sampleWindow.subarray(chunkData.length), 0)
74
- sampleWindow.set(chunkData, sampleWindowSize - chunkData.length)
72
+ /* setup chunking interval */
73
+ this.calcInterval = setInterval(() => {
74
+ /* short-circuit during destruction */
75
+ if (this.closing)
76
+ return
75
77
 
76
- /* calculate the LUFS-S and RMS metric */
77
- const audioData = {
78
+ /* short-circuit if still not enough chunk data */
79
+ if (this.chunkBuffer.length < samplesPerChunk)
80
+ return
81
+
82
+ /* grab the accumulated chunk data */
83
+ const chunkData = this.chunkBuffer
84
+ this.chunkBuffer = new Float32Array(0)
85
+
86
+ /* update internal audio sample sliding window for LUFS-S */
87
+ if (chunkData.length > sampleWindow.length)
88
+ sampleWindow.set(chunkData.subarray(chunkData.length - sampleWindow.length), 0)
89
+ else {
90
+ sampleWindow.set(sampleWindow.subarray(chunkData.length), 0)
91
+ sampleWindow.set(chunkData, sampleWindow.length - chunkData.length)
92
+ }
93
+
94
+ /* calculate the LUFS-M metric */
95
+ const audioDataLUFS = {
78
96
  sampleRate: this.config.audioSampleRate,
79
97
  numberOfChannels: this.config.audioChannels,
80
98
  channelData: [ sampleWindow ],
81
99
  duration: sampleWindowDuration,
82
100
  length: sampleWindow.length
83
101
  } satisfies AudioData
84
- const lufs = getLUFS(audioData, {
102
+ const lufs = getLUFS(audioDataLUFS, {
85
103
  channelMode: this.config.audioChannels === 1 ? "mono" : "stereo",
86
- calculateShortTerm: true,
87
- calculateMomentary: false,
104
+ calculateShortTerm: false,
105
+ calculateMomentary: true,
88
106
  calculateLoudnessRange: false,
89
107
  calculateTruePeak: false
90
108
  })
91
- lufss = lufs.shortTerm ? lufs.shortTerm[0] : -60
92
- rms = getRMS(audioData, { asDB: true })
109
+ lufsm = lufs.momentary ? Math.max(-60, lufs.momentary[0]) : -60
110
+
111
+ /* calculate the RMS metric */
112
+ const totalSamples = chunkData.length / this.config.audioChannels
113
+ const duration = totalSamples / this.config.audioSampleRate
114
+ const audioDataRMS = {
115
+ sampleRate: this.config.audioSampleRate,
116
+ numberOfChannels: this.config.audioChannels,
117
+ channelData: [ chunkData ],
118
+ duration,
119
+ length: chunkData.length
120
+ } satisfies AudioData
121
+ rms = Math.max(-60, getRMS(audioDataRMS, {
122
+ asDB: true
123
+ }))
124
+
125
+ /* automatically clear measurement (in case no new measurements happen) */
93
126
  if (this.silenceTimer !== null)
94
127
  clearTimeout(this.silenceTimer)
95
128
  this.silenceTimer = setTimeout(() => {
96
- lufss = -60
129
+ lufsm = -60
97
130
  rms = -60
98
131
  }, 500)
99
- }
100
-
101
- /* setup chunking interval */
102
- this.calcInterval = setInterval(() => {
103
- if (this.destroyed)
104
- return
105
-
106
- /* process one single 50ms chunk if available */
107
- if (this.chunkBuffer.length >= samplesPerChunk) {
108
- const chunkData = this.chunkBuffer.slice(0, samplesPerChunk)
109
- this.chunkBuffer = this.chunkBuffer.slice(samplesPerChunk)
110
- processChunk(chunkData)
111
- }
112
132
  }, chunkDuration * 1000)
113
133
 
114
134
  /* setup loudness emitting interval */
115
135
  this.emitInterval = setInterval(() => {
116
- if (this.destroyed)
136
+ if (this.closing)
117
137
  return
118
- this.log("debug", `LUFS-S: ${lufss.toFixed(1)} dB, RMS: ${rms.toFixed(1)} dB`)
119
- this.sendResponse([ "meter", "LUFS-S", lufss ])
138
+ this.log("debug", `LUFS-M: ${lufsm.toFixed(1)} dB, RMS: ${rms.toFixed(1)} dB`)
139
+ this.sendResponse([ "meter", "LUFS-M", lufsm ])
120
140
  this.sendResponse([ "meter", "RMS", rms ])
121
141
  if (this.params.dashboard !== "")
122
- this.sendDashboard("audio", this.params.dashboard, "final", lufss)
142
+ this.sendDashboard("audio", this.params.dashboard, "final", lufsm)
123
143
  }, this.params.interval)
124
144
 
125
145
  /* provide Duplex stream and internally attach to meter */
@@ -132,7 +152,7 @@ export default class SpeechFlowNodeA2AMeter extends SpeechFlowNode {
132
152
 
133
153
  /* transform audio chunk */
134
154
  transform (chunk: SpeechFlowChunk, encoding, callback) {
135
- if (self.destroyed) {
155
+ if (self.closing) {
136
156
  callback(new Error("stream already destroyed"))
137
157
  return
138
158
  }
@@ -158,12 +178,12 @@ export default class SpeechFlowNodeA2AMeter extends SpeechFlowNode {
158
178
  callback()
159
179
  }
160
180
  catch (error) {
161
- callback(error instanceof Error ? error : new Error("meter processing failed"))
181
+ callback(util.ensureError(error, "meter processing failed"))
162
182
  }
163
183
  }
164
184
  },
165
185
  final (callback) {
166
- if (self.destroyed || self.params.mode === "sink") {
186
+ if (self.closing || self.params.mode === "sink") {
167
187
  callback()
168
188
  return
169
189
  }
@@ -175,6 +195,9 @@ export default class SpeechFlowNodeA2AMeter extends SpeechFlowNode {
175
195
 
176
196
  /* close node */
177
197
  async close () {
198
+ /* indicate closing immediately to stop any ongoing operations */
199
+ this.closing = true
200
+
178
201
  /* stop intervals */
179
202
  if (this.emitInterval !== null) {
180
203
  clearInterval(this.emitInterval)
@@ -189,13 +212,10 @@ export default class SpeechFlowNodeA2AMeter extends SpeechFlowNode {
189
212
  this.silenceTimer = null
190
213
  }
191
214
 
192
- /* close stream */
215
+ /* shutdown stream */
193
216
  if (this.stream !== null) {
194
- this.stream.destroy()
217
+ await util.destroyStream(this.stream)
195
218
  this.stream = null
196
219
  }
197
-
198
- /* indicate destruction */
199
- this.destroyed = true
200
220
  }
201
221
  }
@@ -9,6 +9,7 @@ import Stream from "node:stream"
9
9
 
10
10
  /* internal dependencies */
11
11
  import SpeechFlowNode, { SpeechFlowChunk } from "./speechflow-node"
12
+ import * as util from "./speechflow-util"
12
13
 
13
14
  /* the type of muting */
14
15
  type MuteMode =
@@ -23,7 +24,7 @@ export default class SpeechFlowNodeA2AMute extends SpeechFlowNode {
23
24
 
24
25
  /* internal state */
25
26
  private muteMode: MuteMode = "none"
26
- private destroyed = false
27
+ private closing = false
27
28
 
28
29
  /* construct node */
29
30
  constructor (id: string, cfg: { [ id: string ]: any }, opts: { [ id: string ]: any }, args: any[]) {
@@ -39,7 +40,7 @@ export default class SpeechFlowNodeA2AMute extends SpeechFlowNode {
39
40
 
40
41
  /* receive external request */
41
42
  async receiveRequest (params: any[]) {
42
- if (this.destroyed)
43
+ if (this.closing)
43
44
  throw new Error("mute: node already destroyed")
44
45
  try {
45
46
  if (params.length === 2 && params[0] === "mode") {
@@ -61,7 +62,7 @@ export default class SpeechFlowNodeA2AMute extends SpeechFlowNode {
61
62
 
62
63
  /* change mute mode */
63
64
  setMuteMode (mode: MuteMode) {
64
- if (this.destroyed) {
65
+ if (this.closing) {
65
66
  this.log("warning", "attempted to set mute mode on destroyed node")
66
67
  return
67
68
  }
@@ -72,7 +73,7 @@ export default class SpeechFlowNodeA2AMute extends SpeechFlowNode {
72
73
  /* open node */
73
74
  async open () {
74
75
  /* clear destruction flag */
75
- this.destroyed = false
76
+ this.closing = false
76
77
 
77
78
  /* establish a transform stream */
78
79
  const self = this
@@ -81,7 +82,7 @@ export default class SpeechFlowNodeA2AMute extends SpeechFlowNode {
81
82
  writableObjectMode: true,
82
83
  decodeStrings: false,
83
84
  transform (chunk: SpeechFlowChunk, encoding, callback) {
84
- if (self.destroyed) {
85
+ if (self.closing) {
85
86
  callback(new Error("stream already destroyed"))
86
87
  return
87
88
  }
@@ -106,7 +107,7 @@ export default class SpeechFlowNodeA2AMute extends SpeechFlowNode {
106
107
  }
107
108
  },
108
109
  final (callback) {
109
- if (self.destroyed) {
110
+ if (self.closing) {
110
111
  callback()
111
112
  return
112
113
  }
@@ -118,12 +119,12 @@ export default class SpeechFlowNodeA2AMute extends SpeechFlowNode {
118
119
 
119
120
  /* close node */
120
121
  async close () {
121
- /* indicate destruction */
122
- this.destroyed = true
122
+ /* indicate closing */
123
+ this.closing = true
123
124
 
124
- /* close stream */
125
+ /* shutdown stream */
125
126
  if (this.stream !== null) {
126
- this.stream.destroy()
127
+ await util.destroyStream(this.stream)
127
128
  this.stream = null
128
129
  }
129
130
  }
@@ -0,0 +1,221 @@
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 path from "node:path"
9
+ import Stream from "node:stream"
10
+
11
+ /* external dependencies */
12
+ import { AudioWorkletNode } from "node-web-audio-api"
13
+
14
+ /* internal dependencies */
15
+ import SpeechFlowNode, { SpeechFlowChunk } from "./speechflow-node"
16
+ import * as util from "./speechflow-util"
17
+
18
+ /* parameter configuration */
19
+ type AudioPitchShifterConfig = {
20
+ rate?: number
21
+ tempo?: number
22
+ pitch?: number
23
+ semitones?: number
24
+ }
25
+
26
+ /* audio pitch shifter class using SoundTouch WebAudio worklet */
27
+ class AudioPitchShifter extends util.WebAudio {
28
+ /* internal state */
29
+ private pitchNode: AudioWorkletNode | null = null
30
+ private config: Required<AudioPitchShifterConfig>
31
+
32
+ /* construct object */
33
+ constructor (
34
+ sampleRate: number,
35
+ channels: number,
36
+ config: AudioPitchShifterConfig = {}
37
+ ) {
38
+ super(sampleRate, channels)
39
+ this.config = {
40
+ rate: config.rate ?? 1.0,
41
+ tempo: config.tempo ?? 1.0,
42
+ pitch: config.pitch ?? 1.0,
43
+ semitones: config.semitones ?? 0.0
44
+ }
45
+ }
46
+
47
+ /* setup object */
48
+ public async setup (): Promise<void> {
49
+ await super.setup()
50
+
51
+ /* add SoundTouch worklet module */
52
+ const packagePath = path.join(__dirname, "../node_modules/@soundtouchjs/audio-worklet")
53
+ const workletPath = path.join(packagePath, "dist/soundtouch-worklet.js")
54
+ await this.audioContext.audioWorklet.addModule(workletPath)
55
+
56
+ /* create SoundTouch worklet node */
57
+ this.pitchNode = new AudioWorkletNode(this.audioContext, "soundtouch-processor", {
58
+ numberOfInputs: 1,
59
+ numberOfOutputs: 1,
60
+ outputChannelCount: [ this.channels ]
61
+ })
62
+
63
+ /* set initial parameter values */
64
+ const params = this.pitchNode.parameters as Map<string, AudioParam>
65
+ params.get("rate")!.value = this.config.rate
66
+ params.get("tempo")!.value = this.config.tempo
67
+ params.get("pitch")!.value = this.config.pitch
68
+ params.get("pitchSemitones")!.value = this.config.semitones
69
+
70
+ /* connect nodes: source -> pitch -> capture */
71
+ this.sourceNode!.connect(this.pitchNode)
72
+ this.pitchNode.connect(this.captureNode!)
73
+ }
74
+
75
+ /* update an audio parameter value */
76
+ private updateParameter (
77
+ paramName: string,
78
+ value: number,
79
+ configField: keyof Required<AudioPitchShifterConfig>
80
+ ): void {
81
+ const params = this.pitchNode?.parameters as Map<string, AudioParam>
82
+ params?.get(paramName)?.setValueAtTime(value, this.audioContext.currentTime)
83
+ this.config[configField] = value
84
+ }
85
+
86
+ /* update rate value */
87
+ public setRate (rate: number): void {
88
+ this.updateParameter("rate", rate, "rate")
89
+ }
90
+
91
+ /* update tempo value */
92
+ public setTempo (tempo: number): void {
93
+ this.updateParameter("tempo", tempo, "tempo")
94
+ }
95
+
96
+ /* update pitch shift value */
97
+ public setPitch (pitch: number): void {
98
+ this.updateParameter("pitch", pitch, "pitch")
99
+ }
100
+
101
+ /* update pitch semitones setting */
102
+ public setSemitones (semitones: number): void {
103
+ this.updateParameter("pitchSemitones", semitones, "semitones")
104
+ }
105
+
106
+ /* destroy the pitch shifter */
107
+ public async destroy (): Promise<void> {
108
+ /* disconnect pitch node */
109
+ if (this.pitchNode !== null) {
110
+ this.pitchNode.disconnect()
111
+ this.pitchNode = null
112
+ }
113
+
114
+ /* destroy parent */
115
+ await super.destroy()
116
+ }
117
+ }
118
+
119
+ /* SpeechFlow node for pitch adjustment using SoundTouch WebAudio */
120
+ export default class SpeechFlowNodeA2APitch extends SpeechFlowNode {
121
+ /* declare official node name */
122
+ public static name = "a2a-pitch"
123
+
124
+ /* internal state */
125
+ private closing = false
126
+ private pitchShifter: AudioPitchShifter | null = null
127
+
128
+ /* construct node */
129
+ constructor (id: string, cfg: { [ id: string ]: any }, opts: { [ id: string ]: any }, args: any[]) {
130
+ super(id, cfg, opts, args)
131
+
132
+ /* declare node configuration parameters */
133
+ this.configure({
134
+ rate: { type: "number", val: 1.0, match: (n: number) => n >= 0.25 && n <= 4.0 },
135
+ tempo: { type: "number", val: 1.0, match: (n: number) => n >= 0.25 && n <= 4.0 },
136
+ pitch: { type: "number", val: 1.0, match: (n: number) => n >= 0.25 && n <= 4.0 },
137
+ semitones: { type: "number", val: 0.0, match: (n: number) => n >= -24 && n <= 24 }
138
+ })
139
+
140
+ /* declare node input/output format */
141
+ this.input = "audio"
142
+ this.output = "audio"
143
+ }
144
+
145
+ /* open node */
146
+ async open () {
147
+ /* clear destruction flag */
148
+ this.closing = false
149
+
150
+ /* setup pitch shifter */
151
+ this.pitchShifter = new AudioPitchShifter(
152
+ this.config.audioSampleRate,
153
+ this.config.audioChannels, {
154
+ rate: this.params.rate,
155
+ tempo: this.params.tempo,
156
+ pitch: this.params.pitch,
157
+ semitones: this.params.semitones
158
+ }
159
+ )
160
+ await this.pitchShifter.setup()
161
+
162
+ /* establish a transform stream */
163
+ const self = this
164
+ this.stream = new Stream.Transform({
165
+ readableObjectMode: true,
166
+ writableObjectMode: true,
167
+ decodeStrings: false,
168
+ transform (chunk: SpeechFlowChunk & { payload: Buffer }, encoding, callback) {
169
+ if (self.closing) {
170
+ callback(new Error("stream already destroyed"))
171
+ return
172
+ }
173
+ if (!Buffer.isBuffer(chunk.payload))
174
+ callback(new Error("invalid chunk payload type"))
175
+ else {
176
+ /* shift pitch of audio chunk */
177
+ const payload = util.convertBufToI16(chunk.payload, self.config.audioLittleEndian)
178
+ self.pitchShifter?.process(payload).then((result) => {
179
+ if (self.closing)
180
+ throw new Error("stream already destroyed")
181
+
182
+ /* take over pitch-shifted data */
183
+ const payload = util.convertI16ToBuf(result, self.config.audioLittleEndian)
184
+ chunk.payload = payload
185
+ this.push(chunk)
186
+ callback()
187
+ }).catch((error: unknown) => {
188
+ if (!self.closing)
189
+ callback(util.ensureError(error, "pitch shifting failed"))
190
+ })
191
+ }
192
+ },
193
+ final (callback) {
194
+ if (self.closing) {
195
+ callback()
196
+ return
197
+ }
198
+ this.push(null)
199
+ callback()
200
+ }
201
+ })
202
+ }
203
+
204
+ /* close node */
205
+ async close () {
206
+ /* indicate closing */
207
+ this.closing = true
208
+
209
+ /* destroy pitch shifter */
210
+ if (this.pitchShifter !== null) {
211
+ await this.pitchShifter.destroy()
212
+ this.pitchShifter = null
213
+ }
214
+
215
+ /* shutdown stream */
216
+ if (this.stream !== null) {
217
+ await util.destroyStream(this.stream)
218
+ this.stream = null
219
+ }
220
+ }
221
+ }
@@ -19,7 +19,7 @@ export default class SpeechFlowNodeA2ARNNoise extends SpeechFlowNode {
19
19
  public static name = "a2a-rnnoise"
20
20
 
21
21
  /* internal state */
22
- private destroyed = false
22
+ private closing = false
23
23
  private sampleSize = 480 /* = 10ms at 48KHz, as required by RNNoise! */
24
24
  private worker: Worker | null = null
25
25
 
@@ -38,12 +38,13 @@ export default class SpeechFlowNodeA2ARNNoise extends SpeechFlowNode {
38
38
  /* open node */
39
39
  async open () {
40
40
  /* clear destruction flag */
41
- this.destroyed = false
41
+ this.closing = false
42
42
 
43
43
  /* initialize worker */
44
44
  this.worker = new Worker(resolve(__dirname, "speechflow-node-a2a-rnnoise-wt.js"))
45
45
  this.worker.on("error", (err) => {
46
46
  this.log("error", `RNNoise worker thread error: ${err}`)
47
+ this.stream?.emit("error", err)
47
48
  })
48
49
  this.worker.on("exit", (code) => {
49
50
  if (code !== 0)
@@ -88,7 +89,7 @@ export default class SpeechFlowNodeA2ARNNoise extends SpeechFlowNode {
88
89
  /* send message to worker */
89
90
  let seq = 0
90
91
  const workerProcessSegment = async (segment: Int16Array<ArrayBuffer>) => {
91
- if (this.destroyed)
92
+ if (this.closing)
92
93
  return segment
93
94
  const id = `${seq++}`
94
95
  return new Promise<Int16Array<ArrayBuffer>>((resolve) => {
@@ -104,7 +105,7 @@ export default class SpeechFlowNodeA2ARNNoise extends SpeechFlowNode {
104
105
  writableObjectMode: true,
105
106
  decodeStrings: false,
106
107
  transform (chunk: SpeechFlowChunk & { payload: Buffer }, encoding, callback) {
107
- if (self.destroyed) {
108
+ if (self.closing) {
108
109
  callback(new Error("stream already destroyed"))
109
110
  return
110
111
  }
@@ -127,14 +128,15 @@ export default class SpeechFlowNodeA2ARNNoise extends SpeechFlowNode {
127
128
  /* forward updated chunk */
128
129
  this.push(chunk)
129
130
  callback()
130
- }).catch((err: Error) => {
131
- self.log("warning", `processing of chunk failed: ${err}`)
132
- callback(err)
131
+ }).catch((err: unknown) => {
132
+ const error = util.ensureError(err)
133
+ self.log("warning", `processing of chunk failed: ${error.message}`)
134
+ callback(error)
133
135
  })
134
136
  }
135
137
  },
136
138
  final (callback) {
137
- if (self.destroyed) {
139
+ if (self.closing) {
138
140
  callback()
139
141
  return
140
142
  }
@@ -146,8 +148,8 @@ export default class SpeechFlowNodeA2ARNNoise extends SpeechFlowNode {
146
148
 
147
149
  /* close node */
148
150
  async close () {
149
- /* indicate destruction */
150
- this.destroyed = true
151
+ /* indicate closing */
152
+ this.closing = true
151
153
 
152
154
  /* shutdown worker */
153
155
  if (this.worker !== null) {
@@ -155,9 +157,9 @@ export default class SpeechFlowNodeA2ARNNoise extends SpeechFlowNode {
155
157
  this.worker = null
156
158
  }
157
159
 
158
- /* close stream */
160
+ /* shutdown stream */
159
161
  if (this.stream !== null) {
160
- this.stream.destroy()
162
+ await util.destroyStream(this.stream)
161
163
  this.stream = null
162
164
  }
163
165
  }
@@ -22,7 +22,7 @@ export default class SpeechFlowNodeA2ASpeex extends SpeechFlowNode {
22
22
  public static name = "a2a-speex"
23
23
 
24
24
  /* internal state */
25
- private destroyed = false
25
+ private closing = false
26
26
  private sampleSize = 480 /* = 10ms at 48KHz */
27
27
  private speexProcessor: SpeexPreprocessor | null = null
28
28
 
@@ -43,7 +43,7 @@ export default class SpeechFlowNodeA2ASpeex extends SpeechFlowNode {
43
43
  /* open node */
44
44
  async open () {
45
45
  /* clear destruction flag */
46
- this.destroyed = false
46
+ this.closing = false
47
47
 
48
48
  /* validate sample rate compatibility */
49
49
  if (this.config.audioSampleRate !== 48000)
@@ -71,7 +71,7 @@ export default class SpeechFlowNodeA2ASpeex extends SpeechFlowNode {
71
71
  writableObjectMode: true,
72
72
  decodeStrings: false,
73
73
  transform (chunk: SpeechFlowChunk & { payload: Buffer }, encoding, callback) {
74
- if (self.destroyed) {
74
+ if (self.closing) {
75
75
  callback(new Error("stream already destroyed"))
76
76
  return
77
77
  }
@@ -83,12 +83,12 @@ export default class SpeechFlowNodeA2ASpeex extends SpeechFlowNode {
83
83
 
84
84
  /* process Int16Array in necessary fixed-size segments */
85
85
  util.processInt16ArrayInSegments(payload, self.sampleSize, (segment) => {
86
- if (self.destroyed)
86
+ if (self.closing)
87
87
  throw new Error("stream already destroyed")
88
88
  self.speexProcessor?.processInt16(segment)
89
89
  return Promise.resolve(segment)
90
90
  }).then((payload: Int16Array<ArrayBuffer>) => {
91
- if (self.destroyed)
91
+ if (self.closing)
92
92
  throw new Error("stream already destroyed")
93
93
 
94
94
  /* convert Int16Array back into Buffer */
@@ -100,14 +100,15 @@ export default class SpeechFlowNodeA2ASpeex extends SpeechFlowNode {
100
100
  /* forward updated chunk */
101
101
  this.push(chunk)
102
102
  callback()
103
- }).catch((err: Error) => {
104
- self.log("warning", `processing of chunk failed: ${err}`)
105
- callback(err)
103
+ }).catch((err: unknown) => {
104
+ const error = util.ensureError(err)
105
+ self.log("warning", `processing of chunk failed: ${error.message}`)
106
+ callback(error)
106
107
  })
107
108
  }
108
109
  },
109
110
  final (callback) {
110
- if (self.destroyed) {
111
+ if (self.closing) {
111
112
  callback()
112
113
  return
113
114
  }
@@ -119,8 +120,8 @@ export default class SpeechFlowNodeA2ASpeex extends SpeechFlowNode {
119
120
 
120
121
  /* close node */
121
122
  async close () {
122
- /* indicate destruction */
123
- this.destroyed = true
123
+ /* indicate closing */
124
+ this.closing = true
124
125
 
125
126
  /* destroy processor */
126
127
  if (this.speexProcessor !== null) {
@@ -128,9 +129,9 @@ export default class SpeechFlowNodeA2ASpeex extends SpeechFlowNode {
128
129
  this.speexProcessor = null
129
130
  }
130
131
 
131
- /* close stream */
132
+ /* shutdown stream */
132
133
  if (this.stream !== null) {
133
- this.stream.destroy()
134
+ await util.destroyStream(this.stream)
134
135
  this.stream = null
135
136
  }
136
137
  }