agello 0.4.3 → 0.4.5

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/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
+ }