@llblab/pi-kit 0.3.2 → 0.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.
- package/AGENTS.md +3 -3
- package/BACKLOG.md +1 -1
- package/CHANGELOG.md +10 -0
- package/README.md +19 -10
- package/node_modules/@llblab/pi-actors/AGENTS.md +1 -0
- package/node_modules/@llblab/pi-actors/CHANGELOG.md +6 -0
- package/node_modules/@llblab/pi-actors/dist/skills/actors/SKILL.md +16 -6
- package/node_modules/@llblab/pi-actors/dist/skills/music-player/SKILL.md +3 -3
- package/node_modules/@llblab/pi-actors/dist/skills/music-player/genapps/music-player.mjs +6 -4
- package/node_modules/@llblab/pi-actors/dist/skills/music-player/scripts/playback.mjs +85 -18
- package/node_modules/@llblab/pi-actors/dist/skills/swarm/SKILL.md +2 -6
- package/node_modules/@llblab/pi-actors/dist/skills/swarm/references/development-swarm.md +2 -31
- package/node_modules/@llblab/pi-actors/docs/recipe-library.md +1 -1
- package/node_modules/@llblab/pi-actors/package.json +1 -1
- package/node_modules/@llblab/pi-actors/skills/actors/SKILL.md +16 -6
- package/node_modules/@llblab/pi-actors/skills/music-player/SKILL.md +3 -3
- package/node_modules/@llblab/pi-actors/skills/music-player/genapps/music-player.mjs +6 -4
- package/node_modules/@llblab/pi-actors/skills/music-player/scripts/playback.mjs +85 -18
- package/node_modules/@llblab/pi-actors/skills/swarm/SKILL.md +2 -6
- package/node_modules/@llblab/pi-actors/skills/swarm/references/development-swarm.md +2 -31
- package/node_modules/@llblab/pi-clean-room/AGENTS.md +5 -0
- package/node_modules/@llblab/pi-clean-room/BACKLOG.md +3 -0
- package/node_modules/@llblab/pi-clean-room/CHANGELOG.md +15 -0
- package/node_modules/@llblab/pi-clean-room/README.md +61 -0
- package/node_modules/@llblab/pi-clean-room/banner.jpg +0 -0
- package/node_modules/@llblab/pi-clean-room/index.ts +178 -0
- package/node_modules/@llblab/pi-clean-room/package.json +53 -0
- package/node_modules/@llblab/pi-grow-loop/AGENTS.md +1 -1
- package/node_modules/@llblab/pi-grow-loop/CHANGELOG.md +4 -0
- package/node_modules/@llblab/pi-grow-loop/package.json +1 -1
- package/node_modules/@llblab/pi-grow-loop/skills/grow-loop/SKILL.md +3 -17
- package/node_modules/@llblab/pi-state-flow/AGENTS.md +28 -0
- package/node_modules/@llblab/pi-state-flow/BACKLOG.md +3 -0
- package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +79 -0
- package/node_modules/@llblab/pi-state-flow/README.md +197 -0
- package/node_modules/@llblab/pi-state-flow/index.ts +4 -0
- package/node_modules/@llblab/pi-state-flow/lib/context.ts +88 -0
- package/node_modules/@llblab/pi-state-flow/lib/episode.ts +29 -0
- package/node_modules/@llblab/pi-state-flow/lib/extension.ts +238 -0
- package/node_modules/@llblab/pi-state-flow/lib/json.ts +78 -0
- package/node_modules/@llblab/pi-state-flow/lib/recovery.ts +38 -0
- package/node_modules/@llblab/pi-state-flow/lib/session.ts +49 -0
- package/node_modules/@llblab/pi-state-flow/lib/skills.ts +58 -0
- package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +103 -0
- package/node_modules/@llblab/pi-state-flow/lib/state.ts +20 -0
- package/node_modules/@llblab/pi-state-flow/lib/status.ts +16 -0
- package/node_modules/@llblab/pi-state-flow/lib/terminal.ts +148 -0
- package/node_modules/@llblab/pi-state-flow/lib/transition.ts +47 -0
- package/node_modules/@llblab/pi-state-flow/lib/validation.ts +27 -0
- package/node_modules/@llblab/pi-state-flow/package.json +55 -0
- package/node_modules/@llblab/pi-telegram/BACKLOG.md +3 -0
- package/node_modules/@llblab/pi-telegram/CHANGELOG.md +18 -0
- package/node_modules/@llblab/pi-telegram/README.md +2 -2
- package/node_modules/@llblab/pi-telegram/docs/README.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/architecture.md +5 -3
- package/node_modules/@llblab/pi-telegram/docs/compact-matrix-literal.md +39 -11
- package/node_modules/@llblab/pi-telegram/docs/generative-apps.md +2 -2
- package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +17 -5
- package/node_modules/@llblab/pi-telegram/docs/outbound.md +6 -4
- package/node_modules/@llblab/pi-telegram/docs/public-api.md +1 -1
- package/node_modules/@llblab/pi-telegram/index.ts +13 -1
- package/node_modules/@llblab/pi-telegram/lib/bindings.ts +12 -3
- package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +29 -18
- package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +15 -6
- package/node_modules/@llblab/pi-telegram/lib/bus.ts +11 -4
- package/node_modules/@llblab/pi-telegram/lib/keyboard.ts +5 -3
- package/node_modules/@llblab/pi-telegram/lib/outbound-buttons.ts +72 -15
- package/node_modules/@llblab/pi-telegram/lib/outbound-markup.ts +81 -9
- package/node_modules/@llblab/pi-telegram/lib/outbound.ts +2 -0
- package/node_modules/@llblab/pi-telegram/lib/replies.ts +1 -1
- package/node_modules/@llblab/pi-telegram/lib/routing.ts +89 -32
- package/node_modules/@llblab/pi-telegram/lib/sync.ts +74 -15
- package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +32 -1
- package/node_modules/@llblab/pi-telegram/lib/thread-reconciler.ts +17 -0
- package/node_modules/@llblab/pi-telegram/lib/threads.ts +123 -15
- package/node_modules/@llblab/pi-telegram/package.json +1 -1
- package/node_modules/@llblab/pi-telegram/skills/generated-control-surface/SKILL.md +4 -2
- package/node_modules/@llblab/pi-telegram/skills/generated-control-surface/references/layout-and-state.md +4 -2
- package/node_modules/@llblab/pi-telegram/skills/generative-apps/SKILL.md +4 -3
- package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/SKILL.md +19 -8
- package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/references/diagnosis.md +4 -2
- package/node_modules/@llblab/skills/abcd-context/AGENTS.md +40 -0
- package/node_modules/@llblab/skills/abcd-context/BACKLOG.md +3 -0
- package/node_modules/@llblab/skills/abcd-context/CHANGELOG.md +19 -0
- package/node_modules/@llblab/skills/abcd-context/README.md +30 -0
- package/node_modules/@llblab/skills/abcd-context/SKILL.md +122 -0
- package/node_modules/@llblab/skills/abcd-context/docs/README.md +9 -0
- package/node_modules/@llblab/skills/abcd-context/docs/protocols.md +199 -0
- package/node_modules/@llblab/skills/abcd-context/docs/templates.md +194 -0
- package/node_modules/@llblab/skills/abcd-context/docs/validation-design.md +117 -0
- package/node_modules/@llblab/skills/abcd-context/fixtures/abcd-project/AGENTS.md +10 -0
- package/node_modules/@llblab/skills/abcd-context/fixtures/abcd-project/BACKLOG.md +5 -0
- package/node_modules/@llblab/skills/abcd-context/fixtures/abcd-project/CHANGELOG.md +5 -0
- package/node_modules/@llblab/skills/abcd-context/fixtures/abcd-project/README.md +10 -0
- package/node_modules/@llblab/skills/abcd-context/fixtures/abcd-project/docs/README.md +7 -0
- package/node_modules/@llblab/skills/abcd-context/fixtures/abcd-project/docs/overview.md +3 -0
- package/node_modules/@llblab/skills/abcd-context/recipes/validate-context.json +7 -0
- package/node_modules/@llblab/skills/abcd-context/scripts/_self-test.mjs +244 -0
- package/node_modules/@llblab/skills/abcd-context/scripts/validate-context.mjs +497 -0
- package/node_modules/@llblab/skills/brain-storm/AGENTS.md +5 -0
- package/node_modules/@llblab/skills/brain-storm/SKILL.md +321 -0
- package/node_modules/@llblab/skills/coding-contract/SKILL.md +228 -0
- package/node_modules/@llblab/skills/domain-dag/AGENTS.md +6 -0
- package/node_modules/@llblab/skills/domain-dag/SKILL.md +257 -0
- package/node_modules/@llblab/skills/domain-dag/domain-dag.json +9 -0
- package/node_modules/@llblab/skills/domain-dag/recipes/validate-domain-dag.json +7 -0
- package/node_modules/@llblab/skills/domain-dag/scripts/validate-domain-dag.mjs +771 -0
- package/node_modules/@llblab/skills/domain-dag/scripts/validate-domain-dag.sh +4 -0
- package/node_modules/@llblab/skills/edge-tts/AGENTS.md +19 -0
- package/node_modules/@llblab/skills/edge-tts/SKILL.md +56 -0
- package/node_modules/@llblab/skills/edge-tts/recipes/say-edge.json +9 -0
- package/node_modules/@llblab/skills/edge-tts/scripts/say.mjs +1548 -0
- package/node_modules/@llblab/skills/edge-tts/scripts/say.sh +4 -0
- package/node_modules/@llblab/skills/extra-self/SKILL.md +229 -0
- package/node_modules/@llblab/skills/frontend-design/SKILL.md +78 -0
- package/node_modules/@llblab/skills/groq-stt/AGENTS.md +15 -0
- package/node_modules/@llblab/skills/groq-stt/SKILL.md +35 -0
- package/node_modules/@llblab/skills/groq-stt/recipes/transcribe-groq.json +10 -0
- package/node_modules/@llblab/skills/groq-stt/scripts/transcribe.mjs +190 -0
- package/node_modules/@llblab/skills/groq-stt/scripts/transcribe.sh +4 -0
- package/node_modules/@llblab/skills/mistral-stt/AGENTS.md +15 -0
- package/node_modules/@llblab/skills/mistral-stt/SKILL.md +35 -0
- package/node_modules/@llblab/skills/mistral-stt/recipes/transcribe-mistral.json +10 -0
- package/node_modules/@llblab/skills/mistral-stt/scripts/transcribe.mjs +191 -0
- package/node_modules/@llblab/skills/mistral-stt/scripts/transcribe.sh +4 -0
- package/node_modules/@llblab/skills/package.json +46 -0
- package/node_modules/@llblab/skills/re-review/SKILL.md +318 -0
- package/node_modules/@llblab/skills/release-flow/SKILL.md +456 -0
- package/node_modules/@llblab/skills/show-me/SKILL.md +144 -0
- package/package.json +20 -8
- package/node_modules/@llblab/pi-actors/dist/skills/music-player/scripts/playback-client.mjs +0 -143
- package/node_modules/@llblab/pi-actors/skills/music-player/scripts/playback-client.mjs +0 -143
|
@@ -0,0 +1,1548 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
4
|
+
import { createWriteStream, realpathSync } from "node:fs";
|
|
5
|
+
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
6
|
+
import { request as httpsRequest } from "node:https";
|
|
7
|
+
import { platform, tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { argv, exit, stderr, stdin, stdout } from "node:process";
|
|
10
|
+
import { connect as tlsConnect } from "node:tls";
|
|
11
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
12
|
+
|
|
13
|
+
// --- Constants ---
|
|
14
|
+
|
|
15
|
+
const EDGE = {
|
|
16
|
+
host: "speech.platform.bing.com",
|
|
17
|
+
path: "/consumer/speech/synthesize/readaloud",
|
|
18
|
+
token: "6A5AA1D4EAFF4E9FB37E23D68491D6F4",
|
|
19
|
+
chromiumMajor: "143",
|
|
20
|
+
secMsGecVersion: "1-143.0.3650.75",
|
|
21
|
+
};
|
|
22
|
+
const DEFAULTS = {
|
|
23
|
+
language: "en",
|
|
24
|
+
rate: "+30%",
|
|
25
|
+
volume: "+0%",
|
|
26
|
+
pitch: "+0Hz",
|
|
27
|
+
boundary: "SentenceBoundary",
|
|
28
|
+
connectTimeoutMs: 10_000,
|
|
29
|
+
receiveTimeoutMs: 60_000,
|
|
30
|
+
};
|
|
31
|
+
const AUDIO = {
|
|
32
|
+
chunkBytes: 4096,
|
|
33
|
+
ticksPerSecond: 10_000_000,
|
|
34
|
+
mp3BitrateBps: 48_000,
|
|
35
|
+
};
|
|
36
|
+
const WS = {
|
|
37
|
+
guid: "258EAFA5-E914-47DA-95CA-C5AB0DC85B11",
|
|
38
|
+
opcodes: {
|
|
39
|
+
continuation: 0x0,
|
|
40
|
+
text: 0x1,
|
|
41
|
+
binary: 0x2,
|
|
42
|
+
close: 0x8,
|
|
43
|
+
ping: 0x9,
|
|
44
|
+
pong: 0xa,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
const IS_WINDOWS = platform() === "win32";
|
|
48
|
+
const VOICES_BY_LANG = {
|
|
49
|
+
en: "en-US-AriaNeural",
|
|
50
|
+
ru: "ru-RU-SvetlanaNeural",
|
|
51
|
+
de: "de-DE-KatjaNeural",
|
|
52
|
+
fr: "fr-FR-DeniseNeural",
|
|
53
|
+
es: "es-ES-AlvaroNeural",
|
|
54
|
+
it: "it-IT-ElsaNeural",
|
|
55
|
+
zh: "zh-CN-XiaoxiaoNeural",
|
|
56
|
+
ja: "ja-JP-NanamiNeural",
|
|
57
|
+
};
|
|
58
|
+
const BASE_HEADERS = {
|
|
59
|
+
"User-Agent":
|
|
60
|
+
`Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ` +
|
|
61
|
+
`(KHTML, like Gecko) Chrome/${EDGE.chromiumMajor}.0.0.0 Safari/537.36 ` +
|
|
62
|
+
`Edg/${EDGE.chromiumMajor}.0.0.0`,
|
|
63
|
+
"Accept-Encoding": "gzip, deflate, br, zstd",
|
|
64
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
65
|
+
};
|
|
66
|
+
const WSS_HEADERS = {
|
|
67
|
+
Pragma: "no-cache",
|
|
68
|
+
"Cache-Control": "no-cache",
|
|
69
|
+
Origin: "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold",
|
|
70
|
+
"Sec-WebSocket-Version": "13",
|
|
71
|
+
...BASE_HEADERS,
|
|
72
|
+
};
|
|
73
|
+
const VOICE_HEADERS = {
|
|
74
|
+
"Sec-CH-UA":
|
|
75
|
+
`" Not;A Brand";v="99", "Microsoft Edge";v="${EDGE.chromiumMajor}", ` +
|
|
76
|
+
`"Chromium";v="${EDGE.chromiumMajor}"`,
|
|
77
|
+
"Sec-CH-UA-Mobile": "?0",
|
|
78
|
+
Accept: "*/*",
|
|
79
|
+
"Sec-Fetch-Site": "none",
|
|
80
|
+
"Sec-Fetch-Mode": "cors",
|
|
81
|
+
"Sec-Fetch-Dest": "empty",
|
|
82
|
+
...BASE_HEADERS,
|
|
83
|
+
};
|
|
84
|
+
const PLAYBACK_CANDIDATES = [
|
|
85
|
+
{
|
|
86
|
+
command: "ffplay",
|
|
87
|
+
args: (file) => ["-nodisp", "-autoexit", "-v", "quiet", "-i", file],
|
|
88
|
+
},
|
|
89
|
+
{ command: "mpv", args: (file) => ["--no-video", "--really-quiet", file] },
|
|
90
|
+
{
|
|
91
|
+
command: "vlc",
|
|
92
|
+
args: (file) => ["--play-and-exit", "--intf", "dummy", file],
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
command: "cvlc",
|
|
96
|
+
args: (file) => ["--play-and-exit", "--intf", "dummy", file],
|
|
97
|
+
},
|
|
98
|
+
{ command: "mpg123", args: (file) => ["-q", file] },
|
|
99
|
+
{ command: "afplay", args: (file) => [file] },
|
|
100
|
+
{
|
|
101
|
+
command: "windows-default",
|
|
102
|
+
label: "Windows default MP3 app",
|
|
103
|
+
available: () => IS_WINDOWS && commandExists("powershell.exe"),
|
|
104
|
+
launch: createWindowsDefaultPlaybackLaunch,
|
|
105
|
+
},
|
|
106
|
+
];
|
|
107
|
+
const CLI_FLAGS = new Map([
|
|
108
|
+
["--help", "help"],
|
|
109
|
+
["-h", "help"],
|
|
110
|
+
["--list-voices", "listVoices"],
|
|
111
|
+
["-l", "listVoices"],
|
|
112
|
+
]);
|
|
113
|
+
const CLI_VALUES = new Map([
|
|
114
|
+
["--text", "text"],
|
|
115
|
+
["-t", "text"],
|
|
116
|
+
["--file", "file"],
|
|
117
|
+
["-f", "file"],
|
|
118
|
+
["--voice", "voice"],
|
|
119
|
+
["-v", "voice"],
|
|
120
|
+
["--lang", "language"],
|
|
121
|
+
["--language", "language"],
|
|
122
|
+
["--rate", "rate"],
|
|
123
|
+
["--volume", "volume"],
|
|
124
|
+
["--pitch", "pitch"],
|
|
125
|
+
["--boundary", "boundary"],
|
|
126
|
+
["--write-media", "writeMedia"],
|
|
127
|
+
["--write-subtitles", "writeSubtitles"],
|
|
128
|
+
["--write-metadata", "writeMetadata"],
|
|
129
|
+
]);
|
|
130
|
+
let clockSkewSeconds = 0;
|
|
131
|
+
|
|
132
|
+
// --- Errors ---
|
|
133
|
+
|
|
134
|
+
class HttpResponseError extends Error {
|
|
135
|
+
constructor(
|
|
136
|
+
message,
|
|
137
|
+
statusCode,
|
|
138
|
+
statusText,
|
|
139
|
+
headers,
|
|
140
|
+
body = Buffer.alloc(0),
|
|
141
|
+
) {
|
|
142
|
+
super(message);
|
|
143
|
+
this.name = "HttpResponseError";
|
|
144
|
+
this.statusCode = statusCode;
|
|
145
|
+
this.statusText = statusText;
|
|
146
|
+
this.headers = headers;
|
|
147
|
+
this.body = body;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
class WebSocketHandshakeError extends HttpResponseError {
|
|
152
|
+
constructor(
|
|
153
|
+
message,
|
|
154
|
+
statusCode,
|
|
155
|
+
statusText,
|
|
156
|
+
headers,
|
|
157
|
+
body = Buffer.alloc(0),
|
|
158
|
+
) {
|
|
159
|
+
super(message, statusCode, statusText, headers, body);
|
|
160
|
+
this.name = "WebSocketHandshakeError";
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// --- Raw WebSocket ---
|
|
165
|
+
|
|
166
|
+
class RawWebSocket {
|
|
167
|
+
constructor(socket) {
|
|
168
|
+
this.socket = socket;
|
|
169
|
+
this.buffer = Buffer.alloc(0);
|
|
170
|
+
this.listeners = new Map();
|
|
171
|
+
this.fragmentOpcode = null;
|
|
172
|
+
this.fragmentChunks = [];
|
|
173
|
+
this.closeSent = false;
|
|
174
|
+
this.closeEmitted = false;
|
|
175
|
+
this.socket.on("data", (chunk) => this.handleData(chunk));
|
|
176
|
+
this.socket.on("error", (error) => this.emit("error", error));
|
|
177
|
+
this.socket.on("close", () => this.emitClose(1006, ""));
|
|
178
|
+
}
|
|
179
|
+
on(eventName, listener) {
|
|
180
|
+
const listeners = this.listeners.get(eventName) || [];
|
|
181
|
+
listeners.push(listener);
|
|
182
|
+
this.listeners.set(eventName, listeners);
|
|
183
|
+
}
|
|
184
|
+
emit(eventName, value) {
|
|
185
|
+
for (const listener of this.listeners.get(eventName) || []) listener(value);
|
|
186
|
+
}
|
|
187
|
+
acceptBufferedData(chunk) {
|
|
188
|
+
if (chunk.length > 0) this.handleData(chunk);
|
|
189
|
+
}
|
|
190
|
+
sendText(text) {
|
|
191
|
+
this.sendFrame(Buffer.from(text, "utf-8"), WS.opcodes.text);
|
|
192
|
+
}
|
|
193
|
+
close(code = 1000, reason = "") {
|
|
194
|
+
if (this.closeSent || this.socket.destroyed) return;
|
|
195
|
+
this.closeSent = true;
|
|
196
|
+
this.sendFrame(createClosePayload(code, reason), WS.opcodes.close);
|
|
197
|
+
this.socket.end();
|
|
198
|
+
}
|
|
199
|
+
handleData(chunk) {
|
|
200
|
+
this.buffer = Buffer.concat([this.buffer, chunk]);
|
|
201
|
+
while (this.buffer.length > 0) {
|
|
202
|
+
const frame = parseWebSocketFrame(this.buffer);
|
|
203
|
+
if (!frame) return;
|
|
204
|
+
this.buffer = this.buffer.slice(frame.consumed);
|
|
205
|
+
this.handleFrame(frame);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
handleFrame(frame) {
|
|
209
|
+
if (frame.opcode === WS.opcodes.close)
|
|
210
|
+
return this.handleCloseFrame(frame.payload);
|
|
211
|
+
if (frame.opcode === WS.opcodes.ping)
|
|
212
|
+
return this.sendFrame(frame.payload, WS.opcodes.pong);
|
|
213
|
+
if (frame.opcode === WS.opcodes.pong) return;
|
|
214
|
+
if (frame.opcode === WS.opcodes.continuation)
|
|
215
|
+
return this.handleContinuationFrame(frame);
|
|
216
|
+
if (frame.opcode === WS.opcodes.text || frame.opcode === WS.opcodes.binary)
|
|
217
|
+
return this.handleDataFrame(frame);
|
|
218
|
+
this.emit(
|
|
219
|
+
"error",
|
|
220
|
+
new Error(`Unsupported websocket opcode: ${frame.opcode}`),
|
|
221
|
+
);
|
|
222
|
+
this.close(1002, "Unsupported opcode");
|
|
223
|
+
}
|
|
224
|
+
handleCloseFrame(payload) {
|
|
225
|
+
const code = payload.length >= 2 ? payload.readUInt16BE(0) : 1005;
|
|
226
|
+
const reason = payload.length > 2 ? payload.slice(2).toString("utf-8") : "";
|
|
227
|
+
if (!this.closeSent) this.close(code === 1005 ? 1000 : code, reason);
|
|
228
|
+
this.emitClose(code, reason);
|
|
229
|
+
}
|
|
230
|
+
handleContinuationFrame(frame) {
|
|
231
|
+
if (this.fragmentOpcode === null)
|
|
232
|
+
throw new Error("Unexpected websocket continuation frame");
|
|
233
|
+
this.fragmentChunks.push(frame.payload);
|
|
234
|
+
if (!frame.fin) return;
|
|
235
|
+
this.emitMessage(this.fragmentOpcode, Buffer.concat(this.fragmentChunks));
|
|
236
|
+
this.fragmentOpcode = null;
|
|
237
|
+
this.fragmentChunks = [];
|
|
238
|
+
}
|
|
239
|
+
handleDataFrame(frame) {
|
|
240
|
+
if (!frame.fin) {
|
|
241
|
+
this.fragmentOpcode = frame.opcode;
|
|
242
|
+
this.fragmentChunks = [frame.payload];
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
this.emitMessage(frame.opcode, frame.payload);
|
|
246
|
+
}
|
|
247
|
+
emitMessage(opcode, payload) {
|
|
248
|
+
if (opcode === WS.opcodes.text)
|
|
249
|
+
return this.emit("message", {
|
|
250
|
+
data: payload.toString("utf-8"),
|
|
251
|
+
isBinary: false,
|
|
252
|
+
});
|
|
253
|
+
if (opcode === WS.opcodes.binary)
|
|
254
|
+
this.emit("message", { data: payload, isBinary: true });
|
|
255
|
+
}
|
|
256
|
+
emitClose(code, reason) {
|
|
257
|
+
if (this.closeEmitted) return;
|
|
258
|
+
this.closeEmitted = true;
|
|
259
|
+
this.emit("close", { code, reason });
|
|
260
|
+
}
|
|
261
|
+
sendFrame(payload, opcode) {
|
|
262
|
+
if (!this.socket.destroyed)
|
|
263
|
+
this.socket.write(createWebSocketFrame(payload, opcode));
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
class AsyncQueue {
|
|
268
|
+
constructor() {
|
|
269
|
+
this.items = [];
|
|
270
|
+
this.waiters = [];
|
|
271
|
+
this.closed = false;
|
|
272
|
+
this.error = null;
|
|
273
|
+
}
|
|
274
|
+
push(item) {
|
|
275
|
+
if (this.closed) return;
|
|
276
|
+
this.items.push(item);
|
|
277
|
+
this.wake();
|
|
278
|
+
}
|
|
279
|
+
close(error = null) {
|
|
280
|
+
if (this.closed) return;
|
|
281
|
+
this.closed = true;
|
|
282
|
+
this.error = error;
|
|
283
|
+
this.wake();
|
|
284
|
+
}
|
|
285
|
+
wake() {
|
|
286
|
+
for (const waiter of this.waiters.splice(0)) waiter();
|
|
287
|
+
}
|
|
288
|
+
async next() {
|
|
289
|
+
while (this.items.length === 0) {
|
|
290
|
+
if (!this.closed)
|
|
291
|
+
await new Promise((resolve) => this.waiters.push(resolve));
|
|
292
|
+
else if (this.error) throw this.error;
|
|
293
|
+
else return { done: true, value: undefined };
|
|
294
|
+
}
|
|
295
|
+
return { done: false, value: this.items.shift() };
|
|
296
|
+
}
|
|
297
|
+
[Symbol.asyncIterator]() {
|
|
298
|
+
return this;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function createClosePayload(code, reason) {
|
|
303
|
+
const reasonBytes = Buffer.from(reason, "utf-8");
|
|
304
|
+
const payload = Buffer.alloc(2 + reasonBytes.length);
|
|
305
|
+
payload.writeUInt16BE(code, 0);
|
|
306
|
+
reasonBytes.copy(payload, 2);
|
|
307
|
+
return payload;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function createWebSocketFrame(payload, opcode) {
|
|
311
|
+
const length = payload.length;
|
|
312
|
+
const header = Buffer.alloc(webSocketHeaderLength(length) + 4);
|
|
313
|
+
header[0] = 0x80 | opcode;
|
|
314
|
+
writeWebSocketLength(header, length);
|
|
315
|
+
const mask = header.slice(header.length - 4);
|
|
316
|
+
const masked = Buffer.alloc(payload.length);
|
|
317
|
+
randomBytes(4).copy(mask);
|
|
318
|
+
for (let index = 0; index < payload.length; index += 1)
|
|
319
|
+
masked[index] = payload[index] ^ mask[index % 4];
|
|
320
|
+
return Buffer.concat([header, masked]);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function webSocketHeaderLength(payloadLength) {
|
|
324
|
+
if (payloadLength < 126) return 2;
|
|
325
|
+
if (payloadLength <= 0xffff) return 4;
|
|
326
|
+
return 10;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function writeWebSocketLength(header, payloadLength) {
|
|
330
|
+
if (payloadLength < 126) {
|
|
331
|
+
header[1] = 0x80 | payloadLength;
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (payloadLength <= 0xffff) {
|
|
335
|
+
header[1] = 0x80 | 126;
|
|
336
|
+
header.writeUInt16BE(payloadLength, 2);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
header[1] = 0x80 | 127;
|
|
340
|
+
header.writeBigUInt64BE(BigInt(payloadLength), 2);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function parseWebSocketFrame(buffer) {
|
|
344
|
+
if (buffer.length < 2) return null;
|
|
345
|
+
const header = readWebSocketFrameHeader(buffer);
|
|
346
|
+
if (!header || buffer.length < header.offset + header.length) return null;
|
|
347
|
+
const payload = Buffer.from(
|
|
348
|
+
buffer.slice(header.offset, header.offset + header.length),
|
|
349
|
+
);
|
|
350
|
+
if (header.mask) unmaskWebSocketPayload(payload, header.mask);
|
|
351
|
+
return {
|
|
352
|
+
fin: header.fin,
|
|
353
|
+
opcode: header.opcode,
|
|
354
|
+
payload,
|
|
355
|
+
consumed: header.offset + header.length,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function readWebSocketFrameHeader(buffer) {
|
|
360
|
+
const fin = Boolean(buffer[0] & 0x80);
|
|
361
|
+
const opcode = buffer[0] & 0x0f;
|
|
362
|
+
const masked = Boolean(buffer[1] & 0x80);
|
|
363
|
+
let length = buffer[1] & 0x7f;
|
|
364
|
+
let offset = 2;
|
|
365
|
+
if (length === 126) {
|
|
366
|
+
if (buffer.length < offset + 2) return null;
|
|
367
|
+
length = buffer.readUInt16BE(offset);
|
|
368
|
+
offset += 2;
|
|
369
|
+
} else if (length === 127) {
|
|
370
|
+
if (buffer.length < offset + 8) return null;
|
|
371
|
+
length = readSafeFrameLength(buffer, offset);
|
|
372
|
+
offset += 8;
|
|
373
|
+
}
|
|
374
|
+
const mask = masked ? buffer.slice(offset, offset + 4) : null;
|
|
375
|
+
if (masked && buffer.length < offset + 4) return null;
|
|
376
|
+
return { fin, opcode, length, mask, offset: offset + (masked ? 4 : 0) };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function readSafeFrameLength(buffer, offset) {
|
|
380
|
+
const length = buffer.readBigUInt64BE(offset);
|
|
381
|
+
if (length > BigInt(Number.MAX_SAFE_INTEGER))
|
|
382
|
+
throw new Error("WebSocket frame is too large");
|
|
383
|
+
return Number(length);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function unmaskWebSocketPayload(payload, mask) {
|
|
387
|
+
for (let index = 0; index < payload.length; index += 1)
|
|
388
|
+
payload[index] ^= mask[index % 4];
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// --- Configuration ---
|
|
392
|
+
|
|
393
|
+
function createTtsConfig(options = {}) {
|
|
394
|
+
const language = normalizeLanguage(
|
|
395
|
+
options.language || options.lang || DEFAULTS.language,
|
|
396
|
+
);
|
|
397
|
+
return {
|
|
398
|
+
language,
|
|
399
|
+
voice: normalizeVoice(
|
|
400
|
+
options.voice || VOICES_BY_LANG[language] || VOICES_BY_LANG.en,
|
|
401
|
+
),
|
|
402
|
+
rate: normalizeRate(options.rate),
|
|
403
|
+
volume: normalizeVolume(options.volume),
|
|
404
|
+
pitch: normalizePitch(options.pitch),
|
|
405
|
+
boundary: normalizeBoundary(options.boundary),
|
|
406
|
+
connectTimeoutMs: options.connectTimeoutMs ?? DEFAULTS.connectTimeoutMs,
|
|
407
|
+
receiveTimeoutMs: options.receiveTimeoutMs ?? DEFAULTS.receiveTimeoutMs,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function normalizeLanguage(value) {
|
|
412
|
+
return value
|
|
413
|
+
? value.split(/[-_]/, 1)[0] || DEFAULTS.language
|
|
414
|
+
: DEFAULTS.language;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function normalizeRate(value, defaultValue = DEFAULTS.rate) {
|
|
418
|
+
return normalizeSignedPercent(value, defaultValue, "rate");
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function normalizeVolume(value, defaultValue = DEFAULTS.volume) {
|
|
422
|
+
return normalizeSignedPercent(value, defaultValue, "volume");
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function normalizeSignedPercent(value, defaultValue, label) {
|
|
426
|
+
const raw = (value ?? "").trim();
|
|
427
|
+
if (!raw) return defaultValue;
|
|
428
|
+
const match = /^([+-]?)(\d+)%$/.exec(raw);
|
|
429
|
+
if (match) return `${match[1] || "+"}${match[2]}%`;
|
|
430
|
+
const numeric = Number(raw);
|
|
431
|
+
if (Number.isFinite(numeric))
|
|
432
|
+
return formatSignedUnit(Math.round(numeric * 100), "%");
|
|
433
|
+
stderr.write(
|
|
434
|
+
`Invalid ${label} '${value}'. Use +30% or numeric multipliers like 1 = 100 percent.\n`,
|
|
435
|
+
);
|
|
436
|
+
return defaultValue;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function normalizePitch(value, defaultValue = DEFAULTS.pitch) {
|
|
440
|
+
const raw = (value ?? "").trim();
|
|
441
|
+
if (!raw) return defaultValue;
|
|
442
|
+
if (/^[+-]\d+Hz$/.test(raw)) return raw;
|
|
443
|
+
const numeric = Number(raw);
|
|
444
|
+
if (Number.isFinite(numeric))
|
|
445
|
+
return formatSignedUnit(Math.round(numeric), "Hz");
|
|
446
|
+
throw new Error(`Invalid pitch '${value}'. Use values like +0Hz or -10Hz.`);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function formatSignedUnit(value, unit) {
|
|
450
|
+
return `${value < 0 ? "" : "+"}${value}${unit}`;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function normalizeBoundary(value = DEFAULTS.boundary) {
|
|
454
|
+
if (value === "WordBoundary" || value === "SentenceBoundary") return value;
|
|
455
|
+
throw new Error("boundary must be WordBoundary or SentenceBoundary");
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function normalizeVoice(voice) {
|
|
459
|
+
if (!voice) return normalizeVoice(VOICES_BY_LANG.en);
|
|
460
|
+
const match = /^([a-z]{2,})-([A-Z]{2,})-(.+Neural)$/.exec(voice);
|
|
461
|
+
if (match) return formatServiceVoice(match[1], match[2], match[3]);
|
|
462
|
+
if (/^Microsoft Server Speech Text to Speech Voice \(.+,.+\)$/.test(voice))
|
|
463
|
+
return voice;
|
|
464
|
+
throw new Error(`Invalid voice '${voice}'.`);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function formatServiceVoice(language, region, rawName) {
|
|
468
|
+
const dash = rawName.indexOf("-");
|
|
469
|
+
const voiceRegion = dash < 0 ? region : `${region}-${rawName.slice(0, dash)}`;
|
|
470
|
+
const name = dash < 0 ? rawName : rawName.slice(dash + 1);
|
|
471
|
+
return `Microsoft Server Speech Text to Speech Voice (${language}-${voiceRegion}, ${name})`;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// --- Edge Protocol ---
|
|
475
|
+
|
|
476
|
+
function buildSocketUrl(connectionId) {
|
|
477
|
+
return `${edgeWssBaseUrl()}?${new URLSearchParams(edgeAuthParams({ ConnectionId: connectionId })).toString()}`;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function buildVoiceListUrl() {
|
|
481
|
+
return `https://${EDGE.host}${EDGE.path}/voices/list?${new URLSearchParams(edgeAuthParams({ trustedclienttoken: EDGE.token })).toString()}`;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function edgeWssBaseUrl() {
|
|
485
|
+
return `wss://${EDGE.host}${EDGE.path}/edge/v1`;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function edgeAuthParams(extra = {}) {
|
|
489
|
+
return {
|
|
490
|
+
TrustedClientToken: EDGE.token,
|
|
491
|
+
"Sec-MS-GEC": generateSecMsGec(),
|
|
492
|
+
"Sec-MS-GEC-Version": EDGE.secMsGecVersion,
|
|
493
|
+
...extra,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function buildConfigMessage(boundary) {
|
|
498
|
+
const wordBoundary = boundary === "WordBoundary";
|
|
499
|
+
const body = {
|
|
500
|
+
context: {
|
|
501
|
+
synthesis: {
|
|
502
|
+
audio: {
|
|
503
|
+
metadataoptions: {
|
|
504
|
+
sentenceBoundaryEnabled: String(!wordBoundary),
|
|
505
|
+
wordBoundaryEnabled: String(wordBoundary),
|
|
506
|
+
},
|
|
507
|
+
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
|
|
508
|
+
},
|
|
509
|
+
},
|
|
510
|
+
},
|
|
511
|
+
};
|
|
512
|
+
return buildEdgeMessage(
|
|
513
|
+
{
|
|
514
|
+
"X-Timestamp": dateToString(),
|
|
515
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
516
|
+
Path: "speech.config",
|
|
517
|
+
},
|
|
518
|
+
`${JSON.stringify(body)}\r\n`,
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function buildSsmlMessage(requestId, config, escapedText) {
|
|
523
|
+
return buildEdgeMessage(
|
|
524
|
+
{
|
|
525
|
+
"X-RequestId": requestId,
|
|
526
|
+
"Content-Type": "application/ssml+xml",
|
|
527
|
+
"X-Timestamp": `${dateToString()}Z`,
|
|
528
|
+
Path: "ssml",
|
|
529
|
+
},
|
|
530
|
+
buildSsml(config, escapedText),
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function buildSsml(config, escapedText) {
|
|
535
|
+
return [
|
|
536
|
+
"<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='en-US'>",
|
|
537
|
+
`<voice name='${config.voice}'>`,
|
|
538
|
+
`<prosody pitch='${config.pitch}' rate='${config.rate}' volume='${config.volume}'>`,
|
|
539
|
+
escapedText,
|
|
540
|
+
"</prosody>",
|
|
541
|
+
"</voice>",
|
|
542
|
+
"</speak>",
|
|
543
|
+
].join("");
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function buildEdgeMessage(headers, body) {
|
|
547
|
+
return `${Object.entries(headers)
|
|
548
|
+
.map(([key, value]) => `${key}:${value}`)
|
|
549
|
+
.join("\r\n")}\r\n\r\n${body}`;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function dateToString() {
|
|
553
|
+
const now = new Date();
|
|
554
|
+
const weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
555
|
+
const months = [
|
|
556
|
+
"Jan",
|
|
557
|
+
"Feb",
|
|
558
|
+
"Mar",
|
|
559
|
+
"Apr",
|
|
560
|
+
"May",
|
|
561
|
+
"Jun",
|
|
562
|
+
"Jul",
|
|
563
|
+
"Aug",
|
|
564
|
+
"Sep",
|
|
565
|
+
"Oct",
|
|
566
|
+
"Nov",
|
|
567
|
+
"Dec",
|
|
568
|
+
];
|
|
569
|
+
const date = [
|
|
570
|
+
weekdays[now.getUTCDay()],
|
|
571
|
+
months[now.getUTCMonth()],
|
|
572
|
+
pad2(now.getUTCDate()),
|
|
573
|
+
now.getUTCFullYear(),
|
|
574
|
+
].join(" ");
|
|
575
|
+
const time = [now.getUTCHours(), now.getUTCMinutes(), now.getUTCSeconds()]
|
|
576
|
+
.map(pad2)
|
|
577
|
+
.join(":");
|
|
578
|
+
return `${date} ${time} GMT+0000 (Coordinated Universal Time)`;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function generateSecMsGec() {
|
|
582
|
+
const windowsEpoch = 11644473600;
|
|
583
|
+
let timestamp = getUnixTimestamp() + windowsEpoch;
|
|
584
|
+
timestamp -= timestamp % 300;
|
|
585
|
+
const ticks = Math.floor(timestamp * 1e7);
|
|
586
|
+
return createHash("sha256")
|
|
587
|
+
.update(`${ticks}${EDGE.token}`)
|
|
588
|
+
.digest("hex")
|
|
589
|
+
.toUpperCase();
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function getUnixTimestamp() {
|
|
593
|
+
return Date.now() / 1000 + clockSkewSeconds;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function adjustClockSkew(headers) {
|
|
597
|
+
const serverMillis = Date.parse(headers.date || "");
|
|
598
|
+
if (!Number.isFinite(serverMillis)) return false;
|
|
599
|
+
clockSkewSeconds += serverMillis / 1000 - getUnixTimestamp();
|
|
600
|
+
return true;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function headersWithMuid(headers) {
|
|
604
|
+
return { ...headers, Cookie: `muid=${randomHex(16).toUpperCase()};` };
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function connectId() {
|
|
608
|
+
return randomHex(16);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function randomHex(bytes = 16) {
|
|
612
|
+
return randomBytes(bytes).toString("hex");
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function pad2(value) {
|
|
616
|
+
return String(value).padStart(2, "0");
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// --- Text And Payload Parsing ---
|
|
620
|
+
|
|
621
|
+
function escapeXml(value) {
|
|
622
|
+
return String(value)
|
|
623
|
+
.replace(/&/g, "&")
|
|
624
|
+
.replace(/</g, "<")
|
|
625
|
+
.replace(/>/g, ">")
|
|
626
|
+
.replace(/\"/g, """)
|
|
627
|
+
.replace(/'/g, "'");
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function unescapeXml(value) {
|
|
631
|
+
return String(value)
|
|
632
|
+
.replace(/</g, "<")
|
|
633
|
+
.replace(/>/g, ">")
|
|
634
|
+
.replace(/"/g, '"')
|
|
635
|
+
.replace(/'/g, "'")
|
|
636
|
+
.replace(/&/g, "&");
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function removeIncompatibleCharacters(value) {
|
|
640
|
+
return String(value)
|
|
641
|
+
.split("")
|
|
642
|
+
.map((char) => (isIncompatibleXmlChar(char.codePointAt(0)) ? " " : char))
|
|
643
|
+
.join("");
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function isIncompatibleXmlChar(code) {
|
|
647
|
+
return (
|
|
648
|
+
(code >= 0 && code <= 8) ||
|
|
649
|
+
(code >= 11 && code <= 12) ||
|
|
650
|
+
(code >= 14 && code <= 31)
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function splitByByteLength(text, limit = AUDIO.chunkBytes) {
|
|
655
|
+
const chunks = [];
|
|
656
|
+
let bytes = Buffer.from(text, "utf-8");
|
|
657
|
+
while (bytes.length > limit) {
|
|
658
|
+
const splitAt = findSplitPoint(bytes, limit);
|
|
659
|
+
const part = bytes.slice(0, splitAt).toString("utf-8").trim();
|
|
660
|
+
if (part) chunks.push(part);
|
|
661
|
+
bytes = bytes.slice(splitAt > 0 ? splitAt : 1);
|
|
662
|
+
}
|
|
663
|
+
const tail = bytes.toString("utf-8").trim();
|
|
664
|
+
if (tail) chunks.push(tail);
|
|
665
|
+
return chunks;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function findSplitPoint(bytes, limit) {
|
|
669
|
+
const natural = Math.max(
|
|
670
|
+
bytes.lastIndexOf(0x0a, limit),
|
|
671
|
+
bytes.lastIndexOf(0x20, limit),
|
|
672
|
+
);
|
|
673
|
+
const splitAt = adjustEntityBoundary(
|
|
674
|
+
bytes,
|
|
675
|
+
natural < 0 ? safeUtf8Split(bytes, limit) : natural,
|
|
676
|
+
);
|
|
677
|
+
if (splitAt <= 0)
|
|
678
|
+
throw new Error(
|
|
679
|
+
"Maximum byte length is too small or invalid text structure near XML entity.",
|
|
680
|
+
);
|
|
681
|
+
return splitAt;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function safeUtf8Split(bytes, limit) {
|
|
685
|
+
let cursor = Math.min(limit, bytes.length);
|
|
686
|
+
while (cursor > 0 && isUtf8ContinuationByte(bytes[cursor])) cursor -= 1;
|
|
687
|
+
return cursor;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function isUtf8ContinuationByte(byte) {
|
|
691
|
+
return byte !== undefined && (byte & 0xc0) === 0x80;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function adjustEntityBoundary(bytes, splitAt) {
|
|
695
|
+
let adjusted = splitAt;
|
|
696
|
+
while (adjusted > 0) {
|
|
697
|
+
const text = bytes.slice(0, adjusted).toString("utf-8");
|
|
698
|
+
const amp = text.lastIndexOf("&");
|
|
699
|
+
if (amp < 0 || text.includes(";", amp)) return adjusted;
|
|
700
|
+
adjusted = Buffer.byteLength(text.slice(0, amp), "utf-8");
|
|
701
|
+
}
|
|
702
|
+
return adjusted;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function parseHeadersAndData(bytes, separatorIndex) {
|
|
706
|
+
return {
|
|
707
|
+
headers: parseHeaderBlock(bytes.slice(0, separatorIndex).toString("utf-8")),
|
|
708
|
+
data: bytes.slice(separatorIndex + 4),
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function parseHeaderBlock(text) {
|
|
713
|
+
const headers = {};
|
|
714
|
+
for (const row of text.split("\r\n")) {
|
|
715
|
+
const divider = row.indexOf(":");
|
|
716
|
+
if (divider >= 0) headers[row.slice(0, divider)] = row.slice(divider + 1);
|
|
717
|
+
}
|
|
718
|
+
return headers;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function parseWssTextPayload(data) {
|
|
722
|
+
const bytes = Buffer.from(data, "utf-8");
|
|
723
|
+
const separator = bytes.indexOf(Buffer.from("\r\n\r\n"));
|
|
724
|
+
return separator < 0
|
|
725
|
+
? { headers: {}, data: Buffer.alloc(0) }
|
|
726
|
+
: parseHeadersAndData(bytes, separator);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function parseWssBinaryPayload(payload) {
|
|
730
|
+
if (payload.length < 2)
|
|
731
|
+
throw new Error("Binary frame is too short for websocket edge headers");
|
|
732
|
+
const headerLength = payload.readUInt16BE(0);
|
|
733
|
+
if (headerLength > payload.length - 2)
|
|
734
|
+
throw new Error(
|
|
735
|
+
"Binary frame header length is greater than payload length",
|
|
736
|
+
);
|
|
737
|
+
const headerBytes = payload.slice(2, 2 + headerLength);
|
|
738
|
+
return {
|
|
739
|
+
headers: parseHeaderBlock(headerBytes.toString("utf-8")),
|
|
740
|
+
data: payload.slice(2 + headerLength),
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function parseMetadata(data, state) {
|
|
745
|
+
for (const meta of JSON.parse(data.toString("utf-8")).Metadata || []) {
|
|
746
|
+
if (meta.Type === "WordBoundary" || meta.Type === "SentenceBoundary")
|
|
747
|
+
return parseBoundaryMetadata(meta, state);
|
|
748
|
+
if (meta.Type !== "SessionEnd")
|
|
749
|
+
throw new Error(`Unknown metadata type: ${meta.Type}`);
|
|
750
|
+
}
|
|
751
|
+
throw new Error("No boundary metadata found");
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function parseBoundaryMetadata(meta, state) {
|
|
755
|
+
return {
|
|
756
|
+
type: meta.Type,
|
|
757
|
+
offset: meta.Data.Offset + state.offsetCompensation,
|
|
758
|
+
duration: meta.Data.Duration,
|
|
759
|
+
text: unescapeXml(meta.Data.text.Text),
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function compensateOffset(state) {
|
|
764
|
+
state.cumulativeAudioBytes += state.chunkAudioBytes;
|
|
765
|
+
state.offsetCompensation = Math.floor(
|
|
766
|
+
(state.cumulativeAudioBytes * 8 * AUDIO.ticksPerSecond) /
|
|
767
|
+
AUDIO.mp3BitrateBps,
|
|
768
|
+
);
|
|
769
|
+
state.chunkAudioBytes = 0;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// --- HTTP And TLS ---
|
|
773
|
+
|
|
774
|
+
function httpsGetBuffer(url, headers, timeoutMs = 10_000) {
|
|
775
|
+
return new Promise((resolve, reject) => {
|
|
776
|
+
const request = httpsRequest(url, { method: "GET", headers }, (response) =>
|
|
777
|
+
collectHttpResponse(response, resolve, reject),
|
|
778
|
+
);
|
|
779
|
+
request.setTimeout(timeoutMs, () =>
|
|
780
|
+
request.destroy(new Error("HTTP request timeout")),
|
|
781
|
+
);
|
|
782
|
+
request.on("error", reject);
|
|
783
|
+
request.end();
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function collectHttpResponse(response, resolve, reject) {
|
|
788
|
+
const chunks = [];
|
|
789
|
+
response.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
|
790
|
+
response.on("end", () => {
|
|
791
|
+
const body = Buffer.concat(chunks);
|
|
792
|
+
const headers = normalizeHttpHeaders(response.headers);
|
|
793
|
+
if (
|
|
794
|
+
response.statusCode &&
|
|
795
|
+
response.statusCode >= 200 &&
|
|
796
|
+
response.statusCode < 300
|
|
797
|
+
)
|
|
798
|
+
return resolve(body);
|
|
799
|
+
reject(
|
|
800
|
+
new HttpResponseError(
|
|
801
|
+
`HTTP request failed: ${response.statusCode} ${response.statusMessage}`,
|
|
802
|
+
response.statusCode || 0,
|
|
803
|
+
response.statusMessage || "",
|
|
804
|
+
headers,
|
|
805
|
+
body,
|
|
806
|
+
),
|
|
807
|
+
);
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function normalizeHttpHeaders(headers) {
|
|
812
|
+
return Object.fromEntries(
|
|
813
|
+
Object.entries(headers).map(([key, value]) => [
|
|
814
|
+
key.toLowerCase(),
|
|
815
|
+
Array.isArray(value) ? value.join(", ") : value || "",
|
|
816
|
+
]),
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function openWebSocket(url, headers, timeoutMs) {
|
|
821
|
+
return new Promise((resolve, reject) => {
|
|
822
|
+
const parsed = new URL(url);
|
|
823
|
+
const key = randomBytes(16).toString("base64");
|
|
824
|
+
const socket = tlsConnect({
|
|
825
|
+
host: parsed.hostname,
|
|
826
|
+
port: Number(parsed.port) || 443,
|
|
827
|
+
servername: parsed.hostname,
|
|
828
|
+
});
|
|
829
|
+
const state = { responseBuffer: Buffer.alloc(0), settled: false };
|
|
830
|
+
const cleanup = createWebSocketHandshakeCleanup(
|
|
831
|
+
socket,
|
|
832
|
+
onData,
|
|
833
|
+
onError,
|
|
834
|
+
onClose,
|
|
835
|
+
setTimeout(() => fail(new Error("WebSocket connect timeout")), timeoutMs),
|
|
836
|
+
);
|
|
837
|
+
const fail = (error) =>
|
|
838
|
+
settleWebSocketHandshake(
|
|
839
|
+
state,
|
|
840
|
+
cleanup,
|
|
841
|
+
() => socket.destroy(),
|
|
842
|
+
() => reject(error),
|
|
843
|
+
);
|
|
844
|
+
const succeed = (websocket) =>
|
|
845
|
+
settleWebSocketHandshake(state, cleanup, null, () => resolve(websocket));
|
|
846
|
+
function onError(error) {
|
|
847
|
+
fail(error);
|
|
848
|
+
}
|
|
849
|
+
function onClose() {
|
|
850
|
+
fail(new Error("WebSocket closed during handshake"));
|
|
851
|
+
}
|
|
852
|
+
function onData(chunk) {
|
|
853
|
+
handleWebSocketHandshakeData({
|
|
854
|
+
chunk,
|
|
855
|
+
state,
|
|
856
|
+
key,
|
|
857
|
+
socket,
|
|
858
|
+
succeed,
|
|
859
|
+
fail,
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
socket.on("data", onData);
|
|
863
|
+
socket.on("error", onError);
|
|
864
|
+
socket.on("close", onClose);
|
|
865
|
+
socket.once("secureConnect", () =>
|
|
866
|
+
socket.write(buildUpgradeRequest(parsed, key, headers)),
|
|
867
|
+
);
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function createWebSocketHandshakeCleanup(
|
|
872
|
+
socket,
|
|
873
|
+
onData,
|
|
874
|
+
onError,
|
|
875
|
+
onClose,
|
|
876
|
+
timeout,
|
|
877
|
+
) {
|
|
878
|
+
return () => {
|
|
879
|
+
clearTimeout(timeout);
|
|
880
|
+
socket.off("data", onData);
|
|
881
|
+
socket.off("error", onError);
|
|
882
|
+
socket.off("close", onClose);
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function settleWebSocketHandshake(state, cleanup, beforeSettle, settle) {
|
|
887
|
+
if (state.settled) return;
|
|
888
|
+
state.settled = true;
|
|
889
|
+
cleanup();
|
|
890
|
+
if (beforeSettle) beforeSettle();
|
|
891
|
+
settle();
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function handleWebSocketHandshakeData(context) {
|
|
895
|
+
context.state.responseBuffer = Buffer.concat([
|
|
896
|
+
context.state.responseBuffer,
|
|
897
|
+
context.chunk,
|
|
898
|
+
]);
|
|
899
|
+
const separator = context.state.responseBuffer.indexOf(
|
|
900
|
+
Buffer.from("\r\n\r\n"),
|
|
901
|
+
);
|
|
902
|
+
if (separator < 0) return;
|
|
903
|
+
const response = parseHttpResponse(
|
|
904
|
+
context.state.responseBuffer.slice(0, separator).toString("latin1"),
|
|
905
|
+
);
|
|
906
|
+
const body = context.state.responseBuffer.slice(separator + 4);
|
|
907
|
+
if (response.statusCode !== 101)
|
|
908
|
+
return context.fail(
|
|
909
|
+
new WebSocketHandshakeError(
|
|
910
|
+
`WebSocket upgrade failed: ${response.statusCode} ${response.statusText}`,
|
|
911
|
+
response.statusCode,
|
|
912
|
+
response.statusText,
|
|
913
|
+
response.headers,
|
|
914
|
+
body,
|
|
915
|
+
),
|
|
916
|
+
);
|
|
917
|
+
if (response.headers["sec-websocket-accept"] !== createAcceptKey(context.key))
|
|
918
|
+
return context.fail(new Error("Invalid Sec-WebSocket-Accept from server"));
|
|
919
|
+
const websocket = new RawWebSocket(context.socket);
|
|
920
|
+
context.succeed(websocket);
|
|
921
|
+
websocket.acceptBufferedData(body);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function buildUpgradeRequest(parsed, key, headers) {
|
|
925
|
+
const requestHeaders = {
|
|
926
|
+
Host: parsed.host,
|
|
927
|
+
Upgrade: "websocket",
|
|
928
|
+
Connection: "Upgrade",
|
|
929
|
+
"Sec-WebSocket-Key": key,
|
|
930
|
+
"Sec-WebSocket-Version": "13",
|
|
931
|
+
...headers,
|
|
932
|
+
};
|
|
933
|
+
return [
|
|
934
|
+
`GET ${parsed.pathname}${parsed.search} HTTP/1.1`,
|
|
935
|
+
...Object.entries(requestHeaders).map(
|
|
936
|
+
([name, value]) => `${name}: ${value}`,
|
|
937
|
+
),
|
|
938
|
+
"",
|
|
939
|
+
"",
|
|
940
|
+
].join("\r\n");
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function parseHttpResponse(headerText) {
|
|
944
|
+
const lines = headerText.split("\r\n");
|
|
945
|
+
const status = /^HTTP\/\d(?:\.\d)?\s+(\d{3})\s*(.*)$/.exec(
|
|
946
|
+
lines.shift() || "",
|
|
947
|
+
);
|
|
948
|
+
if (!status) throw new Error("Invalid HTTP response from websocket endpoint");
|
|
949
|
+
return {
|
|
950
|
+
statusCode: Number(status[1]),
|
|
951
|
+
statusText: status[2],
|
|
952
|
+
headers: parseLowercaseHttpHeaders(lines),
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function parseLowercaseHttpHeaders(lines) {
|
|
957
|
+
const headers = {};
|
|
958
|
+
for (const line of lines) {
|
|
959
|
+
const divider = line.indexOf(":");
|
|
960
|
+
if (divider >= 0)
|
|
961
|
+
headers[line.slice(0, divider).trim().toLowerCase()] = line
|
|
962
|
+
.slice(divider + 1)
|
|
963
|
+
.trim();
|
|
964
|
+
}
|
|
965
|
+
return headers;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
function createAcceptKey(key) {
|
|
969
|
+
return createHash("sha1").update(`${key}${WS.guid}`).digest("base64");
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
async function openSynthesisSocket(config) {
|
|
973
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
974
|
+
try {
|
|
975
|
+
return await openWebSocket(
|
|
976
|
+
buildSocketUrl(connectId()),
|
|
977
|
+
headersWithMuid(WSS_HEADERS),
|
|
978
|
+
config.connectTimeoutMs,
|
|
979
|
+
);
|
|
980
|
+
} catch (error) {
|
|
981
|
+
if (
|
|
982
|
+
attempt === 0 &&
|
|
983
|
+
error instanceof WebSocketHandshakeError &&
|
|
984
|
+
error.statusCode === 403 &&
|
|
985
|
+
adjustClockSkew(error.headers)
|
|
986
|
+
)
|
|
987
|
+
continue;
|
|
988
|
+
throw error;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
throw new Error("Unable to open Edge TTS websocket");
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// --- Synthesis ---
|
|
995
|
+
|
|
996
|
+
export async function* streamSynthesis(text, options = {}) {
|
|
997
|
+
const config = createTtsConfig(options);
|
|
998
|
+
const chunks = splitByByteLength(
|
|
999
|
+
escapeXml(removeIncompatibleCharacters(text ?? "")),
|
|
1000
|
+
);
|
|
1001
|
+
const state = {
|
|
1002
|
+
offsetCompensation: 0,
|
|
1003
|
+
chunkAudioBytes: 0,
|
|
1004
|
+
cumulativeAudioBytes: 0,
|
|
1005
|
+
};
|
|
1006
|
+
for (const chunk of chunks) {
|
|
1007
|
+
state.chunkAudioBytes = 0;
|
|
1008
|
+
yield* streamChunk(chunk, config, state);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
async function* streamChunk(escapedText, config, state) {
|
|
1013
|
+
const socket = await openSynthesisSocket(config);
|
|
1014
|
+
const queue = new AsyncQueue();
|
|
1015
|
+
const context = createSynthesisContext(socket, queue, config, state);
|
|
1016
|
+
socket.on("message", (event) => handleSynthesisSocketMessage(event, context));
|
|
1017
|
+
socket.on("error", (error) => closeSynthesisContext(context, error));
|
|
1018
|
+
socket.on("close", () =>
|
|
1019
|
+
closeSynthesisContext(
|
|
1020
|
+
context,
|
|
1021
|
+
context.audioReceived
|
|
1022
|
+
? null
|
|
1023
|
+
: new Error("Connection closed before speech synthesis completed"),
|
|
1024
|
+
),
|
|
1025
|
+
);
|
|
1026
|
+
socket.sendText(buildConfigMessage(config.boundary));
|
|
1027
|
+
socket.sendText(buildSsmlMessage(connectId(), config, escapedText));
|
|
1028
|
+
for await (const message of queue) yield message;
|
|
1029
|
+
if (!context.audioReceived)
|
|
1030
|
+
throw new Error(
|
|
1031
|
+
"No audio was received. Please verify that your parameters are correct.",
|
|
1032
|
+
);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function createSynthesisContext(socket, queue, config, state) {
|
|
1036
|
+
const context = {
|
|
1037
|
+
socket,
|
|
1038
|
+
queue,
|
|
1039
|
+
config,
|
|
1040
|
+
state,
|
|
1041
|
+
audioReceived: false,
|
|
1042
|
+
closed: false,
|
|
1043
|
+
timeoutId: null,
|
|
1044
|
+
};
|
|
1045
|
+
resetSynthesisTimeout(context);
|
|
1046
|
+
return context;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function resetSynthesisTimeout(context) {
|
|
1050
|
+
clearTimeout(context.timeoutId);
|
|
1051
|
+
context.timeoutId = setTimeout(
|
|
1052
|
+
() =>
|
|
1053
|
+
closeSynthesisContext(
|
|
1054
|
+
context,
|
|
1055
|
+
new Error("Edge TTS websocket receive timeout"),
|
|
1056
|
+
),
|
|
1057
|
+
context.config.receiveTimeoutMs,
|
|
1058
|
+
);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
function closeSynthesisContext(context, error = null) {
|
|
1062
|
+
if (context.closed) return;
|
|
1063
|
+
context.closed = true;
|
|
1064
|
+
clearTimeout(context.timeoutId);
|
|
1065
|
+
context.socket.close();
|
|
1066
|
+
context.queue.close(error);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function handleSynthesisSocketMessage(event, context) {
|
|
1070
|
+
try {
|
|
1071
|
+
resetSynthesisTimeout(context);
|
|
1072
|
+
const result = parseSynthesisSocketMessage(event, context.state);
|
|
1073
|
+
if (result.message) {
|
|
1074
|
+
context.audioReceived ||= result.message.type === "audio";
|
|
1075
|
+
context.queue.push(result.message);
|
|
1076
|
+
}
|
|
1077
|
+
if (result.done) closeSynthesisContext(context);
|
|
1078
|
+
} catch (error) {
|
|
1079
|
+
closeSynthesisContext(context, error);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
function parseSynthesisSocketMessage(event, state) {
|
|
1084
|
+
return event.isBinary
|
|
1085
|
+
? parseSynthesisBinaryMessage(event.data, state)
|
|
1086
|
+
: parseSynthesisTextMessage(event.data, state);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
function parseSynthesisTextMessage(data, state) {
|
|
1090
|
+
const { headers, data: body } = parseWssTextPayload(data);
|
|
1091
|
+
const path = (headers.Path || "").trim();
|
|
1092
|
+
if (path === "audio.metadata")
|
|
1093
|
+
return { message: parseMetadata(body, state), done: false };
|
|
1094
|
+
if (path === "turn.end") {
|
|
1095
|
+
compensateOffset(state);
|
|
1096
|
+
return { done: true };
|
|
1097
|
+
}
|
|
1098
|
+
if (path === "" || path === "response" || path === "turn.start")
|
|
1099
|
+
return { done: false };
|
|
1100
|
+
throw new Error(`Unknown websocket text path: ${path}`);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function parseSynthesisBinaryMessage(payload, state) {
|
|
1104
|
+
const { headers, data } = parseWssBinaryPayload(payload);
|
|
1105
|
+
const path = (headers.Path || "").trim();
|
|
1106
|
+
const contentType = (headers["Content-Type"] || "").trim();
|
|
1107
|
+
if (path !== "audio")
|
|
1108
|
+
throw new Error("Received binary websocket message without audio path");
|
|
1109
|
+
if (!contentType && data.length === 0) return { done: false };
|
|
1110
|
+
if (contentType !== "audio/mpeg")
|
|
1111
|
+
throw new Error(
|
|
1112
|
+
`Unsupported audio content type: ${contentType || "<empty>"}`,
|
|
1113
|
+
);
|
|
1114
|
+
if (data.length === 0)
|
|
1115
|
+
throw new Error("Received audio frame without audio data");
|
|
1116
|
+
state.chunkAudioBytes += data.length;
|
|
1117
|
+
return { message: { type: "audio", data }, done: false };
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
export async function synthesizeToBuffer(text, options = {}) {
|
|
1121
|
+
const chunks = [];
|
|
1122
|
+
for await (const message of streamSynthesis(text, options))
|
|
1123
|
+
if (message.type === "audio") chunks.push(message.data);
|
|
1124
|
+
return Buffer.concat(chunks);
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
export async function synthesizeToFile(text, outputPath, options = {}) {
|
|
1128
|
+
await saveToFiles(text, outputPath, null, options);
|
|
1129
|
+
return outputPath;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
export async function saveToFiles(
|
|
1133
|
+
text,
|
|
1134
|
+
audioPath,
|
|
1135
|
+
metadataPath = null,
|
|
1136
|
+
options = {},
|
|
1137
|
+
) {
|
|
1138
|
+
await writeSynthesisOutputs(text, { audioPath, metadataPath }, options);
|
|
1139
|
+
return audioPath;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
export async function writeSynthesisOutputs(text, outputs, options = {}) {
|
|
1143
|
+
const audioStream = outputs.audioPath
|
|
1144
|
+
? openOutputStream(outputs.audioPath, stdout)
|
|
1145
|
+
: null;
|
|
1146
|
+
const metadata = [];
|
|
1147
|
+
const boundaries = [];
|
|
1148
|
+
try {
|
|
1149
|
+
for await (const message of streamSynthesis(text, options))
|
|
1150
|
+
await collectSynthesisOutput(message, {
|
|
1151
|
+
audioStream,
|
|
1152
|
+
metadata,
|
|
1153
|
+
boundaries,
|
|
1154
|
+
});
|
|
1155
|
+
} finally {
|
|
1156
|
+
if (audioStream && audioStream !== stdout)
|
|
1157
|
+
await closeOutputStream(audioStream);
|
|
1158
|
+
}
|
|
1159
|
+
if (outputs.metadataPath)
|
|
1160
|
+
await writeTextOutput(
|
|
1161
|
+
outputs.metadataPath,
|
|
1162
|
+
formatJsonLines(metadata),
|
|
1163
|
+
stderr,
|
|
1164
|
+
);
|
|
1165
|
+
if (outputs.subtitlePath)
|
|
1166
|
+
await writeTextOutput(outputs.subtitlePath, composeSrt(boundaries), stderr);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
async function collectSynthesisOutput(message, output) {
|
|
1170
|
+
if (message.type === "audio") {
|
|
1171
|
+
if (output.audioStream) await writeChunk(output.audioStream, message.data);
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
output.boundaries.push(message);
|
|
1175
|
+
output.metadata.push(message);
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// --- Output Writers ---
|
|
1179
|
+
|
|
1180
|
+
function openOutputStream(path, stdioStream) {
|
|
1181
|
+
return path === "-" ? stdioStream : createWriteStream(path);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function writeTextOutput(path, value, stdioStream) {
|
|
1185
|
+
if (path === "-") {
|
|
1186
|
+
stdioStream.write(value);
|
|
1187
|
+
return Promise.resolve();
|
|
1188
|
+
}
|
|
1189
|
+
return writeFile(path, value);
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
function writeChunk(stream, chunk) {
|
|
1193
|
+
return new Promise((resolve, reject) =>
|
|
1194
|
+
stream.write(chunk, (error) => (error ? reject(error) : resolve())),
|
|
1195
|
+
);
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function closeOutputStream(stream) {
|
|
1199
|
+
return new Promise((resolve, reject) => {
|
|
1200
|
+
stream.once("error", reject);
|
|
1201
|
+
stream.end(() => {
|
|
1202
|
+
stream.off("error", reject);
|
|
1203
|
+
resolve();
|
|
1204
|
+
});
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
function formatJsonLines(messages) {
|
|
1209
|
+
return messages.length === 0
|
|
1210
|
+
? ""
|
|
1211
|
+
: `${messages.map((message) => JSON.stringify(message)).join("\n")}\n`;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
function composeSrt(messages) {
|
|
1215
|
+
return messages.filter(isBoundaryMessage).map(formatSrtCue).join("\n");
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
function isBoundaryMessage(message) {
|
|
1219
|
+
return message.type === "WordBoundary" || message.type === "SentenceBoundary";
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function formatSrtCue(message, index) {
|
|
1223
|
+
return `${index + 1}\n${ticksToSrtTime(message.offset)} --> ${ticksToSrtTime(message.offset + message.duration)}\n${legalSubtitleContent(message.text)}\n`;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
function ticksToSrtTime(ticks) {
|
|
1227
|
+
const milliseconds = Math.floor(ticks / 10_000);
|
|
1228
|
+
const hours = Math.floor(milliseconds / 3_600_000);
|
|
1229
|
+
const minutes = Math.floor((milliseconds % 3_600_000) / 60_000);
|
|
1230
|
+
const seconds = Math.floor((milliseconds % 60_000) / 1000);
|
|
1231
|
+
const ms = milliseconds % 1000;
|
|
1232
|
+
return `${pad2(hours)}:${pad2(minutes)}:${pad2(seconds)},${String(ms).padStart(3, "0")}`;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
function legalSubtitleContent(value) {
|
|
1236
|
+
return String(value)
|
|
1237
|
+
.replace(/\n\n+/g, "\n")
|
|
1238
|
+
.replace(/^\n+|\n+$/g, "");
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// --- Voices ---
|
|
1242
|
+
|
|
1243
|
+
export async function listVoices(options = {}) {
|
|
1244
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1245
|
+
try {
|
|
1246
|
+
return normalizeVoiceList(
|
|
1247
|
+
JSON.parse(
|
|
1248
|
+
(
|
|
1249
|
+
await httpsGetBuffer(
|
|
1250
|
+
buildVoiceListUrl(),
|
|
1251
|
+
headersWithMuid({
|
|
1252
|
+
...VOICE_HEADERS,
|
|
1253
|
+
"Accept-Encoding": "identity",
|
|
1254
|
+
}),
|
|
1255
|
+
options.timeoutMs ?? 10_000,
|
|
1256
|
+
)
|
|
1257
|
+
).toString("utf-8"),
|
|
1258
|
+
),
|
|
1259
|
+
);
|
|
1260
|
+
} catch (error) {
|
|
1261
|
+
if (
|
|
1262
|
+
attempt === 0 &&
|
|
1263
|
+
error instanceof HttpResponseError &&
|
|
1264
|
+
error.statusCode === 403 &&
|
|
1265
|
+
adjustClockSkew(error.headers)
|
|
1266
|
+
)
|
|
1267
|
+
continue;
|
|
1268
|
+
throw error;
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
throw new Error("Unable to list Edge TTS voices");
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
function normalizeVoiceList(voices) {
|
|
1275
|
+
for (const voice of voices) {
|
|
1276
|
+
voice.VoiceTag ||= {};
|
|
1277
|
+
voice.VoiceTag.ContentCategories ||= [];
|
|
1278
|
+
voice.VoiceTag.VoicePersonalities ||= [];
|
|
1279
|
+
}
|
|
1280
|
+
return voices;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// --- Playback ---
|
|
1284
|
+
|
|
1285
|
+
async function playFileDetached(outputPath, temporaryBase) {
|
|
1286
|
+
const player = selectPlaybackCommand();
|
|
1287
|
+
if (!player)
|
|
1288
|
+
throw new Error(
|
|
1289
|
+
`No MP3 playback command found. Save media with --write-media or install one of: ${PLAYBACK_CANDIDATES.map(playbackCandidateName).join(", ")}.`,
|
|
1290
|
+
);
|
|
1291
|
+
const launch = player.launch
|
|
1292
|
+
? await player.launch(outputPath, temporaryBase)
|
|
1293
|
+
: createBlockingPlaybackLaunch(player, outputPath, temporaryBase);
|
|
1294
|
+
const child = spawn(launch.command, launch.args, {
|
|
1295
|
+
detached: true,
|
|
1296
|
+
stdio: "ignore",
|
|
1297
|
+
windowsHide: true,
|
|
1298
|
+
});
|
|
1299
|
+
child.unref();
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
function selectPlaybackCommand() {
|
|
1303
|
+
return PLAYBACK_CANDIDATES.find(isPlaybackCandidateAvailable);
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
function isPlaybackCandidateAvailable(candidate) {
|
|
1307
|
+
return candidate.available
|
|
1308
|
+
? candidate.available()
|
|
1309
|
+
: commandExists(candidate.command);
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
function playbackCandidateName(candidate) {
|
|
1313
|
+
return candidate.label || candidate.command;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
function commandExists(command) {
|
|
1317
|
+
const result = IS_WINDOWS
|
|
1318
|
+
? spawnSync("where", [command], { stdio: "ignore", windowsHide: true })
|
|
1319
|
+
: spawnSync(
|
|
1320
|
+
"sh",
|
|
1321
|
+
["-c", 'command -v "$1" >/dev/null 2>&1', "check-command", command],
|
|
1322
|
+
{ stdio: "ignore" },
|
|
1323
|
+
);
|
|
1324
|
+
return result.status === 0;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
function createBlockingPlaybackLaunch(player, outputPath, temporaryBase) {
|
|
1328
|
+
return IS_WINDOWS
|
|
1329
|
+
? createWindowsBlockingPlaybackLaunch(player, outputPath, temporaryBase)
|
|
1330
|
+
: createPosixBlockingPlaybackLaunch(player, outputPath, temporaryBase);
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
function createPosixBlockingPlaybackLaunch(player, outputPath, temporaryBase) {
|
|
1334
|
+
return {
|
|
1335
|
+
command: "sh",
|
|
1336
|
+
args: [
|
|
1337
|
+
"-c",
|
|
1338
|
+
'file="$1"; dir="$2"; shift 2; "$@"; rm -f "$file"; rmdir "$dir" 2>/dev/null || true',
|
|
1339
|
+
"edge-direct-player",
|
|
1340
|
+
outputPath,
|
|
1341
|
+
temporaryBase,
|
|
1342
|
+
player.command,
|
|
1343
|
+
...player.args(outputPath),
|
|
1344
|
+
],
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
function createWindowsBlockingPlaybackLaunch(
|
|
1349
|
+
player,
|
|
1350
|
+
outputPath,
|
|
1351
|
+
temporaryBase,
|
|
1352
|
+
) {
|
|
1353
|
+
return {
|
|
1354
|
+
command: "powershell.exe",
|
|
1355
|
+
args: [
|
|
1356
|
+
"-NoProfile",
|
|
1357
|
+
"-ExecutionPolicy",
|
|
1358
|
+
"Bypass",
|
|
1359
|
+
"-Command",
|
|
1360
|
+
"$file=$args[0]; $dir=$args[1]; $cmd=$args[2]; $playerArgs=@(); if ($args.Length -gt 3) { $playerArgs=$args[3..($args.Length-1)] }; & $cmd @playerArgs; Remove-Item -LiteralPath $file -Force -ErrorAction SilentlyContinue; Remove-Item -LiteralPath $dir -Force -ErrorAction SilentlyContinue",
|
|
1361
|
+
outputPath,
|
|
1362
|
+
temporaryBase,
|
|
1363
|
+
player.command,
|
|
1364
|
+
...player.args(outputPath),
|
|
1365
|
+
],
|
|
1366
|
+
};
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
async function createWindowsDefaultPlaybackLaunch(outputPath, temporaryBase) {
|
|
1370
|
+
const cleanupDelaySeconds = String(
|
|
1371
|
+
await estimateCleanupDelaySeconds(outputPath),
|
|
1372
|
+
);
|
|
1373
|
+
return {
|
|
1374
|
+
command: "powershell.exe",
|
|
1375
|
+
args: [
|
|
1376
|
+
"-NoProfile",
|
|
1377
|
+
"-ExecutionPolicy",
|
|
1378
|
+
"Bypass",
|
|
1379
|
+
"-Command",
|
|
1380
|
+
"$file=$args[0]; $dir=$args[1]; $delay=[int]$args[2]; Start-Process -FilePath $file; Start-Sleep -Seconds $delay; Remove-Item -LiteralPath $file -Force -ErrorAction SilentlyContinue; Remove-Item -LiteralPath $dir -Force -ErrorAction SilentlyContinue",
|
|
1381
|
+
outputPath,
|
|
1382
|
+
temporaryBase,
|
|
1383
|
+
cleanupDelaySeconds,
|
|
1384
|
+
],
|
|
1385
|
+
};
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
async function estimateCleanupDelaySeconds(outputPath) {
|
|
1389
|
+
const info = await stat(outputPath);
|
|
1390
|
+
const playbackSeconds = Math.ceil((info.size * 8) / AUDIO.mp3BitrateBps);
|
|
1391
|
+
return Math.min(Math.max(playbackSeconds + 30, 120), 3600);
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
async function runPlayback(text, options) {
|
|
1395
|
+
const temporaryBase = await mkdtemp(join(tmpdir(), "edge-direct-"));
|
|
1396
|
+
const outputPath = join(temporaryBase, `edge-${randomHex(6)}.mp3`);
|
|
1397
|
+
try {
|
|
1398
|
+
await synthesizeToFile(text, outputPath, options);
|
|
1399
|
+
await playFileDetached(outputPath, temporaryBase);
|
|
1400
|
+
stdout.write(`${text}\n`);
|
|
1401
|
+
} catch (error) {
|
|
1402
|
+
await rm(temporaryBase, { recursive: true, force: true }).catch(() => {});
|
|
1403
|
+
throw error;
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
// --- CLI ---
|
|
1408
|
+
|
|
1409
|
+
function parseCliArgs(args) {
|
|
1410
|
+
const parsed = { positionals: [] };
|
|
1411
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1412
|
+
const arg = args[index];
|
|
1413
|
+
if (CLI_FLAGS.has(arg)) parsed[CLI_FLAGS.get(arg)] = true;
|
|
1414
|
+
else if (CLI_VALUES.has(arg))
|
|
1415
|
+
parsed[CLI_VALUES.get(arg)] = readOptionValue(args, ++index, arg);
|
|
1416
|
+
else if (arg.startsWith("--")) throw new Error(`Unknown option: ${arg}`);
|
|
1417
|
+
else parsed.positionals.push(arg);
|
|
1418
|
+
}
|
|
1419
|
+
return parsed;
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
function readOptionValue(args, index, name) {
|
|
1423
|
+
const value = args[index];
|
|
1424
|
+
if (value === undefined) throw new Error(`${name} requires a value`);
|
|
1425
|
+
return value;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
function isOptionModeArg(arg) {
|
|
1429
|
+
return arg.startsWith("--") || CLI_FLAGS.has(arg) || CLI_VALUES.has(arg);
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
function printHelp() {
|
|
1433
|
+
stdout.write(
|
|
1434
|
+
`${[
|
|
1435
|
+
"Usage:",
|
|
1436
|
+
" say.mjs <text> [lang] [rate] [media.mp3] [metadata.jsonl]",
|
|
1437
|
+
" say.mjs --text <text> [--voice <voice>] [--rate <rate>] [--write-media <path>]",
|
|
1438
|
+
" say.mjs --file <path> --write-media <path> [--write-subtitles <path>]",
|
|
1439
|
+
" say.mjs --list-voices",
|
|
1440
|
+
"",
|
|
1441
|
+
"Options:",
|
|
1442
|
+
" -t, --text Text to synthesize",
|
|
1443
|
+
" -f, --file Read text from file, - means stdin",
|
|
1444
|
+
" -v, --voice Voice short name or full service voice name",
|
|
1445
|
+
" --lang Language used for default voice selection",
|
|
1446
|
+
" --rate Rate: +30% or numeric multiplier, e.g. 1 = +100%",
|
|
1447
|
+
" --volume Volume: +0% or numeric multiplier",
|
|
1448
|
+
" --pitch Pitch: +0Hz",
|
|
1449
|
+
" --boundary SentenceBoundary or WordBoundary",
|
|
1450
|
+
" --write-media Write MP3 to path, - means stdout",
|
|
1451
|
+
" --write-subtitles Write SRT subtitles to path, - means stderr",
|
|
1452
|
+
" --write-metadata Write boundary metadata JSONL to path",
|
|
1453
|
+
"",
|
|
1454
|
+
"Playback auto-detects: ffplay, mpv, vlc/cvlc, mpg123, afplay, default MP3 app.",
|
|
1455
|
+
"If no player exists, use --write-media and play the MP3 externally.",
|
|
1456
|
+
].join("\n")}\n`,
|
|
1457
|
+
);
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
async function runOptionCli(options) {
|
|
1461
|
+
if (options.help) return printHelp();
|
|
1462
|
+
if (options.listVoices)
|
|
1463
|
+
return stdout.write(`${JSON.stringify(await listVoices(), null, 2)}\n`);
|
|
1464
|
+
const text = await resolveCliText(options);
|
|
1465
|
+
if (!text) return;
|
|
1466
|
+
const synthesisOptions = createCliSynthesisOptions(options);
|
|
1467
|
+
if (!options.writeMedia) return runPlayback(text, synthesisOptions);
|
|
1468
|
+
await writeSynthesisOutputs(
|
|
1469
|
+
text,
|
|
1470
|
+
{
|
|
1471
|
+
audioPath: options.writeMedia,
|
|
1472
|
+
subtitlePath: options.writeSubtitles,
|
|
1473
|
+
metadataPath: options.writeMetadata,
|
|
1474
|
+
},
|
|
1475
|
+
synthesisOptions,
|
|
1476
|
+
);
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
async function resolveCliText(options) {
|
|
1480
|
+
if (options.file) return readInputFile(options.file);
|
|
1481
|
+
return readText(options.text || "");
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
function createCliSynthesisOptions(options) {
|
|
1485
|
+
const language = normalizeLanguage(options.language || DEFAULTS.language);
|
|
1486
|
+
return {
|
|
1487
|
+
language,
|
|
1488
|
+
voice: options.voice || VOICES_BY_LANG[language] || VOICES_BY_LANG.en,
|
|
1489
|
+
rate: options.rate,
|
|
1490
|
+
volume: options.volume,
|
|
1491
|
+
pitch: options.pitch,
|
|
1492
|
+
boundary: options.boundary,
|
|
1493
|
+
};
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
async function runPositionalCli(args) {
|
|
1497
|
+
const text = await readText(args[0] || "");
|
|
1498
|
+
if (!text) return;
|
|
1499
|
+
const language = normalizeLanguage(args[1]);
|
|
1500
|
+
const rate = normalizeRate(args[2]);
|
|
1501
|
+
const outputPath = args[3];
|
|
1502
|
+
const metadataPath = args[4];
|
|
1503
|
+
const voice = VOICES_BY_LANG[language] || VOICES_BY_LANG.en;
|
|
1504
|
+
if (!outputPath) return runPlayback(text, { language, rate, voice });
|
|
1505
|
+
await saveToFiles(text, outputPath, metadataPath || null, {
|
|
1506
|
+
language,
|
|
1507
|
+
rate,
|
|
1508
|
+
voice,
|
|
1509
|
+
});
|
|
1510
|
+
if (outputPath !== "-") stdout.write(`${outputPath}\n`);
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
async function readText(value) {
|
|
1514
|
+
if (value) return value;
|
|
1515
|
+
if (stdin.isTTY) return "";
|
|
1516
|
+
let input = "";
|
|
1517
|
+
for await (const chunk of stdin) input += chunk;
|
|
1518
|
+
return input.replace(/\n/g, "");
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
async function readInputFile(path) {
|
|
1522
|
+
if (path === "-" || path === "/dev/stdin") return readText("");
|
|
1523
|
+
return readFile(path, "utf-8");
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
async function main() {
|
|
1527
|
+
const cliArgs = argv.slice(2);
|
|
1528
|
+
if (cliArgs.some(isOptionModeArg)) await runOptionCli(parseCliArgs(cliArgs));
|
|
1529
|
+
else await runPositionalCli(cliArgs);
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
function fail(error) {
|
|
1533
|
+
stderr.write(`say.mjs failed: ${error?.message || String(error)}\n`);
|
|
1534
|
+
exit(1);
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
// --- Entrypoint ---
|
|
1538
|
+
|
|
1539
|
+
function isDirectCliEntrypoint(metaUrl, entryPath) {
|
|
1540
|
+
if (!entryPath) return false;
|
|
1541
|
+
try {
|
|
1542
|
+
return realpathSync(fileURLToPath(metaUrl)) === realpathSync(entryPath);
|
|
1543
|
+
} catch {
|
|
1544
|
+
return metaUrl === pathToFileURL(entryPath).href;
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
if (isDirectCliEntrypoint(import.meta.url, argv[1])) main().catch(fail);
|