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.
@@ -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
- this.io.abort()
203
- this.io.quit()
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
  }
@@ -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++, 0, element)
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)