speechflow 1.4.5 → 1.5.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 (166) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +220 -7
  3. package/etc/claude.md +70 -0
  4. package/etc/speechflow.yaml +5 -3
  5. package/etc/stx.conf +7 -0
  6. package/package.json +7 -6
  7. package/speechflow-cli/dst/speechflow-node-a2a-compressor-wt.d.ts +1 -0
  8. package/speechflow-cli/dst/speechflow-node-a2a-compressor-wt.js +155 -0
  9. package/speechflow-cli/dst/speechflow-node-a2a-compressor-wt.js.map +1 -0
  10. package/speechflow-cli/dst/speechflow-node-a2a-compressor.d.ts +15 -0
  11. package/speechflow-cli/dst/speechflow-node-a2a-compressor.js +287 -0
  12. package/speechflow-cli/dst/speechflow-node-a2a-compressor.js.map +1 -0
  13. package/speechflow-cli/dst/speechflow-node-a2a-dynamics-wt.d.ts +1 -0
  14. package/speechflow-cli/dst/speechflow-node-a2a-dynamics-wt.js +208 -0
  15. package/speechflow-cli/dst/speechflow-node-a2a-dynamics-wt.js.map +1 -0
  16. package/speechflow-cli/dst/speechflow-node-a2a-dynamics.d.ts +15 -0
  17. package/speechflow-cli/dst/speechflow-node-a2a-dynamics.js +312 -0
  18. package/speechflow-cli/dst/speechflow-node-a2a-dynamics.js.map +1 -0
  19. package/speechflow-cli/dst/speechflow-node-a2a-expander-wt.d.ts +1 -0
  20. package/speechflow-cli/dst/speechflow-node-a2a-expander-wt.js +161 -0
  21. package/speechflow-cli/dst/speechflow-node-a2a-expander-wt.js.map +1 -0
  22. package/speechflow-cli/dst/speechflow-node-a2a-expander.d.ts +13 -0
  23. package/speechflow-cli/dst/speechflow-node-a2a-expander.js +208 -0
  24. package/speechflow-cli/dst/speechflow-node-a2a-expander.js.map +1 -0
  25. package/speechflow-cli/dst/speechflow-node-a2a-ffmpeg.js +13 -3
  26. package/speechflow-cli/dst/speechflow-node-a2a-ffmpeg.js.map +1 -1
  27. package/speechflow-cli/dst/speechflow-node-a2a-filler.d.ts +14 -0
  28. package/speechflow-cli/dst/speechflow-node-a2a-filler.js +233 -0
  29. package/speechflow-cli/dst/speechflow-node-a2a-filler.js.map +1 -0
  30. package/speechflow-cli/dst/speechflow-node-a2a-gain.d.ts +12 -0
  31. package/speechflow-cli/dst/speechflow-node-a2a-gain.js +125 -0
  32. package/speechflow-cli/dst/speechflow-node-a2a-gain.js.map +1 -0
  33. package/speechflow-cli/dst/speechflow-node-a2a-gender.d.ts +0 -1
  34. package/speechflow-cli/dst/speechflow-node-a2a-gender.js +28 -12
  35. package/speechflow-cli/dst/speechflow-node-a2a-gender.js.map +1 -1
  36. package/speechflow-cli/dst/speechflow-node-a2a-meter.d.ts +1 -0
  37. package/speechflow-cli/dst/speechflow-node-a2a-meter.js +12 -8
  38. package/speechflow-cli/dst/speechflow-node-a2a-meter.js.map +1 -1
  39. package/speechflow-cli/dst/speechflow-node-a2a-mute.js +2 -1
  40. package/speechflow-cli/dst/speechflow-node-a2a-mute.js.map +1 -1
  41. package/speechflow-cli/dst/speechflow-node-a2a-rnnoise-wt.d.ts +1 -0
  42. package/speechflow-cli/dst/speechflow-node-a2a-rnnoise-wt.js +55 -0
  43. package/speechflow-cli/dst/speechflow-node-a2a-rnnoise-wt.js.map +1 -0
  44. package/speechflow-cli/dst/speechflow-node-a2a-rnnoise.d.ts +14 -0
  45. package/speechflow-cli/dst/speechflow-node-a2a-rnnoise.js +184 -0
  46. package/speechflow-cli/dst/speechflow-node-a2a-rnnoise.js.map +1 -0
  47. package/speechflow-cli/dst/speechflow-node-a2a-speex.d.ts +14 -0
  48. package/speechflow-cli/dst/speechflow-node-a2a-speex.js +156 -0
  49. package/speechflow-cli/dst/speechflow-node-a2a-speex.js.map +1 -0
  50. package/speechflow-cli/dst/speechflow-node-a2a-vad.js +3 -3
  51. package/speechflow-cli/dst/speechflow-node-a2a-vad.js.map +1 -1
  52. package/speechflow-cli/dst/speechflow-node-a2a-wav.js +22 -17
  53. package/speechflow-cli/dst/speechflow-node-a2a-wav.js.map +1 -1
  54. package/speechflow-cli/dst/speechflow-node-a2t-awstranscribe.d.ts +18 -0
  55. package/speechflow-cli/dst/speechflow-node-a2t-awstranscribe.js +317 -0
  56. package/speechflow-cli/dst/speechflow-node-a2t-awstranscribe.js.map +1 -0
  57. package/speechflow-cli/dst/speechflow-node-a2t-deepgram.js +15 -13
  58. package/speechflow-cli/dst/speechflow-node-a2t-deepgram.js.map +1 -1
  59. package/speechflow-cli/dst/speechflow-node-a2t-openaitranscribe.d.ts +19 -0
  60. package/speechflow-cli/dst/speechflow-node-a2t-openaitranscribe.js +351 -0
  61. package/speechflow-cli/dst/speechflow-node-a2t-openaitranscribe.js.map +1 -0
  62. package/speechflow-cli/dst/speechflow-node-t2a-awspolly.d.ts +16 -0
  63. package/speechflow-cli/dst/speechflow-node-t2a-awspolly.js +171 -0
  64. package/speechflow-cli/dst/speechflow-node-t2a-awspolly.js.map +1 -0
  65. package/speechflow-cli/dst/speechflow-node-t2a-elevenlabs.js +19 -14
  66. package/speechflow-cli/dst/speechflow-node-t2a-elevenlabs.js.map +1 -1
  67. package/speechflow-cli/dst/speechflow-node-t2a-kokoro.js +11 -6
  68. package/speechflow-cli/dst/speechflow-node-t2a-kokoro.js.map +1 -1
  69. package/speechflow-cli/dst/speechflow-node-t2t-awstranslate.d.ts +13 -0
  70. package/speechflow-cli/dst/speechflow-node-t2t-awstranslate.js +141 -0
  71. package/speechflow-cli/dst/speechflow-node-t2t-awstranslate.js.map +1 -0
  72. package/speechflow-cli/dst/speechflow-node-t2t-deepl.js +13 -15
  73. package/speechflow-cli/dst/speechflow-node-t2t-deepl.js.map +1 -1
  74. package/speechflow-cli/dst/speechflow-node-t2t-format.js +10 -15
  75. package/speechflow-cli/dst/speechflow-node-t2t-format.js.map +1 -1
  76. package/speechflow-cli/dst/speechflow-node-t2t-ollama.js +44 -31
  77. package/speechflow-cli/dst/speechflow-node-t2t-ollama.js.map +1 -1
  78. package/speechflow-cli/dst/speechflow-node-t2t-openai.js +44 -45
  79. package/speechflow-cli/dst/speechflow-node-t2t-openai.js.map +1 -1
  80. package/speechflow-cli/dst/speechflow-node-t2t-sentence.js +8 -8
  81. package/speechflow-cli/dst/speechflow-node-t2t-sentence.js.map +1 -1
  82. package/speechflow-cli/dst/speechflow-node-t2t-subtitle.js +10 -12
  83. package/speechflow-cli/dst/speechflow-node-t2t-subtitle.js.map +1 -1
  84. package/speechflow-cli/dst/speechflow-node-t2t-transformers.js +22 -27
  85. package/speechflow-cli/dst/speechflow-node-t2t-transformers.js.map +1 -1
  86. package/speechflow-cli/dst/speechflow-node-x2x-filter.d.ts +1 -0
  87. package/speechflow-cli/dst/speechflow-node-x2x-filter.js +50 -15
  88. package/speechflow-cli/dst/speechflow-node-x2x-filter.js.map +1 -1
  89. package/speechflow-cli/dst/speechflow-node-x2x-trace.js +17 -18
  90. package/speechflow-cli/dst/speechflow-node-x2x-trace.js.map +1 -1
  91. package/speechflow-cli/dst/speechflow-node-xio-device.js +13 -21
  92. package/speechflow-cli/dst/speechflow-node-xio-device.js.map +1 -1
  93. package/speechflow-cli/dst/speechflow-node-xio-mqtt.d.ts +1 -0
  94. package/speechflow-cli/dst/speechflow-node-xio-mqtt.js +22 -16
  95. package/speechflow-cli/dst/speechflow-node-xio-mqtt.js.map +1 -1
  96. package/speechflow-cli/dst/speechflow-node-xio-websocket.js +19 -19
  97. package/speechflow-cli/dst/speechflow-node-xio-websocket.js.map +1 -1
  98. package/speechflow-cli/dst/speechflow-node.d.ts +6 -3
  99. package/speechflow-cli/dst/speechflow-node.js +13 -2
  100. package/speechflow-cli/dst/speechflow-node.js.map +1 -1
  101. package/speechflow-cli/dst/speechflow-utils-audio-wt.d.ts +1 -0
  102. package/speechflow-cli/dst/speechflow-utils-audio-wt.js +124 -0
  103. package/speechflow-cli/dst/speechflow-utils-audio-wt.js.map +1 -0
  104. package/speechflow-cli/dst/speechflow-utils-audio.d.ts +13 -0
  105. package/speechflow-cli/dst/speechflow-utils-audio.js +137 -0
  106. package/speechflow-cli/dst/speechflow-utils-audio.js.map +1 -0
  107. package/speechflow-cli/dst/speechflow-utils.d.ts +18 -0
  108. package/speechflow-cli/dst/speechflow-utils.js +123 -35
  109. package/speechflow-cli/dst/speechflow-utils.js.map +1 -1
  110. package/speechflow-cli/dst/speechflow.js +69 -14
  111. package/speechflow-cli/dst/speechflow.js.map +1 -1
  112. package/speechflow-cli/etc/oxlint.jsonc +112 -11
  113. package/speechflow-cli/etc/stx.conf +2 -2
  114. package/speechflow-cli/etc/tsconfig.json +1 -1
  115. package/speechflow-cli/package.d/@shiguredo+rnnoise-wasm+2025.1.5.patch +25 -0
  116. package/speechflow-cli/package.json +102 -94
  117. package/speechflow-cli/src/lib.d.ts +24 -0
  118. package/speechflow-cli/src/speechflow-node-a2a-compressor-wt.ts +151 -0
  119. package/speechflow-cli/src/speechflow-node-a2a-compressor.ts +303 -0
  120. package/speechflow-cli/src/speechflow-node-a2a-expander-wt.ts +158 -0
  121. package/speechflow-cli/src/speechflow-node-a2a-expander.ts +212 -0
  122. package/speechflow-cli/src/speechflow-node-a2a-ffmpeg.ts +13 -3
  123. package/speechflow-cli/src/speechflow-node-a2a-filler.ts +223 -0
  124. package/speechflow-cli/src/speechflow-node-a2a-gain.ts +98 -0
  125. package/speechflow-cli/src/speechflow-node-a2a-gender.ts +31 -17
  126. package/speechflow-cli/src/speechflow-node-a2a-meter.ts +13 -9
  127. package/speechflow-cli/src/speechflow-node-a2a-mute.ts +3 -2
  128. package/speechflow-cli/src/speechflow-node-a2a-rnnoise-wt.ts +62 -0
  129. package/speechflow-cli/src/speechflow-node-a2a-rnnoise.ts +164 -0
  130. package/speechflow-cli/src/speechflow-node-a2a-speex.ts +137 -0
  131. package/speechflow-cli/src/speechflow-node-a2a-vad.ts +3 -3
  132. package/speechflow-cli/src/speechflow-node-a2a-wav.ts +20 -13
  133. package/speechflow-cli/src/speechflow-node-a2t-awstranscribe.ts +308 -0
  134. package/speechflow-cli/src/speechflow-node-a2t-deepgram.ts +15 -13
  135. package/speechflow-cli/src/speechflow-node-a2t-openaitranscribe.ts +337 -0
  136. package/speechflow-cli/src/speechflow-node-t2a-awspolly.ts +187 -0
  137. package/speechflow-cli/src/speechflow-node-t2a-elevenlabs.ts +19 -14
  138. package/speechflow-cli/src/speechflow-node-t2a-kokoro.ts +12 -7
  139. package/speechflow-cli/src/speechflow-node-t2t-awstranslate.ts +152 -0
  140. package/speechflow-cli/src/speechflow-node-t2t-deepl.ts +13 -15
  141. package/speechflow-cli/src/speechflow-node-t2t-format.ts +10 -15
  142. package/speechflow-cli/src/speechflow-node-t2t-ollama.ts +55 -42
  143. package/speechflow-cli/src/speechflow-node-t2t-openai.ts +58 -58
  144. package/speechflow-cli/src/speechflow-node-t2t-sentence.ts +10 -10
  145. package/speechflow-cli/src/speechflow-node-t2t-subtitle.ts +15 -16
  146. package/speechflow-cli/src/speechflow-node-t2t-transformers.ts +27 -32
  147. package/speechflow-cli/src/speechflow-node-x2x-filter.ts +20 -16
  148. package/speechflow-cli/src/speechflow-node-x2x-trace.ts +20 -19
  149. package/speechflow-cli/src/speechflow-node-xio-device.ts +15 -23
  150. package/speechflow-cli/src/speechflow-node-xio-mqtt.ts +23 -16
  151. package/speechflow-cli/src/speechflow-node-xio-websocket.ts +19 -19
  152. package/speechflow-cli/src/speechflow-node.ts +21 -8
  153. package/speechflow-cli/src/speechflow-utils-audio-wt.ts +172 -0
  154. package/speechflow-cli/src/speechflow-utils-audio.ts +147 -0
  155. package/speechflow-cli/src/speechflow-utils.ts +125 -32
  156. package/speechflow-cli/src/speechflow.ts +74 -17
  157. package/speechflow-ui-db/dst/index.js +31 -31
  158. package/speechflow-ui-db/etc/eslint.mjs +0 -1
  159. package/speechflow-ui-db/etc/tsc-client.json +3 -3
  160. package/speechflow-ui-db/package.json +11 -10
  161. package/speechflow-ui-db/src/app.vue +20 -6
  162. package/speechflow-ui-st/dst/index.js +26 -26
  163. package/speechflow-ui-st/etc/eslint.mjs +0 -1
  164. package/speechflow-ui-st/etc/tsc-client.json +3 -3
  165. package/speechflow-ui-st/package.json +11 -10
  166. package/speechflow-ui-st/src/app.vue +5 -12
