agello 0.4.2 → 0.4.4
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 +36 -3
- package/package.json +1 -1
- package/src/page-audio.ts +159 -0
- package/src/player.ts +51 -4
- package/src/screencast.ts +68 -2
- package/src/server.ts +47 -9
- package/src/tts.ts +289 -0
- package/web/agent-bridge.js +338 -36
- package/web/presentation-media.js +459 -0
package/src/server.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// (images are saved to $TMPDIR/agello-uploads and sent as [image: <path>])
|
|
9
9
|
// (action "annotate": + target {selector, label, rect, text?, url?} from inspect)
|
|
10
10
|
// GET /screen SSE: terminal-browser screen frames (CDP screencast)
|
|
11
|
+
// GET /screen/audio SSE: tab audio as PCM chunks; muted in the browser while listened
|
|
11
12
|
// POST /upload {images: [{type, data(base64)}]} -> {paths}: terminal image paste
|
|
12
13
|
// WS /terminal interactive herdr terminal frames, keyboard input, resize
|
|
13
14
|
// GET /panes herdr workspace -> tab -> pane tree
|
|
@@ -31,6 +32,7 @@ import { join } from "node:path";
|
|
|
31
32
|
import { runJson } from "./run.ts";
|
|
32
33
|
import { createPlayer, parsePos, parseScript, type PlayerInfo } from "./player.ts";
|
|
33
34
|
import { createScreencast, type Inspected, type Rect } from "./screencast.ts";
|
|
35
|
+
import { createTts } from "./tts.ts";
|
|
34
36
|
|
|
35
37
|
export type ServerOptions = {
|
|
36
38
|
port: number;
|
|
@@ -189,12 +191,21 @@ export async function startServer(opts: ServerOptions) {
|
|
|
189
191
|
// ----- SSE clients -----
|
|
190
192
|
|
|
191
193
|
const clients = new Set<ReadableStreamDefaultController>();
|
|
194
|
+
const viewers = new Map<ReadableStreamDefaultController, string | null>();
|
|
195
|
+
const clientPings = new Map<ReadableStreamDefaultController, Timer>();
|
|
196
|
+
const dropClient = (ctrl: ReadableStreamDefaultController) => {
|
|
197
|
+
if (!clients.delete(ctrl)) return;
|
|
198
|
+
clearInterval(clientPings.get(ctrl));
|
|
199
|
+
clientPings.delete(ctrl);
|
|
200
|
+
tts.disconnect(viewers.get(ctrl) ?? null);
|
|
201
|
+
viewers.delete(ctrl);
|
|
202
|
+
};
|
|
192
203
|
const enc = new TextEncoder();
|
|
193
204
|
const send = (ctrl: ReadableStreamDefaultController, event: string, data: unknown) => {
|
|
194
205
|
try {
|
|
195
206
|
ctrl.enqueue(enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
|
|
196
207
|
} catch {
|
|
197
|
-
|
|
208
|
+
dropClient(ctrl);
|
|
198
209
|
}
|
|
199
210
|
};
|
|
200
211
|
const broadcast = (event: string, data: unknown) => clients.forEach((c) => send(c, event, data));
|
|
@@ -382,13 +393,16 @@ export async function startServer(opts: ServerOptions) {
|
|
|
382
393
|
|
|
383
394
|
// Script player: agello owns order and timing, the agent owns the deck
|
|
384
395
|
// (each step's `go`) and decides when to resume after a question.
|
|
396
|
+
const tts = createTts({ emit: broadcast, pause: () => player.pause() });
|
|
385
397
|
const player = createPlayer({
|
|
386
398
|
async go(expr) {
|
|
387
399
|
const js = expr.startsWith("#") ? `location.hash = ${JSON.stringify(expr)}` : expr;
|
|
388
400
|
if (!(await screen.evaluate(js))) console.error(`present: go failed: ${expr}`);
|
|
389
401
|
},
|
|
390
|
-
|
|
391
|
-
|
|
402
|
+
narrate: tts.narrate,
|
|
403
|
+
say(text, at, hold, narration) {
|
|
404
|
+
broadcast("message", { role: "assistant", text, ts: new Date().toISOString(), script: at, hold,
|
|
405
|
+
...(narration ? { narration } : {}) });
|
|
392
406
|
},
|
|
393
407
|
done() {
|
|
394
408
|
const n = player.info().steps;
|
|
@@ -482,13 +496,15 @@ export async function startServer(opts: ServerOptions) {
|
|
|
482
496
|
return res;
|
|
483
497
|
}
|
|
484
498
|
|
|
485
|
-
function events(): Response {
|
|
499
|
+
function events(viewer: string | null): Response {
|
|
486
500
|
let ctrl!: ReadableStreamDefaultController;
|
|
487
501
|
let ping: Timer;
|
|
488
502
|
const stream = new ReadableStream({
|
|
489
503
|
start(c) {
|
|
490
504
|
ctrl = c;
|
|
491
505
|
clients.add(c);
|
|
506
|
+
viewers.set(c, viewer);
|
|
507
|
+
tts.connect(viewer);
|
|
492
508
|
send(c, "hello", {
|
|
493
509
|
transcript: !!transcript,
|
|
494
510
|
pane: PANE,
|
|
@@ -498,12 +514,16 @@ export async function startServer(opts: ServerOptions) {
|
|
|
498
514
|
});
|
|
499
515
|
if (lastStatus) send(c, "status", lastStatus);
|
|
500
516
|
send(c, "present", presentView());
|
|
517
|
+
send(c, "tts", tts.status());
|
|
518
|
+
const narration = tts.currentNarration();
|
|
519
|
+
if (narration) send(c, "message", { role: "assistant", ts: new Date().toISOString(), ...narration });
|
|
501
520
|
for (const t of pendingTools.values()) send(c, "tool_start", t);
|
|
502
521
|
ping = setInterval(() => send(c, "ping", {}), 15000);
|
|
522
|
+
clientPings.set(c, ping);
|
|
503
523
|
},
|
|
504
524
|
cancel() {
|
|
505
525
|
clearInterval(ping);
|
|
506
|
-
|
|
526
|
+
dropClient(ctrl);
|
|
507
527
|
},
|
|
508
528
|
});
|
|
509
529
|
return new Response(stream, {
|
|
@@ -530,10 +550,11 @@ export async function startServer(opts: ServerOptions) {
|
|
|
530
550
|
if (!t || !text.trim()) return Response.json({ ok: false, error: "invalid_target" }, { status: 400 });
|
|
531
551
|
text = t;
|
|
532
552
|
}
|
|
533
|
-
// Raised hand: stop the script at once (no agent round trip)
|
|
534
|
-
// agent where it stopped.
|
|
553
|
+
// Raised hand: stop the script at once (no agent round trip), rewind so
|
|
554
|
+
// resume replays the interrupted line, and tell the agent where it stopped.
|
|
535
555
|
if (action === "hand-raise") {
|
|
536
556
|
player.pause();
|
|
557
|
+
player.rewind();
|
|
537
558
|
const at = where();
|
|
538
559
|
if (at) text += ` (대본 정지: ${at})`;
|
|
539
560
|
}
|
|
@@ -545,6 +566,8 @@ export async function startServer(opts: ServerOptions) {
|
|
|
545
566
|
}
|
|
546
567
|
|
|
547
568
|
async function route(req: Request, url: URL): Promise<Response> {
|
|
569
|
+
const ttsResponse = await tts.handle(req, url.pathname);
|
|
570
|
+
if (ttsResponse) return ttsResponse;
|
|
548
571
|
if (url.pathname.startsWith("/panes")) {
|
|
549
572
|
const res = await panesRoute(req, url.pathname, PANE);
|
|
550
573
|
if (res) return res;
|
|
@@ -558,9 +581,11 @@ export async function startServer(opts: ServerOptions) {
|
|
|
558
581
|
present: presentView(),
|
|
559
582
|
});
|
|
560
583
|
case "/events":
|
|
561
|
-
return events();
|
|
584
|
+
return events(url.searchParams.get("viewer"));
|
|
562
585
|
case "/screen":
|
|
563
586
|
return screen.handle();
|
|
587
|
+
case "/screen/audio":
|
|
588
|
+
return screen.handleAudio();
|
|
564
589
|
case "/terminal.js":
|
|
565
590
|
return new Response(terminalJS, { headers: { "Content-Type": "text/javascript; charset=utf-8" } });
|
|
566
591
|
case "/terminal.css":
|
|
@@ -569,6 +594,10 @@ export async function startServer(opts: ServerOptions) {
|
|
|
569
594
|
return new Response(COMPONENT, {
|
|
570
595
|
headers: { "Content-Type": "text/javascript; charset=utf-8", "Cache-Control": "no-store" },
|
|
571
596
|
});
|
|
597
|
+
case "/presentation-media.js":
|
|
598
|
+
return new Response(Bun.file(join(WEB_DIR, "presentation-media.js")), {
|
|
599
|
+
headers: { "Content-Type": "text/javascript; charset=utf-8", "Cache-Control": "no-store" },
|
|
600
|
+
});
|
|
572
601
|
case "/send":
|
|
573
602
|
if (req.method === "POST") return sendPrompt(req);
|
|
574
603
|
break;
|
|
@@ -622,7 +651,7 @@ export async function startServer(opts: ServerOptions) {
|
|
|
622
651
|
async fetch(req, server) {
|
|
623
652
|
const url = new URL(req.url);
|
|
624
653
|
const origin = req.headers.get("Origin");
|
|
625
|
-
const isScript = ["/agent-bridge.js", "/terminal.js", "/terminal.css"].includes(url.pathname);
|
|
654
|
+
const isScript = ["/agent-bridge.js", "/presentation-media.js", "/terminal.js", "/terminal.css"].includes(url.pathname);
|
|
626
655
|
|
|
627
656
|
// The component script itself is public; data and input endpoints are origin-checked.
|
|
628
657
|
if (!isScript && !originAllowed(origin, url.origin))
|
|
@@ -656,6 +685,15 @@ export async function startServer(opts: ServerOptions) {
|
|
|
656
685
|
session: SESSION,
|
|
657
686
|
transcript,
|
|
658
687
|
stop() {
|
|
688
|
+
player.stop();
|
|
689
|
+
tts.stop();
|
|
690
|
+
for (const client of clients) {
|
|
691
|
+
clearInterval(clientPings.get(client));
|
|
692
|
+
try { client.close(); } catch {}
|
|
693
|
+
}
|
|
694
|
+
clients.clear();
|
|
695
|
+
viewers.clear();
|
|
696
|
+
clientPings.clear();
|
|
659
697
|
terminal.stop();
|
|
660
698
|
timers.forEach(clearInterval);
|
|
661
699
|
server.stop(true);
|
package/src/tts.ts
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// Session-only Typecast credentials and single-viewer narration coordination.
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
export const TYPECAST = {
|
|
6
|
+
endpoint: "https://api.typecast.ai/v1/text-to-speech/with-timestamps",
|
|
7
|
+
model: "ssfm-v30", language: "kor", voiceId: "tc_6a0e85a97f7750959b970d5d",
|
|
8
|
+
};
|
|
9
|
+
export type TtsStatus = {
|
|
10
|
+
provider: "typecast"; configured: boolean; enabled: boolean;
|
|
11
|
+
voiceId: string; language: string; model: string; driverId?: string; error?: string;
|
|
12
|
+
};
|
|
13
|
+
export type SpeechEvent = {
|
|
14
|
+
phase: "ready" | "cancel" | "ended" | "error"; id: string; driverId?: string;
|
|
15
|
+
at?: string; text?: string; audioUrl?: string; duration?: number; error?: string;
|
|
16
|
+
};
|
|
17
|
+
type Audio = { bytes: Uint8Array; duration: number };
|
|
18
|
+
type Active = {
|
|
19
|
+
id: string; started: boolean; duration: number; timer?: Timer;
|
|
20
|
+
text: string; at: string;
|
|
21
|
+
start: (duration: number, id: string) => void;
|
|
22
|
+
finish: () => void; fail: (error: Error) => void;
|
|
23
|
+
};
|
|
24
|
+
const VIEWER = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
25
|
+
const MAX_AUDIO = 20 * 1024 * 1024;
|
|
26
|
+
const abortError = () => new DOMException("Cancelled", "AbortError");
|
|
27
|
+
|
|
28
|
+
// Parse only the named credential; no shell evaluation, expansion or .env loading.
|
|
29
|
+
export function parseTypecastKey(source: string): string | undefined {
|
|
30
|
+
for (const line of source.split(/\r?\n/)) {
|
|
31
|
+
const match = line.match(/^\s*(?:export\s+)?TYPECAST_API_KEY\s*=\s*(.*)$/);
|
|
32
|
+
if (!match) continue;
|
|
33
|
+
let value = match[1].trim();
|
|
34
|
+
if (value.startsWith('"') || value.startsWith("'")) {
|
|
35
|
+
const quote = value[0];
|
|
36
|
+
const end = value.indexOf(quote, 1);
|
|
37
|
+
if (end < 0 || !/^\s*(?:#.*)?$/.test(value.slice(end + 1))) return undefined;
|
|
38
|
+
value = value.slice(1, end);
|
|
39
|
+
} else value = value.replace(/\s+#.*$/, "").trim();
|
|
40
|
+
return value || undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function synthesizeTypecast(
|
|
45
|
+
apiKey: string, voiceId: string, text: string, signal: AbortSignal,
|
|
46
|
+
doFetch: typeof fetch = fetch,
|
|
47
|
+
): Promise<Audio> {
|
|
48
|
+
if (!text.trim() || [...text].length > 2000) throw new Error("tts_text_limit");
|
|
49
|
+
let response: Response;
|
|
50
|
+
try {
|
|
51
|
+
response = await doFetch(TYPECAST.endpoint + "?granularity=word", {
|
|
52
|
+
method: "POST", signal,
|
|
53
|
+
headers: { "X-API-KEY": apiKey, "Content-Type": "application/json" },
|
|
54
|
+
body: JSON.stringify({ model: TYPECAST.model, text, voice_id: voiceId, language: TYPECAST.language,
|
|
55
|
+
prompt: { emotion_type: "preset", emotion_preset: "normal", emotion_intensity: 1 },
|
|
56
|
+
output: { audio_format: "mp3", target_lufs: -14 } }),
|
|
57
|
+
});
|
|
58
|
+
} catch { throw new Error(signal.aborted ? "tts_cancelled" : "tts_network_error"); }
|
|
59
|
+
// Provider bodies and exceptions can contain credentials: never forward them.
|
|
60
|
+
if (!response.ok) throw new Error(response.status === 401 || response.status === 403 ? "tts_invalid_key" : "tts_provider_error");
|
|
61
|
+
let data: any;
|
|
62
|
+
try { data = await response.json(); } catch { throw new Error("tts_invalid_audio"); }
|
|
63
|
+
if (typeof data.audio !== "string" || !data.audio || data.audio.length > MAX_AUDIO * 1.4 ||
|
|
64
|
+
data.audio_format !== "mp3" || !Number.isFinite(data.audio_duration) || data.audio_duration <= 0 || data.audio_duration > 600)
|
|
65
|
+
throw new Error("tts_invalid_audio");
|
|
66
|
+
const bytes = Buffer.from(data.audio, "base64");
|
|
67
|
+
if (!bytes.length || bytes.length > MAX_AUDIO) throw new Error("tts_invalid_audio");
|
|
68
|
+
return { bytes, duration: data.audio_duration };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createTts(deps: {
|
|
72
|
+
emit: (event: "tts" | "speech", data: TtsStatus | SpeechEvent) => void;
|
|
73
|
+
pause: () => void;
|
|
74
|
+
apiKey?: string; cwd?: string; fetch?: typeof fetch; startTimeoutMs?: number; prepareTimeoutMs?: number;
|
|
75
|
+
readLocalKey?: () => Promise<string | undefined>;
|
|
76
|
+
}) {
|
|
77
|
+
let apiKey = deps.apiKey ?? process.env.TYPECAST_API_KEY ?? "";
|
|
78
|
+
let enabled = false;
|
|
79
|
+
let voiceId = TYPECAST.voiceId;
|
|
80
|
+
let driverId: string | undefined;
|
|
81
|
+
let driverToken: string | undefined;
|
|
82
|
+
let error: string | undefined;
|
|
83
|
+
let active: Active | undefined;
|
|
84
|
+
let preparing: AbortController | undefined;
|
|
85
|
+
const connected = new Map<string, number>();
|
|
86
|
+
const cache = new Map<string, Audio>();
|
|
87
|
+
const urls = new Map<string, Audio & { expires: number }>();
|
|
88
|
+
let cacheBytes = 0;
|
|
89
|
+
let stopped = false;
|
|
90
|
+
let configuration = 0;
|
|
91
|
+
|
|
92
|
+
const status = (): TtsStatus => ({ provider: "typecast", configured: !!apiKey, enabled,
|
|
93
|
+
voiceId, language: TYPECAST.language, model: TYPECAST.model,
|
|
94
|
+
...(driverId ? { driverId } : {}), ...(error ? { error } : {}) });
|
|
95
|
+
const changed = () => deps.emit("tts", status());
|
|
96
|
+
const clearAudio = () => { cache.clear(); urls.clear(); cacheBytes = 0; };
|
|
97
|
+
const pruneUrls = () => {
|
|
98
|
+
for (const [id, audio] of urls) if (audio.expires <= Date.now() && id !== active?.id) urls.delete(id);
|
|
99
|
+
let bytes = [...urls.values()].reduce((sum, audio) => sum + audio.bytes.byteLength, 0);
|
|
100
|
+
while (urls.size > 16 || bytes > 40 * 1024 * 1024) {
|
|
101
|
+
const oldest = [...urls.keys()].find((id) => id !== active?.id);
|
|
102
|
+
if (!oldest) break;
|
|
103
|
+
bytes -= urls.get(oldest)!.bytes.byteLength;
|
|
104
|
+
urls.delete(oldest);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
function fail(code: string) {
|
|
108
|
+
error = code;
|
|
109
|
+
const item = active;
|
|
110
|
+
if (item) {
|
|
111
|
+
active = undefined;
|
|
112
|
+
clearTimeout(item.timer);
|
|
113
|
+
deps.emit("speech", { phase: "error", id: item.id, driverId, error: code });
|
|
114
|
+
item.fail(new Error(code));
|
|
115
|
+
}
|
|
116
|
+
preparing?.abort();
|
|
117
|
+
changed();
|
|
118
|
+
deps.pause();
|
|
119
|
+
}
|
|
120
|
+
function cancel() {
|
|
121
|
+
preparing?.abort();
|
|
122
|
+
preparing = undefined;
|
|
123
|
+
const item = active;
|
|
124
|
+
if (!item) return;
|
|
125
|
+
active = undefined;
|
|
126
|
+
clearTimeout(item.timer);
|
|
127
|
+
deps.emit("speech", { phase: "cancel", id: item.id, driverId });
|
|
128
|
+
item.fail(abortError());
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function configure(data: any): Promise<{ ok: boolean; tts: TtsStatus; driverToken?: string; error?: string }> {
|
|
132
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) return { ok: false, tts: status(), error: "invalid_tts_config" };
|
|
133
|
+
for (const field of ["enabled", "useLocalKey", "clearKey"]) if (data[field] !== undefined && typeof data[field] !== "boolean")
|
|
134
|
+
return { ok: false, tts: status(), error: "invalid_tts_config" };
|
|
135
|
+
if (data.apiKey !== undefined && (typeof data.apiKey !== "string" || data.apiKey.length > 4096 || /[\r\n]/.test(data.apiKey)))
|
|
136
|
+
return { ok: false, tts: status(), error: "invalid_tts_config" };
|
|
137
|
+
if (data.voiceId !== undefined && (typeof data.voiceId !== "string" || !/^(?:tc|uc)_[a-zA-Z0-9_-]{1,100}$/.test(data.voiceId)))
|
|
138
|
+
return { ok: false, tts: status(), error: "invalid_voice" };
|
|
139
|
+
if (data.enabled === true && (typeof data.viewerId !== "string" || !VIEWER.test(data.viewerId)))
|
|
140
|
+
return { ok: false, tts: status(), error: "invalid_viewer" };
|
|
141
|
+
if (data.enabled === true && !connected.get(data.viewerId))
|
|
142
|
+
return { ok: false, tts: status(), error: "tts_driver_disconnected" };
|
|
143
|
+
const revision = ++configuration;
|
|
144
|
+
if (stopped) return { ok: false, tts: status(), error: "tts_stopped" };
|
|
145
|
+
let nextKey = apiKey;
|
|
146
|
+
if (data.useLocalKey) {
|
|
147
|
+
try {
|
|
148
|
+
nextKey = (deps.readLocalKey ? await deps.readLocalKey() : parseTypecastKey(await readFile(
|
|
149
|
+
resolve(deps.cwd ?? process.cwd(), "../yt-outlier/.env"), "utf8"))) ?? "";
|
|
150
|
+
} catch { return { ok: false, tts: status(), error: "tts_local_key_unavailable" }; }
|
|
151
|
+
if (!nextKey) return { ok: false, tts: status(), error: "tts_local_key_unavailable" };
|
|
152
|
+
}
|
|
153
|
+
if (revision !== configuration || stopped)
|
|
154
|
+
return { ok: false, tts: status(), error: "tts_config_superseded" };
|
|
155
|
+
if (data.enabled === true && !connected.get(data.viewerId))
|
|
156
|
+
return { ok: false, tts: status(), error: "tts_driver_disconnected" };
|
|
157
|
+
if (data.apiKey !== undefined) nextKey = data.apiKey.trim();
|
|
158
|
+
if (data.clearKey) nextKey = "";
|
|
159
|
+
if (!data.clearKey && (data.enabled ?? enabled) && !nextKey) return { ok: false, tts: status(), error: "tts_key_required" };
|
|
160
|
+
// Configuration changes apply from the next explicit resume.
|
|
161
|
+
deps.pause();
|
|
162
|
+
cancel();
|
|
163
|
+
if (nextKey !== apiKey || (data.voiceId && data.voiceId !== voiceId)) clearAudio();
|
|
164
|
+
apiKey = nextKey;
|
|
165
|
+
if (data.voiceId) voiceId = data.voiceId;
|
|
166
|
+
if (data.enabled !== undefined) enabled = data.enabled;
|
|
167
|
+
if (data.clearKey) enabled = false;
|
|
168
|
+
if (enabled && data.enabled === true) {
|
|
169
|
+
driverId = data.viewerId;
|
|
170
|
+
driverToken = crypto.randomUUID();
|
|
171
|
+
}
|
|
172
|
+
if (!enabled) { driverId = undefined; driverToken = undefined; }
|
|
173
|
+
error = undefined;
|
|
174
|
+
changed();
|
|
175
|
+
return { ok: true, tts: status(), ...(enabled && data.enabled === true ? { driverToken } : {}) };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function speak(text: string, at: string, signal: AbortSignal, onStart: Active["start"]): Promise<void> {
|
|
179
|
+
if (stopped || signal.aborted) throw abortError();
|
|
180
|
+
if (!driverId || !connected.get(driverId)) { fail("tts_driver_disconnected"); throw new Error("tts_driver_disconnected"); }
|
|
181
|
+
error = undefined;
|
|
182
|
+
changed();
|
|
183
|
+
const ctrl = new AbortController();
|
|
184
|
+
preparing = ctrl;
|
|
185
|
+
const onAbort = () => { ctrl.abort(); cancel(); };
|
|
186
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
187
|
+
const prepareTimer = setTimeout(() => { if (preparing === ctrl) fail("tts_prepare_timeout"); }, deps.prepareTimeoutMs ?? 45000);
|
|
188
|
+
try {
|
|
189
|
+
const cacheId = voiceId + ":" + text;
|
|
190
|
+
let audio = cache.get(cacheId);
|
|
191
|
+
if (!audio) {
|
|
192
|
+
audio = await synthesizeTypecast(apiKey, voiceId, text, ctrl.signal, deps.fetch);
|
|
193
|
+
if (ctrl.signal.aborted || signal.aborted || preparing !== ctrl) throw abortError();
|
|
194
|
+
cache.set(cacheId, audio);
|
|
195
|
+
cacheBytes += audio.bytes.byteLength;
|
|
196
|
+
while (cache.size > 24 || cacheBytes > 40 * 1024 * 1024) {
|
|
197
|
+
const oldest = cache.keys().next().value!;
|
|
198
|
+
cacheBytes -= cache.get(oldest)!.bytes.byteLength;
|
|
199
|
+
cache.delete(oldest);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (signal.aborted || ctrl.signal.aborted) throw abortError();
|
|
203
|
+
preparing = undefined;
|
|
204
|
+
clearTimeout(prepareTimer);
|
|
205
|
+
const id = crypto.randomUUID();
|
|
206
|
+
urls.set(id, { ...audio, expires: Date.now() + 10 * 60 * 1000 });
|
|
207
|
+
pruneUrls();
|
|
208
|
+
await new Promise<void>((finish, reject) => {
|
|
209
|
+
active = { id, text, at, started: false, duration: audio!.duration, start: onStart, finish, fail: reject,
|
|
210
|
+
timer: setTimeout(() => fail("tts_start_timeout"), deps.startTimeoutMs ?? 15000) };
|
|
211
|
+
deps.emit("speech", { phase: "ready", id, driverId, at, text, audioUrl: `/tts/audio/${id}`, duration: audio!.duration });
|
|
212
|
+
});
|
|
213
|
+
} catch (cause) {
|
|
214
|
+
if (!signal.aborted && !ctrl.signal.aborted && error === undefined) {
|
|
215
|
+
const code = cause instanceof Error && /^tts_[a-z_]+$/.test(cause.message) ? cause.message : "tts_failed";
|
|
216
|
+
fail(code);
|
|
217
|
+
}
|
|
218
|
+
throw cause;
|
|
219
|
+
} finally {
|
|
220
|
+
clearTimeout(prepareTimer);
|
|
221
|
+
if (preparing === ctrl) preparing = undefined;
|
|
222
|
+
signal.removeEventListener("abort", onAbort);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function acknowledge(data: any) {
|
|
227
|
+
if (!active || !driverToken || data?.viewerId !== driverId || data?.driverToken !== driverToken || data?.id !== active.id) return false;
|
|
228
|
+
if (data.phase === "started" && !active.started) {
|
|
229
|
+
active.started = true;
|
|
230
|
+
clearTimeout(active.timer);
|
|
231
|
+
active.timer = setTimeout(() => fail("tts_end_timeout"), active.duration * 2000 + 15000);
|
|
232
|
+
active.start(active.duration, active.id);
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
if (data.phase === "ended" && active.started) {
|
|
236
|
+
const item = active;
|
|
237
|
+
active = undefined;
|
|
238
|
+
clearTimeout(item.timer);
|
|
239
|
+
deps.emit("speech", { phase: "ended", id: item.id, driverId });
|
|
240
|
+
item.finish();
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
if (data.phase === "error") { fail("tts_playback_error"); return true; }
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function handle(req: Request, pathname = new URL(req.url).pathname): Promise<Response | null> {
|
|
248
|
+
if (pathname === "/tts") {
|
|
249
|
+
if (req.method === "GET") return Response.json(status());
|
|
250
|
+
if (req.method === "POST") {
|
|
251
|
+
const data = await req.json().catch(() => null);
|
|
252
|
+
const result = await configure(data);
|
|
253
|
+
return Response.json(result, { status: result.ok ? 200 : 400 });
|
|
254
|
+
}
|
|
255
|
+
return new Response(null, { status: 405 });
|
|
256
|
+
}
|
|
257
|
+
if (pathname === "/tts/ack") {
|
|
258
|
+
if (req.method !== "POST") return new Response(null, { status: 405 });
|
|
259
|
+
return Response.json({ ok: true, accepted: acknowledge(await req.json().catch(() => null)) });
|
|
260
|
+
}
|
|
261
|
+
if (pathname.startsWith("/tts/audio/")) {
|
|
262
|
+
if (req.method !== "GET") return new Response(null, { status: 405 });
|
|
263
|
+
pruneUrls();
|
|
264
|
+
const audio = urls.get(pathname.slice("/tts/audio/".length));
|
|
265
|
+
return audio ? new Response(audio.bytes as BodyInit, { headers: { "Content-Type": "audio/mpeg", "Cache-Control": "no-store" } })
|
|
266
|
+
: Response.json({ ok: false, error: "tts_audio_not_found" }, { status: 404 });
|
|
267
|
+
}
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
status, configure, handle, acknowledge,
|
|
273
|
+
// A joining audience receives the caption, never playback or its token.
|
|
274
|
+
currentNarration: () => active?.started
|
|
275
|
+
? { text: active.text, script: active.at, narration: active.id, hold: active.duration } : null,
|
|
276
|
+
narrate: (text: string, at: string, signal: AbortSignal, started: Active["start"]) => enabled ? speak(text, at, signal, started) : null,
|
|
277
|
+
connect(viewer: string | null) { if (viewer && VIEWER.test(viewer)) connected.set(viewer, (connected.get(viewer) ?? 0) + 1); },
|
|
278
|
+
disconnect(viewer: string | null) {
|
|
279
|
+
if (!viewer) return;
|
|
280
|
+
const count = (connected.get(viewer) ?? 0) - 1;
|
|
281
|
+
if (count > 0) connected.set(viewer, count); else connected.delete(viewer);
|
|
282
|
+
if (enabled && viewer === driverId && !connected.has(viewer)) fail("tts_driver_disconnected");
|
|
283
|
+
},
|
|
284
|
+
stop() {
|
|
285
|
+
stopped = true; configuration++; cancel(); clearAudio(); connected.clear();
|
|
286
|
+
apiKey = ""; driverToken = undefined; driverId = undefined; enabled = false;
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
}
|