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/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
- clients.delete(ctrl);
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
- say(text, at, hold) {
391
- broadcast("message", { role: "assistant", text, ts: new Date().toISOString(), script: at, hold });
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
- clients.delete(ctrl);
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) and tell the
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
+ }