@@ -0,0 +1,303 @@
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
+ import { EventEmitter } from "node:events"
11
+
12
+ /* external dependencies */
13
+ import { GainNode, AudioWorkletNode } from "node-web-audio-api"
14
+
15
+ /* internal dependencies */
16
+ import SpeechFlowNode, { SpeechFlowChunk } from "./speechflow-node"
17
+ import * as utils from "./speechflow-utils"
18
+ import { WebAudio } from "./speechflow-utils-audio"
19
+
20
+ /* internal types */
21
+ interface AudioCompressorConfig {
22
+ thresholdDb?: number
23
+ ratio?: number
24
+ attackMs?: number
25
+ releaseMs?: number
26
+ kneeDb?: number
27
+ makeupDb?: number
28
+ }
29
+
30
+ /* audio compressor class */
31
+ class AudioCompressor extends WebAudio {
32
+ /* internal state */
33
+ private type: "standalone" | "sidechain"
34
+ private mode: "compress" | "measure" | "adjust"
35
+ private config: Required<AudioCompressorConfig>
36
+ private compressorNode: AudioWorkletNode | null = null
37
+ private gainNode: GainNode | null = null
38
+
39
+ /* construct object */
40
+ constructor(
41
+ sampleRate: number,
42
+ channels: number,
43
+ type: "standalone" | "sidechain" = "standalone",
44
+ mode: "compress" | "measure" | "adjust" = "compress",
45
+ config: AudioCompressorConfig = {}
46
+ ) {
47
+ super(sampleRate, channels)
48
+
49
+ /* store type and mode */
50
+ this.type = type
51
+ this.mode = mode
52
+
53
+ /* store configuration */
54
+ this.config = {
55
+ thresholdDb: config.thresholdDb ?? -23,
56
+ ratio: config.ratio ?? 4.0,
57
+ attackMs: config.attackMs ?? 10,
58
+ releaseMs: config.releaseMs ?? 50,
59
+ kneeDb: config.kneeDb ?? 6.0,
60
+ makeupDb: config.makeupDb ?? 0
61
+ }
62
+ }
63
+
64
+ /* setup object */
65
+ public async setup (): Promise<void> {
66
+ await super.setup()
67
+
68
+ /* add audio worklet module */
69
+ const url = path.resolve(__dirname, "speechflow-node-a2a-compressor-wt.js")
70
+ await this.audioContext.audioWorklet.addModule(url)
71
+
72
+ /* determine operation modes */
73
+ const needsCompressor = (this.type === "standalone" && this.mode === "compress") ||
74
+ (this.type === "sidechain" && this.mode === "measure")
75
+ const needsGain = (this.type === "standalone" && this.mode === "compress") ||
76
+ (this.type === "sidechain" && this.mode === "adjust")
77
+
78
+ /* create compressor worklet node */
79
+ if (needsCompressor) {
80
+ this.compressorNode = new AudioWorkletNode(this.audioContext, "compressor", {
81
+ numberOfInputs: 1,
82
+ numberOfOutputs: 1,
83
+ processorOptions: {
84
+ sampleRate: this.audioContext.sampleRate
85
+ }
86
+ })
87
+ }
88
+
89
+ /* create gain node */
90
+ if (needsGain)
91
+ this.gainNode = this.audioContext.createGain()
92
+
93
+ /* connect nodes (according to type and mode) */
94
+ if (this.type === "standalone" && this.mode === "compress") {
95
+ this.sourceNode!.connect(this.compressorNode!)
96
+ this.compressorNode!.connect(this.gainNode!)
97
+ this.gainNode!.connect(this.captureNode!)
98
+ }
99
+ else if (this.type === "sidechain" && this.mode === "measure") {
100
+ this.sourceNode!.connect(this.compressorNode!)
101
+ }
102
+ else if (this.type === "sidechain" && this.mode === "adjust") {
103
+ this.sourceNode!.connect(this.gainNode!)
104
+ this.gainNode!.connect(this.captureNode!)
105
+ }
106
+
107
+ /* configure compressor worklet node */
108
+ const currentTime = this.audioContext.currentTime
109
+ if (needsCompressor) {
110
+ const node = this.compressorNode!
111
+ const params = node.parameters as Map<string, AudioParam>
112
+ params.get("threshold")!.setValueAtTime(this.config.thresholdDb, currentTime)
113
+ params.get("ratio")!.setValueAtTime(this.config.ratio, currentTime)
114
+ params.get("attack")!.setValueAtTime(this.config.attackMs / 1000, currentTime)
115
+ params.get("release")!.setValueAtTime(this.config.releaseMs / 1000, currentTime)
116
+ params.get("knee")!.setValueAtTime(this.config.kneeDb, currentTime)
117
+ params.get("makeup")!.setValueAtTime(this.config.makeupDb, currentTime)
118
+ }
119
+
120
+ /* configure gain node */
121
+ if (needsGain) {
122
+ const gain = Math.pow(10, this.config.makeupDb / 20)
123
+ this.gainNode!.gain.setValueAtTime(gain, currentTime)
124
+ }
125
+ }
126
+
127
+ /* get the current gain reduction */
128
+ public getGainReduction (): number {
129
+ const processor = (this.compressorNode as any)?.port?.processor
130
+ return processor?.reduction ?? 0
131
+ }
132
+
133
+ /* set the current gain */
134
+ public setGain (decibel: number): void {
135
+ const gain = Math.pow(10, decibel / 20)
136
+ this.gainNode?.gain.setTargetAtTime(gain, this.audioContext.currentTime, 0.002)
137
+ }
138
+
139
+ /* destroy the compressor */
140
+ public async destroy (): Promise<void> {
141
+ await super.destroy()
142
+
143
+ /* destroy nodes */
144
+ if (this.compressorNode !== null) {
145
+ this.compressorNode.disconnect()
146
+ this.compressorNode = null
147
+ }
148
+ if (this.gainNode !== null) {
149
+ this.gainNode.disconnect()
150
+ this.gainNode = null
151
+ }
152
+ }
153
+ }
154
+
155
+ /* SpeechFlow node for compression in audio-to-audio passing */
156
+ export default class SpeechFlowNodeCompressor extends SpeechFlowNode {
157
+ /* declare official node name */
158
+ public static name = "compressor"
159
+
160
+ /* internal state */
161
+ private destroyed = false
162
+ private compressor: AudioCompressor | null = null
163
+ private bus: EventEmitter | null = null
164
+ private intervalId: ReturnType<typeof setInterval> | null = null
165
+
166
+ /* construct node */
167
+ constructor (id: string, cfg: { [ id: string ]: any }, opts: { [ id: string ]: any }, args: any[]) {
168
+ super(id, cfg, opts, args)
169
+
170
+ /* declare node configuration parameters */
171
+ this.configure({
172
+ type: { type: "string", val: "standalone", match: /^(?:standalone|sidechain)$/ },
173
+ mode: { type: "string", val: "compress", match: /^(?:compress|measure|adjust)$/ },
174
+ bus: { type: "string", val: "compressor", match: /^.+$/ },
175
+ thresholdDb: { type: "number", val: -23, match: (n: number) => n <= 0 && n >= -100 },
176
+ ratio: { type: "number", val: 4.0, match: (n: number) => n >= 1 && n <= 20 },
177
+ attackMs: { type: "number", val: 10, match: (n: number) => n >= 0 && n <= 1000 },
178
+ releaseMs: { type: "number", val: 50, match: (n: number) => n >= 0 && n <= 1000 },
179
+ kneeDb: { type: "number", val: 6.0, match: (n: number) => n >= 0 && n <= 40 },
180
+ makeupDb: { type: "number", val: 0, match: (n: number) => n >= -24 && n <= 24 }
181
+ })
182
+
183
+ /* sanity check mode and role */
184
+ if (this.params.type === "standalone" && this.params.mode !== "compress")
185
+ throw new Error("type \"standalone\" implies mode \"compress\"")
186
+ if (this.params.type === "sidechain" && this.params.mode === "compress")
187
+ throw new Error("type \"sidechain\" implies mode \"measure\" or \"adjust\"")
188
+
189
+ /* declare node input/output format */
190
+ this.input = "audio"
191
+ this.output = "audio"
192
+ }
193
+
194
+ /* open node */
195
+ async open () {
196
+ /* clear destruction flag */
197
+ this.destroyed = false
198
+
199
+ /* setup compressor */
200
+ this.compressor = new AudioCompressor(
201
+ this.config.audioSampleRate,
202
+ this.config.audioChannels,
203
+ this.params.type,
204
+ this.params.mode, {
205
+ thresholdDb: this.params.thresholdDb,
206
+ ratio: this.params.ratio,
207
+ attackMs: this.params.attackMs,
208
+ releaseMs: this.params.releaseMs,
209
+ kneeDb: this.params.kneeDb,
210
+ makeupDb: this.params.makeupDb
211
+ }
212
+ )
213
+ await this.compressor.setup()
214
+
215
+ /* optionally establish sidechain processing */
216
+ if (this.params.type === "sidechain") {
217
+ this.bus = this.accessBus(this.params.bus)
218
+ if (this.params.mode === "measure") {
219
+ this.intervalId = setInterval(() => {
220
+ const decibel = this.compressor?.getGainReduction()
221
+ this.bus?.emit("sidechain-decibel", decibel)
222
+ }, 10)
223
+ }
224
+ else if (this.params.mode === "adjust") {
225
+ this.bus.on("sidechain-decibel", (decibel: number) => {
226
+ this.compressor?.setGain(decibel)
227
+ })
228
+ }
229
+ }
230
+
231
+ /* establish a transform stream */
232
+ const self = this
233
+ this.stream = new Stream.Transform({
234
+ readableObjectMode: true,
235
+ writableObjectMode: true,
236
+ decodeStrings: false,
237
+ transform (chunk: SpeechFlowChunk & { payload: Buffer }, encoding, callback) {
238
+ if (self.destroyed) {
239
+ callback(new Error("stream already destroyed"))
240
+ return
241
+ }
242
+ if (!Buffer.isBuffer(chunk.payload))
243
+ callback(new Error("invalid chunk payload type"))
244
+ else {
245
+ /* compress chunk */
246
+ const payload = utils.convertBufToI16(chunk.payload)
247
+ self.compressor?.process(payload).then((result) => {
248
+ if (self.destroyed)
249
+ throw new Error("stream already destroyed")
250
+ if ((self.params.type === "standalone" && self.params.mode === "compress") ||
251
+ (self.params.type === "sidechain" && self.params.mode === "adjust") ) {
252
+ /* take over compressed data */
253
+ const payload = utils.convertI16ToBuf(result)
254
+ chunk.payload = payload
255
+ }
256
+ this.push(chunk)
257
+ callback()
258
+ }).catch((error) => {
259
+ callback(new Error(`compression failed: ${error}`))
260
+ })
261
+ }
262
+ },
263
+ final (callback) {
264
+ if (self.destroyed) {
265
+ callback()
266
+ return
267
+ }
268
+ this.push(null)
269
+ callback()
270
+ }
271
+ })
272
+ }
273
+
274
+ /* close node */
275
+ async close () {
276
+ /* indicate destruction */
277
+ this.destroyed = true
278
+
279
+ /* clear interval */
280
+ if (this.intervalId !== null) {
281
+ clearInterval(this.intervalId)
282
+ this.intervalId = null
283
+ }
284
+
285
+ /* destroy bus */
286
+ if (this.bus !== null) {
287
+ this.bus.removeAllListeners()
288
+ this.bus = null
289
+ }
290
+
291
+ /* destroy compressor */
292
+ if (this.compressor !== null) {
293
+ await this.compressor.destroy()
294
+ this.compressor = null
295
+ }
296
+
297
+ /* close stream */
298
+ if (this.stream !== null) {
299
+ this.stream.destroy()
300
+ this.stream = null
301
+ }
302
+ }
303
+ }
@@ -0,0 +1,158 @@
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
+ import * as utils from "./speechflow-utils"
8
+
9
+ /* downward expander with soft knee */
10
+ class ExpanderProcessor extends AudioWorkletProcessor {
11
+ /* internal state */
12
+ private env: number[] = []
13
+ private sampleRate: number
14
+
15
+ /* eslint no-undef: off */
16
+ static get parameterDescriptors(): AudioParamDescriptor[] {
17
+ return [
18
+ { name: "threshold", defaultValue: -45, minValue: -100, maxValue: 0, automationRate: "k-rate" }, // dBFS
19
+ { name: "floor", defaultValue: -64, minValue: -100, maxValue: 0, automationRate: "k-rate" }, // dBFS minimum output level
20
+ { name: "ratio", defaultValue: 4.0, minValue: 1.0, maxValue: 20, automationRate: "k-rate" }, // expansion ratio
21
+ { name: "attack", defaultValue: 0.010, minValue: 0.0, maxValue: 1, automationRate: "k-rate" }, // seconds
22
+ { name: "release", defaultValue: 0.050, minValue: 0.0, maxValue: 1, automationRate: "k-rate" }, // seconds
23
+ { name: "knee", defaultValue: 6.0, minValue: 0.0, maxValue: 40, automationRate: "k-rate" }, // dB
24
+ { name: "makeup", defaultValue: 0.0, minValue: -24, maxValue: 24, automationRate: "k-rate" } // dB
25
+ ]
26
+ }
27
+
28
+ /* class constructor for custom option processing */
29
+ constructor (options: any) {
30
+ super()
31
+ const { sampleRate } = options.processorOptions
32
+ this.sampleRate = sampleRate as number
33
+ }
34
+
35
+ /* determine gain difference */
36
+ private gainDBFor (levelDB: number, thresholdDB: number, ratio: number, kneeDB: number): number {
37
+ /* short-circuit for unreasonable ratio */
38
+ if (ratio <= 1.0)
39
+ return 0
40
+
41
+ /* determine thresholds */
42
+ const halfKnee = kneeDB * 0.5
43
+ const belowKnee = levelDB < (thresholdDB - halfKnee)
44
+ const aboveThr = levelDB >= thresholdDB
45
+
46
+ /* short-circuit for no expansion (above threshold) */
47
+ if (aboveThr)
48
+ return 0
49
+
50
+ /* apply soft-knee */
51
+ if (kneeDB > 0 && !belowKnee) {
52
+ const x = (levelDB - (thresholdDB - halfKnee)) / kneeDB
53
+ const idealGainDB = (thresholdDB + (levelDB - thresholdDB) * ratio) - levelDB
54
+ return idealGainDB * x * x
55
+ }
56
+
57
+ /* determine target level */
58
+ const targetOut = thresholdDB + (levelDB - thresholdDB) / ratio
59
+
60
+ /* return gain difference */
61
+ return targetOut - levelDB
62
+ }
63
+
64
+ /* update envelope (smoothed amplitude contour) for single channel */
65
+ private updateEnvelopeForChannel (
66
+ chan: number,
67
+ samples: Float32Array,
68
+ attack: number,
69
+ release: number
70
+ ): void {
71
+ /* fetch old envelope value */
72
+ if (this.env[chan] === undefined)
73
+ this.env[chan] = 1e-12
74
+ let env = this.env[chan]
75
+
76
+ /* calculate attack/release alpha values */
77
+ const alphaA = Math.exp(-1 / (attack * this.sampleRate))
78
+ const alphaR = Math.exp(-1 / (release * this.sampleRate))
79
+
80
+ /* iterate over all samples and calculate RMS */
81
+ for (const s of samples) {
82
+ const x = Math.abs(s)
83
+ const det = x * x
84
+ if (det > env)
85
+ env = alphaA * env + (1 - alphaA) * det
86
+ else
87
+ env = alphaR * env + (1 - alphaR) * det
88
+ }
89
+ this.env[chan] = Math.sqrt(Math.max(env, 1e-12))
90
+ }
91
+
92
+ /* process a single sample frame */
93
+ process(
94
+ inputs: Float32Array[][],
95
+ outputs: Float32Array[][],
96
+ parameters: Record<string, Float32Array>
97
+ ): boolean {
98
+ /* sanity check */
99
+ const input = inputs[0]
100
+ const output = outputs[0]
101
+ if (!input || input.length === 0 || !output)
102
+ return true
103
+
104
+ /* determine number of channels */
105
+ const nCh = input.length
106
+
107
+ /* reset envelope array if channel count changed */
108
+ if (nCh !== this.env.length)
109
+ this.env = []
110
+
111
+ /* initially just copy input to output (pass-through) */
112
+ for (let c = 0; c < output.length; c++) {
113
+ if (!output[c] || !input[c])
114
+ continue
115
+ output[c].set(input[c])
116
+ }
117
+
118
+ /* fetch parameters */
119
+ const thresholdDB = parameters["threshold"][0]
120
+ const floorDB = parameters["floor"][0]
121
+ const ratio = parameters["ratio"][0]
122
+ const kneeDB = parameters["knee"][0]
123
+ const attackS = Math.max(parameters["attack"][0], 1 / this.sampleRate)
124
+ const releaseS = Math.max(parameters["release"][0], 1 / this.sampleRate)
125
+ const makeupDB = parameters["makeup"][0]
126
+
127
+ /* update envelope per channel */
128
+ for (let ch = 0; ch < nCh; ch++)
129
+ this.updateEnvelopeForChannel(ch, input[ch], attackS, releaseS)
130
+
131
+ /* determine linear value from decibel makeup value */
132
+ const makeUpLin = utils.dB2lin(makeupDB)
133
+
134
+ /* iterate over all channels */
135
+ for (let ch = 0; ch < nCh; ch++) {
136
+ const levelDB = utils.lin2dB(this.env[ch])
137
+ const gainDB = this.gainDBFor(levelDB, thresholdDB, ratio, kneeDB)
138
+ let gainLin = utils.dB2lin(gainDB) * makeUpLin
139
+
140
+ /* do not attenuate below floor */
141
+ const expectedOutLevelDB = levelDB + gainDB + makeupDB
142
+ if (expectedOutLevelDB < floorDB) {
143
+ const neededLiftDB = floorDB - expectedOutLevelDB
144
+ gainLin /= utils.dB2lin(neededLiftDB)
145
+ }
146
+
147
+ /* apply gain change to channel */
148
+ const inp = input[ch]
149
+ const out = output[ch]
150
+ for (let i = 0; i < inp.length; i++)
151
+ out[i] = inp[i] * gainLin
152
+ }
153
+ return true
154
+ }
155
+ }
156
+
157
+ /* register the new audio nodes */
158
+ registerProcessor("expander", ExpanderProcessor)
@@ -0,0 +1,212 @@
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 utils from "./speechflow-utils"
17
+ import { WebAudio } from "./speechflow-utils-audio"
18
+
19
+ /* internal types */
20
+ interface AudioExpanderConfig {
21
+ thresholdDb?: number
22
+ floorDb?: number
23
+ ratio?: number
24
+ attackMs?: number
25
+ releaseMs?: number
26
+ kneeDb?: number
27
+ makeupDb?: number
28
+ }
29
+
30
+ /* audio noise expander class */
31
+ class AudioExpander extends WebAudio {
32
+ /* internal state */
33
+ private config: Required<AudioExpanderConfig>
34
+ private expanderNode: AudioWorkletNode | null = null
35
+
36
+ /* construct object */
37
+ constructor(
38
+ sampleRate: number,
39
+ channels: number,
40
+ config: AudioExpanderConfig = {}
41
+ ) {
42
+ super(sampleRate, channels)
43
+
44
+ /* store configuration */
45
+ this.config = {
46
+ thresholdDb: config.thresholdDb ?? -45,
47
+ floorDb: config.floorDb ?? -64,
48
+ ratio: config.ratio ?? 4.0,
49
+ attackMs: config.attackMs ?? 10,
50
+ releaseMs: config.releaseMs ?? 50,
51
+ kneeDb: config.kneeDb ?? 6.0,
52
+ makeupDb: config.makeupDb ?? 0
53
+ }
54
+ }
55
+
56
+ /* initialize object */
57
+ public async setup (): Promise<void> {
58
+ await super.setup()
59
+
60
+ /* add audio worklet module */
61
+ const url = path.resolve(__dirname, "speechflow-node-a2a-expander-wt.js")
62
+ await this.audioContext.audioWorklet.addModule(url)
63
+
64
+ /* create expander node */
65
+ this.expanderNode = new AudioWorkletNode(this.audioContext, "expander", {
66
+ numberOfInputs: 1,
67
+ numberOfOutputs: 1,
68
+ processorOptions: {
69
+ sampleRate: this.audioContext.sampleRate
70
+ }
71
+ })
72
+
73
+ /* configure expander node */
74
+ const currentTime = this.audioContext.currentTime
75
+ const node = this.expanderNode!
76
+ const params = node.parameters as Map<string, AudioParam>
77
+ params.get("threshold")!.setValueAtTime(this.config.thresholdDb, currentTime)
78
+ params.get("floor")!.setValueAtTime(this.config.floorDb, currentTime)
79
+ params.get("ratio")!.setValueAtTime(this.config.ratio, currentTime)
80
+ params.get("attack")!.setValueAtTime(this.config.attackMs / 1000, currentTime)
81
+ params.get("release")!.setValueAtTime(this.config.releaseMs / 1000, currentTime)
82
+ params.get("knee")!.setValueAtTime(this.config.kneeDb, currentTime)
83
+ params.get("makeup")!.setValueAtTime(this.config.makeupDb, currentTime)
84
+
85
+ /* connect nodes */
86
+ this.sourceNode!.connect(this.expanderNode)
87
+ this.expanderNode.connect(this.captureNode!)
88
+ }
89
+
90
+ public async destroy (): Promise<void> {
91
+ await super.destroy()
92
+
93
+ /* destroy expander node */
94
+ if (this.expanderNode !== null) {
95
+ this.expanderNode.disconnect()
96
+ this.expanderNode = null
97
+ }
98
+ }
99
+ }
100
+
101
+ /* SpeechFlow node for noise expander in audio-to-audio passing */
102
+ export default class SpeechFlowNodeExpander extends SpeechFlowNode {
103
+ /* declare official node name */
104
+ public static name = "expander"
105
+
106
+ /* internal state */
107
+ private destroyed = false
108
+ private expander: AudioExpander | null = null
109
+
110
+ /* construct node */
111
+ constructor (id: string, cfg: { [ id: string ]: any }, opts: { [ id: string ]: any }, args: any[]) {
112
+ super(id, cfg, opts, args)
113
+
114
+ /* declare node configuration parameters */
115
+ this.configure({
116
+ thresholdDb: { type: "number", val: -45, match: (n: number) => n <= 0 && n >= -100 },
117
+ floorDb: { type: "number", val: -64, match: (n: number) => n <= 0 && n >= -100 },
118
+ ratio: { type: "number", val: 4.0, match: (n: number) => n >= 1 && n <= 20 },
119
+ attackMs: { type: "number", val: 10, match: (n: number) => n >= 0 && n <= 1000 },
120
+ releaseMs: { type: "number", val: 50, match: (n: number) => n >= 0 && n <= 1000 },
121
+ kneeDb: { type: "number", val: 6.0, match: (n: number) => n >= 0 && n <= 40 },
122
+ makeupDb: { type: "number", val: 0, match: (n: number) => n >= -24 && n <= 24 }
123
+ })
124
+
125
+ /* sanity check floor vs threshold */
126
+ if (this.params.floorDb >= this.params.thresholdDb)
127
+ throw new Error("floor dB must be less than threshold dB for proper expansion")
128
+
129
+ /* declare node input/output format */
130
+ this.input = "audio"
131
+ this.output = "audio"
132
+ }
133
+
134
+ /* open node */
135
+ async open () {
136
+ /* clear destruction flag */
137
+ this.destroyed = false
138
+
139
+ /* setup expander */
140
+ this.expander = new AudioExpander(
141
+ this.config.audioSampleRate,
142
+ this.config.audioChannels, {
143
+ thresholdDb: this.params.thresholdDb,
144
+ floorDb: this.params.floorDb,
145
+ ratio: this.params.ratio,
146
+ attackMs: this.params.attackMs,
147
+ releaseMs: this.params.releaseMs,
148
+ kneeDb: this.params.kneeDb,
149
+ makeupDb: this.params.makeupDb
150
+ }
151
+ )
152
+ await this.expander.setup()
153
+
154
+ /* establish a transform stream */
155
+ const self = this
156
+ this.stream = new Stream.Transform({
157
+ readableObjectMode: true,
158
+ writableObjectMode: true,
159
+ decodeStrings: false,
160
+ transform (chunk: SpeechFlowChunk & { payload: Buffer }, encoding, callback) {
161
+ if (self.destroyed) {
162
+ callback(new Error("stream already destroyed"))
163
+ return
164
+ }
165
+ if (!Buffer.isBuffer(chunk.payload))
166
+ callback(new Error("invalid chunk payload type"))
167
+ else {
168
+ /* expand chunk */
169
+ const payload = utils.convertBufToI16(chunk.payload)
170
+ self.expander?.process(payload).then((result) => {
171
+ if (self.destroyed)
172
+ throw new Error("stream already destroyed")
173
+
174
+ /* take over expanded data */
175
+ const payload = utils.convertI16ToBuf(result)
176
+ chunk.payload = payload
177
+ this.push(chunk)
178
+ callback()
179
+ }).catch((error) => {
180
+ callback(new Error(`expansion failed: ${error}`))
181
+ })
182
+ }
183
+ },
184
+ final (callback) {
185
+ if (self.destroyed) {
186
+ callback()
187
+ return
188
+ }
189
+ this.push(null)
190
+ callback()
191
+ }
192
+ })
193
+ }
194
+
195
+ /* close node */
196
+ async close () {
197
+ /* indicate destruction */
198
+ this.destroyed = true
199
+
200
+ /* destroy expander */
201
+ if (this.expander !== null) {
202
+ await this.expander.destroy()
203
+ this.expander = null
204
+ }
205
+
206
+ /* close stream */
207
+ if (this.stream !== null) {
208
+ this.stream.destroy()
209
+ this.stream = null
210
+ }
211
+ }
212
+ }