speechflow 1.2.7 → 1.3.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.
- package/CHANGELOG.md +14 -0
- package/README.md +36 -21
- package/dst/speechflow-node-a2a-gender.d.ts +1 -0
- package/dst/speechflow-node-a2a-gender.js +7 -2
- package/dst/speechflow-node-a2a-gender.js.map +1 -1
- package/dst/speechflow-node-a2a-meter.js +1 -1
- package/dst/speechflow-node-a2a-meter.js.map +1 -1
- package/dst/speechflow-node-a2t-deepgram.js +9 -9
- package/dst/speechflow-node-a2t-deepgram.js.map +1 -1
- package/dst/speechflow-node-t2a-elevenlabs.js +0 -1
- package/dst/speechflow-node-t2a-elevenlabs.js.map +1 -1
- package/dst/speechflow-node-t2t-sentence.d.ts +17 -0
- package/dst/speechflow-node-t2t-sentence.js +239 -0
- package/dst/speechflow-node-t2t-sentence.js.map +1 -0
- package/dst/speechflow-node-xio-device.js +10 -2
- package/dst/speechflow-node-xio-device.js.map +1 -1
- package/dst/speechflow-utils.js +1 -1
- package/dst/speechflow-utils.js.map +1 -1
- package/dst/speechflow.d.ts +1 -0
- package/dst/speechflow.js +12 -1
- package/dst/speechflow.js.map +1 -1
- package/etc/speechflow.yaml +23 -21
- package/etc/stx.conf +1 -3
- package/package.json +14 -14
- package/src/speechflow-node-a2a-gender.ts +8 -2
- package/src/speechflow-node-a2a-meter.ts +1 -1
- package/src/speechflow-node-a2t-deepgram.ts +9 -9
- package/src/speechflow-node-t2t-sentence.ts +230 -0
- package/src/speechflow-node-xio-device.ts +10 -2
- package/src/speechflow-utils.ts +1 -1
- package/src/speechflow.ts +13 -1
|
@@ -0,0 +1,230 @@
|
|
|
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 { Duration } from "luxon"
|
|
12
|
+
|
|
13
|
+
/* internal dependencies */
|
|
14
|
+
import SpeechFlowNode, { SpeechFlowChunk } from "./speechflow-node"
|
|
15
|
+
import * as utils from "./speechflow-utils"
|
|
16
|
+
|
|
17
|
+
/* text stream queue element */
|
|
18
|
+
type TextQueueElement = {
|
|
19
|
+
type: "text-frame",
|
|
20
|
+
chunk: SpeechFlowChunk,
|
|
21
|
+
complete?: boolean
|
|
22
|
+
} | {
|
|
23
|
+
type: "text-eof"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/* SpeechFlow node for sentence splitting */
|
|
27
|
+
export default class SpeechFlowNodeSentence extends SpeechFlowNode {
|
|
28
|
+
/* declare official node name */
|
|
29
|
+
public static name = "sentence"
|
|
30
|
+
|
|
31
|
+
/* internal state */
|
|
32
|
+
private static speexInitialized = false
|
|
33
|
+
private queue = new utils.Queue<TextQueueElement>()
|
|
34
|
+
private queueRecv = this.queue.pointerUse("recv")
|
|
35
|
+
private queueSplit = this.queue.pointerUse("split")
|
|
36
|
+
private queueSend = this.queue.pointerUse("send")
|
|
37
|
+
private destroyed = false
|
|
38
|
+
|
|
39
|
+
/* construct node */
|
|
40
|
+
constructor (id: string, cfg: { [ id: string ]: any }, opts: { [ id: string ]: any }, args: any[]) {
|
|
41
|
+
super(id, cfg, opts, args)
|
|
42
|
+
|
|
43
|
+
/* declare node configuration parameters */
|
|
44
|
+
this.configure({})
|
|
45
|
+
|
|
46
|
+
/* declare node input/output format */
|
|
47
|
+
this.input = "text"
|
|
48
|
+
this.output = "text"
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/* open node */
|
|
52
|
+
async open () {
|
|
53
|
+
/* clear destruction flag */
|
|
54
|
+
this.destroyed = false
|
|
55
|
+
|
|
56
|
+
/* pass-through logging */
|
|
57
|
+
const log = (level: string, msg: string) => { this.log(level, msg) }
|
|
58
|
+
|
|
59
|
+
/* work off queued audio frames */
|
|
60
|
+
let workingOffTimer: ReturnType<typeof setTimeout> | null = null
|
|
61
|
+
let workingOff = false
|
|
62
|
+
const workOffQueue = async () => {
|
|
63
|
+
if (this.destroyed)
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
/* control working off round */
|
|
67
|
+
if (workingOff)
|
|
68
|
+
return
|
|
69
|
+
workingOff = true
|
|
70
|
+
if (workingOffTimer !== null) {
|
|
71
|
+
clearTimeout(workingOffTimer)
|
|
72
|
+
workingOffTimer = null
|
|
73
|
+
}
|
|
74
|
+
this.queue.off("write", workOffQueue)
|
|
75
|
+
|
|
76
|
+
const position = this.queueSplit.position()
|
|
77
|
+
const maxPosition = this.queueSplit.maxPosition()
|
|
78
|
+
log("info", `SPLIT: ${maxPosition - position} elements in queue`)
|
|
79
|
+
|
|
80
|
+
while (true) {
|
|
81
|
+
const element = this.queueSplit.peek()
|
|
82
|
+
if (element === undefined)
|
|
83
|
+
break
|
|
84
|
+
if (element.type === "text-eof") {
|
|
85
|
+
this.queueSplit.walk(+1)
|
|
86
|
+
break
|
|
87
|
+
}
|
|
88
|
+
const chunk = element.chunk
|
|
89
|
+
const payload = chunk.payload as string
|
|
90
|
+
const m = payload.match(/^((?:.|\r?\n)+?[.;?!])\s*((?:.|\r?\n)*)$/)
|
|
91
|
+
if (m !== null) {
|
|
92
|
+
/* contains a sentence */
|
|
93
|
+
const [ , sentence, rest ] = m
|
|
94
|
+
if (rest !== "") {
|
|
95
|
+
/* contains more than a sentence */
|
|
96
|
+
const chunk2 = chunk.clone()
|
|
97
|
+
const duration = Duration.fromMillis(
|
|
98
|
+
chunk.timestampEnd.minus(chunk.timestampStart).toMillis() *
|
|
99
|
+
(sentence.length / payload.length))
|
|
100
|
+
chunk2.timestampStart = chunk.timestampStart.plus(duration)
|
|
101
|
+
chunk.timestampEnd = chunk2.timestampStart
|
|
102
|
+
chunk.payload = sentence
|
|
103
|
+
chunk2.payload = rest
|
|
104
|
+
element.complete = true
|
|
105
|
+
this.queueSplit.touch()
|
|
106
|
+
this.queueSplit.walk(+1)
|
|
107
|
+
this.queueSplit.insert({ type: "text-frame", chunk: chunk2 })
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
/* contains just the sentence */
|
|
111
|
+
element.complete = true
|
|
112
|
+
this.queueSplit.touch()
|
|
113
|
+
this.queueSplit.walk(+1)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
/* contains less than a sentence */
|
|
118
|
+
const position = this.queueSplit.position()
|
|
119
|
+
if (position < this.queueSplit.maxPosition() - 1) {
|
|
120
|
+
/* merge into following chunk */
|
|
121
|
+
const element2 = this.queueSplit.peek(position + 1)
|
|
122
|
+
if (element2 === undefined)
|
|
123
|
+
break
|
|
124
|
+
if (element2.type === "text-eof") {
|
|
125
|
+
element.complete = true
|
|
126
|
+
this.queueSplit.touch()
|
|
127
|
+
this.queueSplit.walk(+1)
|
|
128
|
+
break
|
|
129
|
+
}
|
|
130
|
+
element2.chunk.timestampStart = element.chunk.timestampStart
|
|
131
|
+
element2.chunk.payload =
|
|
132
|
+
element.chunk.payload as string + " " +
|
|
133
|
+
element2.chunk.payload as string
|
|
134
|
+
this.queueSplit.delete()
|
|
135
|
+
this.queueSplit.touch()
|
|
136
|
+
}
|
|
137
|
+
else
|
|
138
|
+
break
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* re-initiate working off round */
|
|
143
|
+
workingOff = false
|
|
144
|
+
workingOffTimer = setTimeout(workOffQueue, 100)
|
|
145
|
+
this.queue.once("write", workOffQueue)
|
|
146
|
+
}
|
|
147
|
+
this.queue.once("write", workOffQueue)
|
|
148
|
+
|
|
149
|
+
/* provide Duplex stream and internally attach to classifier */
|
|
150
|
+
const self = this
|
|
151
|
+
this.stream = new Stream.Duplex({
|
|
152
|
+
writableObjectMode: true,
|
|
153
|
+
readableObjectMode: true,
|
|
154
|
+
decodeStrings: false,
|
|
155
|
+
highWaterMark: 1,
|
|
156
|
+
|
|
157
|
+
/* receive text chunk (writable side of stream) */
|
|
158
|
+
write (chunk: SpeechFlowChunk, encoding, callback) {
|
|
159
|
+
if (Buffer.isBuffer(chunk.payload))
|
|
160
|
+
callback(new Error("expected text input as string chunks"))
|
|
161
|
+
else if (chunk.payload.length === 0)
|
|
162
|
+
callback()
|
|
163
|
+
else {
|
|
164
|
+
log("info", `received text: ${JSON.stringify(chunk.payload)}`)
|
|
165
|
+
self.queueRecv.append({ type: "text-frame", chunk })
|
|
166
|
+
callback()
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
/* receive no more text chunks (writable side of stream) */
|
|
171
|
+
final (callback) {
|
|
172
|
+
/* signal end of file */
|
|
173
|
+
self.queueRecv.append({ type: "text-eof" })
|
|
174
|
+
callback()
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
/* send text chunk(s) (readable side of stream) */
|
|
178
|
+
read (_size) {
|
|
179
|
+
/* flush pending audio chunks */
|
|
180
|
+
const position = self.queueSend.position()
|
|
181
|
+
const maxPosition = self.queueSend.maxPosition()
|
|
182
|
+
log("info", `SEND: ${maxPosition - position} elements in queue`)
|
|
183
|
+
const flushPendingChunks = () => {
|
|
184
|
+
const element = self.queueSend.peek()
|
|
185
|
+
if (element !== undefined
|
|
186
|
+
&& element.type === "text-eof") {
|
|
187
|
+
this.push(null)
|
|
188
|
+
self.queueSend.walk(+1)
|
|
189
|
+
}
|
|
190
|
+
else if (element !== undefined
|
|
191
|
+
&& element.type === "text-frame"
|
|
192
|
+
&& element.complete === true) {
|
|
193
|
+
while (true) {
|
|
194
|
+
const element = self.queueSend.peek()
|
|
195
|
+
if (element === undefined)
|
|
196
|
+
break
|
|
197
|
+
else if (element.type === "text-eof") {
|
|
198
|
+
this.push(null)
|
|
199
|
+
self.queueSend.walk(+1)
|
|
200
|
+
break
|
|
201
|
+
}
|
|
202
|
+
else if (element.type === "text-frame"
|
|
203
|
+
&& element.complete !== true)
|
|
204
|
+
break
|
|
205
|
+
log("info", `send text: ${JSON.stringify(element.chunk.payload)}`)
|
|
206
|
+
this.push(element.chunk)
|
|
207
|
+
self.queueSend.walk(+1)
|
|
208
|
+
self.queue.trim()
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
else
|
|
212
|
+
self.queue.once("write", flushPendingChunks)
|
|
213
|
+
}
|
|
214
|
+
flushPendingChunks()
|
|
215
|
+
}
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/* close node */
|
|
220
|
+
async close () {
|
|
221
|
+
/* close stream */
|
|
222
|
+
if (this.stream !== null) {
|
|
223
|
+
this.stream.destroy()
|
|
224
|
+
this.stream = null
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/* indicate destruction */
|
|
228
|
+
this.destroyed = true
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -199,8 +199,16 @@ export default class SpeechFlowNodeDevice extends SpeechFlowNode {
|
|
|
199
199
|
async close () {
|
|
200
200
|
/* shutdown PortAudio */
|
|
201
201
|
if (this.io !== null) {
|
|
202
|
-
|
|
203
|
-
|
|
202
|
+
await new Promise<void>((resolve, reject) => {
|
|
203
|
+
this.io!.abort(() => {
|
|
204
|
+
resolve()
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
await new Promise<void>((resolve, reject) => {
|
|
208
|
+
this.io!.quit(() => {
|
|
209
|
+
resolve()
|
|
210
|
+
})
|
|
211
|
+
})
|
|
204
212
|
this.io = null
|
|
205
213
|
}
|
|
206
214
|
}
|
package/src/speechflow-utils.ts
CHANGED
|
@@ -378,7 +378,7 @@ export class QueuePointer<T extends QueueElement> extends EventEmitter {
|
|
|
378
378
|
this.queue.emit("write", { start: this.index - 1, end: this.index - 1 })
|
|
379
379
|
}
|
|
380
380
|
insert (element: T) {
|
|
381
|
-
this.queue.elements.splice(this.index
|
|
381
|
+
this.queue.elements.splice(this.index, 0, element)
|
|
382
382
|
this.queue.emit("write", { start: this.index - 1, end: this.index })
|
|
383
383
|
}
|
|
384
384
|
delete () {
|
package/src/speechflow.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
1
2
|
/*!
|
|
2
3
|
** SpeechFlow - Speech Processing Flow Graph
|
|
3
4
|
** Copyright (c) 2024-2025 Dr. Ralf S. Engelschall <rse@engelschall.com>
|
|
@@ -243,6 +244,7 @@ type wsPeerInfo = {
|
|
|
243
244
|
"./speechflow-node-t2t-format.js",
|
|
244
245
|
"./speechflow-node-t2t-ollama.js",
|
|
245
246
|
"./speechflow-node-t2t-openai.js",
|
|
247
|
+
"./speechflow-node-t2t-sentence.js",
|
|
246
248
|
"./speechflow-node-t2t-subtitle.js",
|
|
247
249
|
"./speechflow-node-t2t-transformers.js",
|
|
248
250
|
"./speechflow-node-x2x-filter.js",
|
|
@@ -643,12 +645,14 @@ type wsPeerInfo = {
|
|
|
643
645
|
shuttingDown = true
|
|
644
646
|
if (signal === "finished")
|
|
645
647
|
cli!.log("info", "**** streams of all nodes finished -- shutting down service ****")
|
|
648
|
+
else if (signal === "exception")
|
|
649
|
+
cli!.log("warning", "**** exception occurred -- shutting down service ****")
|
|
646
650
|
else
|
|
647
651
|
cli!.log("warning", `**** received signal ${signal} -- shutting down service ****`)
|
|
648
652
|
|
|
649
653
|
/* shutdown HAPI service */
|
|
650
654
|
cli!.log("info", `HAPI: stopping REST/WebSocket network service: http://${args.address}:${args.port}`)
|
|
651
|
-
await hapi.stop()
|
|
655
|
+
await hapi.stop({ timeout: 2000 })
|
|
652
656
|
|
|
653
657
|
/* graph processing: PASS 1: disconnect node streams */
|
|
654
658
|
for (const node of graphNodes) {
|
|
@@ -714,6 +718,14 @@ type wsPeerInfo = {
|
|
|
714
718
|
process.on("SIGUSR1", () => { shutdown("SIGUSR1") })
|
|
715
719
|
process.on("SIGUSR2", () => { shutdown("SIGUSR2") })
|
|
716
720
|
process.on("SIGTERM", () => { shutdown("SIGTERM") })
|
|
721
|
+
process.on("uncaughtException", (err) => {
|
|
722
|
+
cli!.log("error", `uncaught exception: ${err}`)
|
|
723
|
+
shutdown("exception")
|
|
724
|
+
})
|
|
725
|
+
process.on("unhandledRejection", (reason) => {
|
|
726
|
+
cli!.log("error", `unhandled rejection: ${reason}`)
|
|
727
|
+
shutdown("exception")
|
|
728
|
+
})
|
|
717
729
|
})().catch((err: Error) => {
|
|
718
730
|
if (cli !== null)
|
|
719
731
|
cli.log("error", err.message)
|