speechflow 0.9.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/README.md +239 -0
- package/dst/speechflow-node-deepgram.js +135 -0
- package/dst/speechflow-node-deepl.js +105 -0
- package/dst/speechflow-node-device.js +95 -0
- package/dst/speechflow-node-elevenlabs.js +131 -0
- package/dst/speechflow-node-file.js +47 -0
- package/dst/speechflow-node-websocket.js +147 -0
- package/dst/speechflow-node.js +77 -0
- package/dst/speechflow-util.js +37 -0
- package/dst/speechflow.js +223 -0
- package/etc/biome.jsonc +37 -0
- package/etc/eslint.mjs +95 -0
- package/etc/nps.yaml +40 -0
- package/etc/oxlint.jsonc +20 -0
- package/etc/tsconfig.json +23 -0
- package/package.json +76 -0
- package/sample.yaml +32 -0
- package/src/lib.d.ts +20 -0
- package/src/speechflow-logo.ai +1492 -4
- package/src/speechflow-logo.svg +46 -0
- package/src/speechflow-node-deepgram.ts +102 -0
- package/src/speechflow-node-deepl.ts +76 -0
- package/src/speechflow-node-device.ts +96 -0
- package/src/speechflow-node-elevenlabs.ts +99 -0
- package/src/speechflow-node-file.ts +46 -0
- package/src/speechflow-node-websocket.ts +140 -0
- package/src/speechflow-node.ts +76 -0
- package/src/speechflow-util.ts +36 -0
- package/src/speechflow.ts +242 -0
- package/tsconfig.json +3 -0
|
@@ -0,0 +1,76 @@
|
|
|
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 Events from "node:events"
|
|
8
|
+
import Stream from "node:stream"
|
|
9
|
+
|
|
10
|
+
export default class SpeechFlowNode extends Events.EventEmitter {
|
|
11
|
+
public config = {
|
|
12
|
+
audioChannels: 1, /* audio mono channel */
|
|
13
|
+
audioBitDepth: 16, /* audio PCM 16-bit integer */
|
|
14
|
+
audioLittleEndian: true, /* audio PCM little-endian */
|
|
15
|
+
audioSampleRate: 48000, /* audio 48kHz sample rate */
|
|
16
|
+
textEncoding: "utf8" /* UTF-8 text encoding */
|
|
17
|
+
} as const
|
|
18
|
+
public input = "none"
|
|
19
|
+
public output = "none"
|
|
20
|
+
public params = {} as { [ id: string ]: any }
|
|
21
|
+
public stream: Stream.Writable | Stream.Readable | Stream.Duplex | null = null
|
|
22
|
+
|
|
23
|
+
public connectionsIn = new Set<SpeechFlowNode>()
|
|
24
|
+
public connectionsOut = new Set<SpeechFlowNode>()
|
|
25
|
+
|
|
26
|
+
constructor (
|
|
27
|
+
public id: string,
|
|
28
|
+
private opts: { [ id: string ]: any },
|
|
29
|
+
private args: any[]
|
|
30
|
+
) {
|
|
31
|
+
super()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
configure (spec: { [ id: string ]: { type: string, pos?: number, val?: any, match?: RegExp } }) {
|
|
35
|
+
for (const name of Object.keys(spec)) {
|
|
36
|
+
if (this.opts[name] !== undefined) {
|
|
37
|
+
if (typeof this.opts[name] !== spec[name].type)
|
|
38
|
+
throw new Error(`invalid type of option "${name}"`)
|
|
39
|
+
if ("match" in spec[name] && this.opts[name].match(spec[name].match) === null)
|
|
40
|
+
throw new Error(`invalid value of option "${name}" (has to match ${spec[name].match})`)
|
|
41
|
+
this.params[name] = this.opts[name]
|
|
42
|
+
}
|
|
43
|
+
else if (this.opts[name] === undefined
|
|
44
|
+
&& "pos" in spec[name]
|
|
45
|
+
&& spec[name].pos! < this.args.length) {
|
|
46
|
+
if (typeof this.args[spec[name].pos!] !== spec[name].type)
|
|
47
|
+
throw new Error(`invalid type of argument "${name}"`)
|
|
48
|
+
if ("match" in spec[name] && this.args[spec[name].pos!].match(spec[name].match) === null)
|
|
49
|
+
throw new Error(`invalid value of option "${name}" (has to match ${spec[name].match})`)
|
|
50
|
+
this.params[name] = this.args[spec[name].pos!]
|
|
51
|
+
}
|
|
52
|
+
else if ("val" in spec[name] && spec[name].val !== undefined)
|
|
53
|
+
this.params[name] = spec[name].val
|
|
54
|
+
else
|
|
55
|
+
throw new Error(`required parameter "${name}" not given`)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
connect (other: SpeechFlowNode) {
|
|
59
|
+
this.connectionsOut.add(other)
|
|
60
|
+
other.connectionsIn.add(this)
|
|
61
|
+
}
|
|
62
|
+
disconnect (other: SpeechFlowNode) {
|
|
63
|
+
if (!this.connectionsOut.has(other))
|
|
64
|
+
throw new Error("invalid node: not connected to this node")
|
|
65
|
+
this.connectionsOut.delete(other)
|
|
66
|
+
other.connectionsIn.delete(this)
|
|
67
|
+
}
|
|
68
|
+
log (level: string, msg: string, data?: any) {
|
|
69
|
+
this.emit("log", level, msg, data)
|
|
70
|
+
}
|
|
71
|
+
async open () {
|
|
72
|
+
}
|
|
73
|
+
async close () {
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
@@ -0,0 +1,36 @@
|
|
|
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 PortAudio from "@gpeng/naudiodon"
|
|
8
|
+
|
|
9
|
+
export default class SpeechFlowUtil {
|
|
10
|
+
static audioDeviceFromURL (mode: "any" | "r" | "w" | "rw", url: string) {
|
|
11
|
+
const m = url.match(/^(.+?):(.+)$/)
|
|
12
|
+
if (m === null)
|
|
13
|
+
throw new Error(`invalid audio device URL "${url}"`)
|
|
14
|
+
const [ , type, name ] = m
|
|
15
|
+
const apis = PortAudio.getHostAPIs()
|
|
16
|
+
const api = apis.HostAPIs.find((api) => api.type.toLowerCase() === type.toLowerCase())
|
|
17
|
+
if (!api)
|
|
18
|
+
throw new Error(`invalid audio device type "${type}"`)
|
|
19
|
+
const devices = PortAudio.getDevices()
|
|
20
|
+
console.log(devices)
|
|
21
|
+
const device = devices.find((device) => {
|
|
22
|
+
return (
|
|
23
|
+
( ( mode === "r" && device.maxInputChannels > 0)
|
|
24
|
+
|| (mode === "w" && device.maxOutputChannels > 0)
|
|
25
|
+
|| (mode === "rw" && device.maxInputChannels > 0 && device.maxOutputChannels > 0)
|
|
26
|
+
|| (mode === "any" && (device.maxInputChannels > 0 || device.maxOutputChannels > 0)))
|
|
27
|
+
&& device.name.match(name)
|
|
28
|
+
&& device.hostAPIName === api.name
|
|
29
|
+
)
|
|
30
|
+
})
|
|
31
|
+
if (!device)
|
|
32
|
+
throw new Error(`invalid audio device name "${name}" (of audio type "${type}")`)
|
|
33
|
+
return device
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
@@ -0,0 +1,242 @@
|
|
|
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 Stream from "node:stream"
|
|
8
|
+
|
|
9
|
+
import CLIio from "cli-io"
|
|
10
|
+
import yargs from "yargs"
|
|
11
|
+
import jsYAML from "js-yaml"
|
|
12
|
+
import FlowLink from "flowlink"
|
|
13
|
+
import objectPath from "object-path"
|
|
14
|
+
|
|
15
|
+
import SpeechFlowNode from "./speechflow-node"
|
|
16
|
+
import SpeechFlowNodeFile from "./speechflow-node-file"
|
|
17
|
+
import SpeechFlowNodeDevice from "./speechflow-node-device"
|
|
18
|
+
import SpeechFlowNodeWebsocket from "./speechflow-node-websocket"
|
|
19
|
+
import SpeechFlowNodeDeepgram from "./speechflow-node-deepgram"
|
|
20
|
+
import SpeechFlowNodeDeepL from "./speechflow-node-deepl"
|
|
21
|
+
import SpeechFlowNodeElevenLabs from "./speechflow-node-elevenlabs"
|
|
22
|
+
|
|
23
|
+
import pkg from "../package.json"
|
|
24
|
+
|
|
25
|
+
let cli: CLIio | null = null
|
|
26
|
+
;(async () => {
|
|
27
|
+
/* parse command-line arguments */
|
|
28
|
+
const args = await yargs()
|
|
29
|
+
/* eslint @stylistic/indent: off */
|
|
30
|
+
.usage(
|
|
31
|
+
"Usage: $0 " +
|
|
32
|
+
"[-h|--help] " +
|
|
33
|
+
"[-V|--version] " +
|
|
34
|
+
"[-v|--verbose <level>] " +
|
|
35
|
+
"[-e|--expression <expression>] " +
|
|
36
|
+
"[-f|--expression-file <expression-file>] " +
|
|
37
|
+
"[-c|--config <key>@<yaml-config-file>] " +
|
|
38
|
+
"[<argument> [...]]"
|
|
39
|
+
)
|
|
40
|
+
.help("h").alias("h", "help").default("h", false)
|
|
41
|
+
.describe("h", "show usage help")
|
|
42
|
+
.boolean("V").alias("V", "version").default("V", false)
|
|
43
|
+
.describe("V", "show program version information")
|
|
44
|
+
.string("v").nargs("v", 1).alias("v", "log-level").default("v", "warning")
|
|
45
|
+
.describe("v", "level for verbose logging ('none', 'error', 'warning', 'info', 'debug')")
|
|
46
|
+
.string("e").nargs("e", 1).alias("e", "expression").default("e", "")
|
|
47
|
+
.describe("e", "FlowLink expression")
|
|
48
|
+
.string("f").nargs("f", 1).alias("f", "expression-file").default("f", "")
|
|
49
|
+
.describe("f", "FlowLink expression file")
|
|
50
|
+
.string("c").nargs("c", 1).alias("c", "config-file").default("c", "")
|
|
51
|
+
.describe("c", "configuration in format <id>@<file>")
|
|
52
|
+
.version(false)
|
|
53
|
+
.strict()
|
|
54
|
+
.showHelpOnFail(true)
|
|
55
|
+
.demand(0)
|
|
56
|
+
.parse(process.argv.slice(2))
|
|
57
|
+
|
|
58
|
+
/* short-circuit version request */
|
|
59
|
+
if (args.version) {
|
|
60
|
+
process.stderr.write(`${pkg.name} ${pkg.version} <${pkg.homepage}>\n`)
|
|
61
|
+
process.stderr.write(`${pkg.description}\n`)
|
|
62
|
+
process.stderr.write(`Copyright (c) 2024-2025 ${pkg.author.name} <${pkg.author.url}>\n`)
|
|
63
|
+
process.stderr.write(`Licensed under ${pkg.license} <http://spdx.org/licenses/${pkg.license}.html>\n`)
|
|
64
|
+
process.exit(0)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* establish CLI environment */
|
|
68
|
+
cli = new CLIio({
|
|
69
|
+
encoding: "utf8",
|
|
70
|
+
logLevel: args.logLevel,
|
|
71
|
+
logTime: true,
|
|
72
|
+
logPrefix: pkg.name
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
/* handle uncaught exceptions */
|
|
76
|
+
process.on("uncaughtException", async (err: Error) => {
|
|
77
|
+
cli!.log("warning", `process crashed with a fatal error: ${err} ${err.stack}`)
|
|
78
|
+
process.exit(1)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
/* handle unhandled promise rejections */
|
|
82
|
+
process.on("unhandledRejection", async (reason, promise) => {
|
|
83
|
+
if (reason instanceof Error)
|
|
84
|
+
cli!.log("error", `promise rejection not handled: ${reason.message}: ${reason.stack}`)
|
|
85
|
+
else
|
|
86
|
+
cli!.log("error", `promise rejection not handled: ${reason}`)
|
|
87
|
+
process.exit(1)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
/* sanity check usage */
|
|
91
|
+
let n = 0
|
|
92
|
+
if (typeof args.expression === "string" && args.expression !== "") n++
|
|
93
|
+
if (typeof args.expressionFile === "string" && args.expressionFile !== "") n++
|
|
94
|
+
if (typeof args.configFile === "string" && args.configFile !== "") n++
|
|
95
|
+
if (n !== 1)
|
|
96
|
+
throw new Error("cannot use more than one FlowLink specification source (either option -e, -f or -c)")
|
|
97
|
+
|
|
98
|
+
/* read configuration */
|
|
99
|
+
let config = ""
|
|
100
|
+
if (typeof args.expression === "string" && args.expression !== "")
|
|
101
|
+
config = args.expression
|
|
102
|
+
else if (typeof args.expressionFile === "string" && args.expressionFile !== "")
|
|
103
|
+
config = await cli.input(args.expressionFile, { encoding: "utf8" })
|
|
104
|
+
else if (typeof args.configFile === "string" && args.configFile !== "") {
|
|
105
|
+
const m = args.configFile.match(/^(.+?)@(.+)$/)
|
|
106
|
+
if (m === null)
|
|
107
|
+
throw new Error("invalid configuration file specification (expected \"<key>@<yaml-config-file>\")")
|
|
108
|
+
const [ , key, file ] = m
|
|
109
|
+
const yaml = await cli.input(file, { encoding: "utf8" })
|
|
110
|
+
const obj: any = jsYAML.load(yaml)
|
|
111
|
+
if (obj[key] === undefined)
|
|
112
|
+
throw new Error(`no such key "${key}" found in configuration file`)
|
|
113
|
+
config = obj[key] as string
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/* configuration of nodes */
|
|
117
|
+
const nodes: { [ id: string ]: typeof SpeechFlowNode } = {
|
|
118
|
+
"file": SpeechFlowNodeFile,
|
|
119
|
+
"device": SpeechFlowNodeDevice,
|
|
120
|
+
"websocket": SpeechFlowNodeWebsocket,
|
|
121
|
+
"deepgram": SpeechFlowNodeDeepgram,
|
|
122
|
+
"deepl": SpeechFlowNodeDeepL,
|
|
123
|
+
"elevenlabs": SpeechFlowNodeElevenLabs
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/* parse configuration into node graph */
|
|
127
|
+
const flowlink = new FlowLink<SpeechFlowNode>({
|
|
128
|
+
trace: (msg: string) => {
|
|
129
|
+
cli!.log("debug", msg)
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
let nodenum = 1
|
|
133
|
+
const variables = { argv: args._, env: process.env }
|
|
134
|
+
const graphNodes = new Set<SpeechFlowNode>()
|
|
135
|
+
flowlink.evaluate(config, {
|
|
136
|
+
resolveVariable (id: string) {
|
|
137
|
+
if (!objectPath.has(variables, id))
|
|
138
|
+
throw new Error(`failed to resolve variable "${id}"`)
|
|
139
|
+
const value = objectPath.get(variables, id)
|
|
140
|
+
cli!.log("info", `resolve variable: "${id}" -> "${value}"`)
|
|
141
|
+
return value
|
|
142
|
+
},
|
|
143
|
+
createNode (id: string, opts: { [ id: string ]: any }, args: any[]) {
|
|
144
|
+
if (nodes[id] === undefined)
|
|
145
|
+
throw new Error(`unknown SpeechFlow node "${id}"`)
|
|
146
|
+
const node = new nodes[id](`${id}[${nodenum++}]`, opts, args)
|
|
147
|
+
graphNodes.add(node)
|
|
148
|
+
const params = Object.keys(node.params)
|
|
149
|
+
.map((key) => `${key}: ${JSON.stringify(node.params[key])}`).join(", ")
|
|
150
|
+
cli!.log("info", `created SpeechFlow node "${node.id}" (${params})`)
|
|
151
|
+
return node
|
|
152
|
+
},
|
|
153
|
+
connectNode (node1: SpeechFlowNode, node2: SpeechFlowNode) {
|
|
154
|
+
cli!.log("info", `connect SpeechFlow node "${node1.id}" to node "${node2.id}"`)
|
|
155
|
+
node1.connect(node2)
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
/* graph processing: PASS 1: activate and sanity check nodes */
|
|
160
|
+
for (const node of graphNodes) {
|
|
161
|
+
/* connect node events */
|
|
162
|
+
node.on("log", (level: string, msg: string, data?: any) => {
|
|
163
|
+
let str = `[${node.id}]: ${msg}`
|
|
164
|
+
if (data !== undefined)
|
|
165
|
+
str += ` (${JSON.stringify(data)})`
|
|
166
|
+
cli!.log(level, str)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
/* open node */
|
|
170
|
+
cli!.log("info", `opening node "${node.id}"`)
|
|
171
|
+
await node.open().catch((err: Error) => {
|
|
172
|
+
cli!.log("error", `[${node.id}]: ${err.message}`)
|
|
173
|
+
throw new Error(`failed to open node "${node.id}"`)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
/* determine connections */
|
|
177
|
+
const connectionsIn = Array.from(node.connectionsIn)
|
|
178
|
+
const connectionsOut = Array.from(node.connectionsOut)
|
|
179
|
+
|
|
180
|
+
/* ensure necessary incoming links */
|
|
181
|
+
if (node.input !== "none" && connectionsIn.length === 0)
|
|
182
|
+
throw new Error(`node "${node.id}" requires input but has no input nodes connected`)
|
|
183
|
+
|
|
184
|
+
/* prune unnecessary incoming links */
|
|
185
|
+
if (node.input === "none" && connectionsIn.length > 0)
|
|
186
|
+
connectionsIn.forEach((other) => { other.disconnect(node) })
|
|
187
|
+
|
|
188
|
+
/* ensure necessary outgoing links */
|
|
189
|
+
if (node.output !== "none" && connectionsOut.length === 0)
|
|
190
|
+
throw new Error(`node "${node.id}" requires output but has no output nodes connected`)
|
|
191
|
+
|
|
192
|
+
/* prune unnecessary outgoing links */
|
|
193
|
+
if (node.output === "none" && connectionsOut.length > 0)
|
|
194
|
+
connectionsOut.forEach((other) => { node.disconnect(other) })
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/* graph processing: PASS 2: activate streams */
|
|
198
|
+
for (const node of graphNodes) {
|
|
199
|
+
if (node.stream === null)
|
|
200
|
+
throw new Error(`stream of outgoing node "${node.id}" still not initialized`)
|
|
201
|
+
for (const other of Array.from(node.connectionsOut)) {
|
|
202
|
+
if (other.stream === null)
|
|
203
|
+
throw new Error(`stream of incoming node "${other.id}" still not initialized`)
|
|
204
|
+
if (node.output !== other.input)
|
|
205
|
+
throw new Error(`${node.output} output node "${node.id}" cannot be " +
|
|
206
|
+
"connected to ${other.input} input node "${other.id}" (payload is incompatible)`)
|
|
207
|
+
cli!.log("info", `connecting stream of node "${node.id}" to stream of node "${other.id}"`)
|
|
208
|
+
node.stream.pipe(other.stream as Stream.Writable)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/* gracefully shutdown process */
|
|
213
|
+
let shuttingDown = false
|
|
214
|
+
const shutdown = async (signal: string) => {
|
|
215
|
+
if (shuttingDown)
|
|
216
|
+
return
|
|
217
|
+
shuttingDown = true
|
|
218
|
+
cli!.log("warning", `received signal ${signal} -- shutting down service`)
|
|
219
|
+
for (const node of graphNodes) {
|
|
220
|
+
cli!.log("info", `closing node "${node.id}"`)
|
|
221
|
+
const connectionsIn = Array.from(node.connectionsIn)
|
|
222
|
+
const connectionsOut = Array.from(node.connectionsOut)
|
|
223
|
+
connectionsIn.forEach((other) => { other.disconnect(node) })
|
|
224
|
+
connectionsOut.forEach((other) => { node.disconnect(other) })
|
|
225
|
+
await node.close()
|
|
226
|
+
}
|
|
227
|
+
process.exit(1)
|
|
228
|
+
}
|
|
229
|
+
process.on("SIGINT", () => {
|
|
230
|
+
shutdown("SIGINT")
|
|
231
|
+
})
|
|
232
|
+
process.on("SIGTERM", () => {
|
|
233
|
+
shutdown("SIGTERM")
|
|
234
|
+
})
|
|
235
|
+
})().catch((err: Error) => {
|
|
236
|
+
if (cli !== null)
|
|
237
|
+
cli.log("error", err.message)
|
|
238
|
+
else
|
|
239
|
+
process.stderr.write(`${pkg.name}: ERROR: ${err.message}\n`)
|
|
240
|
+
process.exit(1)
|
|
241
|
+
})
|
|
242
|
+
|
package/tsconfig.json
ADDED