@runtypelabs/persona 3.34.1 → 3.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -11
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-CthJFfNx.d.cts → types-DgYsuwXL.d.cts} +15 -14
- package/dist/animations/{types-CthJFfNx.d.ts → types-DgYsuwXL.d.ts} +15 -14
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/codegen.cjs +11 -11
- package/dist/codegen.js +10 -10
- package/dist/index.cjs +46 -44
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +514 -158
- package/dist/index.d.ts +514 -158
- package/dist/index.global.js +41 -89
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +46 -44
- package/dist/index.js.map +1 -1
- package/dist/install.global.js +1 -1
- package/dist/install.global.js.map +1 -1
- package/dist/launcher.global.js +2 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/markdown-parsers.js +53 -0
- package/dist/plugin-kit.d.cts +4 -4
- package/dist/plugin-kit.d.ts +4 -4
- package/dist/runtype-tts.js +1 -0
- package/dist/smart-dom-reader.cjs +1 -1
- package/dist/smart-dom-reader.d.cts +352 -103
- package/dist/smart-dom-reader.d.ts +352 -103
- package/dist/smart-dom-reader.js +1 -1
- package/dist/theme-editor-preview.cjs +203 -0
- package/dist/theme-editor-preview.d.cts +5748 -0
- package/dist/theme-editor-preview.d.ts +5748 -0
- package/dist/theme-editor-preview.js +203 -0
- package/dist/theme-editor.cjs +14 -131
- package/dist/theme-editor.d.cts +362 -574
- package/dist/theme-editor.d.ts +362 -574
- package/dist/theme-editor.js +14 -131
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +1 -1
- package/dist/theme-reference.d.ts +1 -1
- package/dist/theme-reference.js +1 -1
- package/dist/voice-worklet-player.cjs +80 -0
- package/dist/voice-worklet-player.d.cts +215 -0
- package/dist/voice-worklet-player.d.ts +215 -0
- package/dist/voice-worklet-player.js +80 -0
- package/dist/widget.css +54 -36
- package/package.json +16 -2
- package/src/animations/glyph-cycle.ts +11 -14
- package/src/animations/wipe.ts +1 -1
- package/src/ask-user-question-tool.test.ts +1 -1
- package/src/ask-user-question-tool.ts +6 -8
- package/src/client.test.ts +56 -12
- package/src/client.ts +35 -31
- package/src/codegen.test.ts +1 -1
- package/src/codegen.ts +1 -1
- package/src/components/approval-bubble.test.ts +1 -1
- package/src/components/approval-bubble.ts +3 -3
- package/src/components/artifact-pane.ts +3 -3
- package/src/components/ask-user-question-bubble.test.ts +14 -14
- package/src/components/ask-user-question-bubble.ts +10 -10
- package/src/components/composer-builder.ts +17 -16
- package/src/components/composer-parts.test.ts +54 -0
- package/src/components/composer-parts.ts +95 -126
- package/src/components/demo-carousel.ts +1 -1
- package/src/components/event-stream-view.test.ts +7 -7
- package/src/components/event-stream-view.ts +5 -5
- package/src/components/header-builder.ts +33 -27
- package/src/components/header-layouts.ts +1 -1
- package/src/components/header-parts.test.ts +90 -0
- package/src/components/header-parts.ts +62 -93
- package/src/components/launcher.test.ts +11 -0
- package/src/components/launcher.ts +1 -1
- package/src/components/message-bubble.test.ts +74 -2
- package/src/components/message-bubble.ts +25 -11
- package/src/components/messages.ts +2 -2
- package/src/components/panel.test.ts +1 -1
- package/src/components/panel.ts +92 -97
- package/src/components/pill-composer-builder.ts +28 -24
- package/src/components/reasoning-bubble.ts +2 -2
- package/src/components/suggestions.ts +1 -1
- package/src/components/tool-bubble.ts +1 -1
- package/src/components/widget-view.test.ts +181 -0
- package/src/components/widget-view.ts +234 -0
- package/src/defaults.ts +54 -51
- package/src/generated/runtype-openapi-contract.ts +5 -0
- package/src/index-core.ts +30 -4
- package/src/index-global.ts +73 -6
- package/src/index.ts +7 -1
- package/src/install.test.ts +11 -11
- package/src/install.ts +28 -18
- package/src/launcher-global.ts +25 -14
- package/src/markdown-parsers-eager.ts +20 -0
- package/src/markdown-parsers-entry.ts +4 -0
- package/src/markdown-parsers-loader.ts +49 -0
- package/src/plugin-kit.test.ts +1 -1
- package/src/plugin-kit.ts +7 -7
- package/src/plugins/types.ts +5 -5
- package/src/postprocessors.ts +27 -14
- package/src/runtime/host-layout.test.ts +1 -1
- package/src/runtime/host-layout.ts +4 -5
- package/src/runtime/init.ts +2 -2
- package/src/session.test.ts +5 -6
- package/src/session.ts +333 -227
- package/src/session.voice.test.ts +146 -0
- package/src/session.webmcp.test.ts +24 -25
- package/src/smart-dom-reader.ts +3 -4
- package/src/styles/widget.css +54 -36
- package/src/suggest-replies-tool.test.ts +3 -3
- package/src/suggest-replies-tool.ts +5 -5
- package/src/testing/index.ts +1 -1
- package/src/theme-editor/color-utils.ts +2 -2
- package/src/theme-editor/index.ts +4 -13
- package/src/theme-editor/presets.ts +1 -1
- package/src/theme-editor/preview-utils.ts +22 -20
- package/src/theme-editor/preview.ts +4 -1
- package/src/theme-editor/role-mappings.ts +3 -3
- package/src/theme-editor/sections.ts +21 -20
- package/src/theme-editor/types.ts +1 -1
- package/src/theme-editor/webmcp/index.ts +1 -1
- package/src/theme-editor/webmcp/summary.ts +1 -2
- package/src/theme-editor/webmcp/tools.ts +30 -30
- package/src/theme-editor/webmcp/types.ts +2 -2
- package/src/theme-editor-preview.ts +10 -0
- package/src/theme-reference.ts +26 -26
- package/src/types.ts +368 -99
- package/src/ui.approval-plugin.test.ts +3 -3
- package/src/ui.ask-user-question-plugin.test.ts +9 -9
- package/src/ui.component-directive.test.ts +4 -4
- package/src/ui.composer-bar.test.ts +10 -11
- package/src/ui.composer-keyboard.test.ts +5 -5
- package/src/ui.header-icon-color.test.ts +59 -0
- package/src/ui.scroll.test.ts +5 -6
- package/src/ui.stop-button.test.ts +3 -4
- package/src/ui.suggest-replies.test.ts +2 -2
- package/src/ui.ts +292 -131
- package/src/utils/buttons.ts +1 -1
- package/src/utils/code-generators.ts +1 -1
- package/src/utils/composer-history.ts +3 -3
- package/src/utils/dom-context.test.ts +2 -2
- package/src/utils/dom-context.ts +7 -7
- package/src/utils/dom.test.ts +67 -0
- package/src/utils/dom.ts +75 -0
- package/src/utils/dropdown.ts +2 -2
- package/src/utils/event-stream-buffer.test.ts +2 -2
- package/src/utils/message-fingerprint.test.ts +9 -0
- package/src/utils/message-fingerprint.ts +5 -0
- package/src/utils/morph.ts +1 -1
- package/src/utils/sanitize.ts +32 -16
- package/src/utils/sequence-buffer.test.ts +8 -8
- package/src/utils/sequence-buffer.ts +4 -4
- package/src/utils/smart-dom-adapter.test.ts +1 -1
- package/src/utils/smart-dom-adapter.ts +4 -5
- package/src/utils/speech-text.test.ts +122 -0
- package/src/utils/speech-text.ts +101 -0
- package/src/utils/stream-animation.test.ts +3 -3
- package/src/utils/stream-animation.ts +6 -6
- package/src/utils/theme.test.ts +1 -1
- package/src/utils/throughput-tracker.test.ts +11 -11
- package/src/utils/throughput-tracker.ts +9 -9
- package/src/utils/tokens.ts +13 -13
- package/src/utils/virtual-scroller.ts +1 -1
- package/src/vendor/smart-dom-reader/README.md +4 -4
- package/src/vendor/smart-dom-reader/index.d.ts +1 -1
- package/src/vendor/smart-dom-reader/index.js +2 -2
- package/src/version.ts +1 -1
- package/src/voice/audio-playback-manager.test.ts +159 -0
- package/src/voice/audio-playback-manager.ts +145 -30
- package/src/voice/browser-speech-engine.ts +130 -0
- package/src/voice/fallback-speech-engine.ts +89 -0
- package/src/voice/index.ts +21 -0
- package/src/voice/read-aloud-controller.test.ts +143 -0
- package/src/voice/read-aloud-controller.ts +136 -0
- package/src/voice/runtype-speech-engine.test.ts +280 -0
- package/src/voice/runtype-speech-engine.ts +211 -0
- package/src/voice/runtype-tts-entry.ts +10 -0
- package/src/voice/runtype-tts-loader.test.ts +47 -0
- package/src/voice/runtype-tts-loader.ts +42 -0
- package/src/voice/runtype-voice-provider.ts +332 -537
- package/src/voice/voice-factory.ts +26 -4
- package/src/voice/voice.test.ts +321 -7
- package/src/voice/worklet-playback-engine.ts +272 -0
- package/src/voice-worklet-player.ts +39 -0
- package/src/webmcp-bridge.test.ts +6 -6
- package/src/webmcp-bridge.ts +24 -25
- package/src/webmcp-polyfill.ts +1 -1
- package/src/voice/voice-activity-detector.ts +0 -90
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { ReadAloudController } from "./read-aloud-controller";
|
|
3
|
+
import type { ReadAloudState, SpeechCallbacks, SpeechEngine, SpeechRequest } from "../types";
|
|
4
|
+
|
|
5
|
+
class FakeEngine implements SpeechEngine {
|
|
6
|
+
readonly id = "fake";
|
|
7
|
+
readonly supportsPause: boolean;
|
|
8
|
+
speakCalls: SpeechRequest[] = [];
|
|
9
|
+
lastCallbacks: SpeechCallbacks | null = null;
|
|
10
|
+
pauseCount = 0;
|
|
11
|
+
resumeCount = 0;
|
|
12
|
+
stopCount = 0;
|
|
13
|
+
|
|
14
|
+
constructor(supportsPause = true) {
|
|
15
|
+
this.supportsPause = supportsPause;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
speak(request: SpeechRequest, callbacks: SpeechCallbacks): void {
|
|
19
|
+
this.speakCalls.push(request);
|
|
20
|
+
this.lastCallbacks = callbacks;
|
|
21
|
+
}
|
|
22
|
+
pause(): void {
|
|
23
|
+
this.pauseCount++;
|
|
24
|
+
}
|
|
25
|
+
resume(): void {
|
|
26
|
+
this.resumeCount++;
|
|
27
|
+
}
|
|
28
|
+
stop(): void {
|
|
29
|
+
this.stopCount++;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
34
|
+
const req = (text: string): SpeechRequest => ({ text });
|
|
35
|
+
|
|
36
|
+
describe("ReadAloudController", () => {
|
|
37
|
+
let engine: FakeEngine;
|
|
38
|
+
let controller: ReadAloudController;
|
|
39
|
+
let states: Array<[string | null, ReadAloudState]>;
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
engine = new FakeEngine(true);
|
|
43
|
+
controller = new ReadAloudController(() => engine);
|
|
44
|
+
states = [];
|
|
45
|
+
controller.onChange((id, state) => states.push([id, state]));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("goes idle → loading → playing on first toggle", async () => {
|
|
49
|
+
controller.toggle("a", req("hello"));
|
|
50
|
+
// loading is set synchronously, before the engine resolves
|
|
51
|
+
expect(controller.stateFor("a")).toBe("loading");
|
|
52
|
+
await tick();
|
|
53
|
+
expect(engine.speakCalls).toHaveLength(1);
|
|
54
|
+
engine.lastCallbacks!.onStart!();
|
|
55
|
+
expect(controller.stateFor("a")).toBe("playing");
|
|
56
|
+
expect(states.map((s) => s[1])).toEqual(["loading", "playing"]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("pauses then resumes a playing message", async () => {
|
|
60
|
+
controller.toggle("a", req("hello"));
|
|
61
|
+
await tick();
|
|
62
|
+
engine.lastCallbacks!.onStart!();
|
|
63
|
+
|
|
64
|
+
controller.toggle("a", req("hello")); // playing → pause
|
|
65
|
+
expect(engine.pauseCount).toBe(1);
|
|
66
|
+
expect(controller.stateFor("a")).toBe("paused");
|
|
67
|
+
|
|
68
|
+
controller.toggle("a", req("hello")); // paused → resume
|
|
69
|
+
expect(engine.resumeCount).toBe(1);
|
|
70
|
+
expect(controller.stateFor("a")).toBe("playing");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("stops a playing message (no resume) when the engine can't pause", async () => {
|
|
74
|
+
engine = new FakeEngine(false);
|
|
75
|
+
controller = new ReadAloudController(() => engine);
|
|
76
|
+
controller.toggle("a", req("hello"));
|
|
77
|
+
await tick();
|
|
78
|
+
engine.lastCallbacks!.onStart!();
|
|
79
|
+
|
|
80
|
+
controller.toggle("a", req("hello")); // playing → stop (no pause support)
|
|
81
|
+
expect(engine.pauseCount).toBe(0);
|
|
82
|
+
expect(engine.stopCount).toBeGreaterThanOrEqual(1);
|
|
83
|
+
expect(controller.stateFor("a")).toBe("idle");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("stops the previous message when a different one starts", async () => {
|
|
87
|
+
controller.toggle("a", req("first"));
|
|
88
|
+
await tick();
|
|
89
|
+
engine.lastCallbacks!.onStart!();
|
|
90
|
+
expect(controller.stateFor("a")).toBe("playing");
|
|
91
|
+
|
|
92
|
+
controller.toggle("b", req("second")); // switch
|
|
93
|
+
await tick();
|
|
94
|
+
expect(engine.stopCount).toBeGreaterThanOrEqual(1);
|
|
95
|
+
engine.lastCallbacks!.onStart!();
|
|
96
|
+
expect(controller.stateFor("a")).toBe("idle");
|
|
97
|
+
expect(controller.stateFor("b")).toBe("playing");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("returns to idle on natural end", async () => {
|
|
101
|
+
controller.toggle("a", req("hello"));
|
|
102
|
+
await tick();
|
|
103
|
+
engine.lastCallbacks!.onStart!();
|
|
104
|
+
engine.lastCallbacks!.onEnd!();
|
|
105
|
+
expect(controller.stateFor("a")).toBe("idle");
|
|
106
|
+
expect(controller.activeMessageId()).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("ignores a stale callback from a superseded request", async () => {
|
|
110
|
+
controller.toggle("a", req("first"));
|
|
111
|
+
await tick();
|
|
112
|
+
const staleCallbacks = engine.lastCallbacks!;
|
|
113
|
+
staleCallbacks.onStart!();
|
|
114
|
+
|
|
115
|
+
controller.toggle("b", req("second")); // supersedes "a"
|
|
116
|
+
await tick();
|
|
117
|
+
engine.lastCallbacks!.onStart!();
|
|
118
|
+
expect(controller.stateFor("b")).toBe("playing");
|
|
119
|
+
|
|
120
|
+
// Late onEnd from the superseded "a" request must not clobber "b".
|
|
121
|
+
staleCallbacks.onEnd!();
|
|
122
|
+
expect(controller.stateFor("b")).toBe("playing");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("stop() resets to idle and stops the engine", async () => {
|
|
126
|
+
controller.toggle("a", req("hello"));
|
|
127
|
+
await tick();
|
|
128
|
+
engine.lastCallbacks!.onStart!();
|
|
129
|
+
controller.stop();
|
|
130
|
+
expect(controller.stateFor("a")).toBe("idle");
|
|
131
|
+
expect(engine.stopCount).toBeGreaterThanOrEqual(1);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("reports a null engine resolution as idle", async () => {
|
|
135
|
+
const nullController = new ReadAloudController(() => null);
|
|
136
|
+
const seen: ReadAloudState[] = [];
|
|
137
|
+
nullController.onChange((_id, state) => seen.push(state));
|
|
138
|
+
nullController.toggle("a", req("hello"));
|
|
139
|
+
await tick();
|
|
140
|
+
expect(nullController.stateFor("a")).toBe("idle");
|
|
141
|
+
expect(seen).toEqual(["loading", "idle"]);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Read-Aloud Controller
|
|
2
|
+
//
|
|
3
|
+
// Engine-agnostic orchestrator for the per-message "Read aloud" action and the
|
|
4
|
+
// auto-speak path. Owns the three things the UI shouldn't: which message is
|
|
5
|
+
// currently active, its playback state, and the single speech engine instance.
|
|
6
|
+
//
|
|
7
|
+
// The button drives a single `toggle(id, request)`; listeners are notified on
|
|
8
|
+
// every state change so the UI can reflect play / pause / resume. The engine is
|
|
9
|
+
// resolved lazily (on first playback, inside the user gesture) via the
|
|
10
|
+
// `resolveEngine` factory, so swapping the browser engine for a hosted one
|
|
11
|
+
// (Runtype or custom) is a config change — this class is unchanged.
|
|
12
|
+
|
|
13
|
+
import type { ReadAloudState, SpeechEngine, SpeechRequest } from "../types";
|
|
14
|
+
|
|
15
|
+
export type ReadAloudListener = (
|
|
16
|
+
activeId: string | null,
|
|
17
|
+
state: ReadAloudState
|
|
18
|
+
) => void;
|
|
19
|
+
|
|
20
|
+
export class ReadAloudController {
|
|
21
|
+
private engine: SpeechEngine | null = null;
|
|
22
|
+
private activeId: string | null = null;
|
|
23
|
+
private state: ReadAloudState = "idle";
|
|
24
|
+
private listeners = new Set<ReadAloudListener>();
|
|
25
|
+
// Bumped on every play/stop so a late async engine creation or a stale
|
|
26
|
+
// utterance callback from a superseded request can't mutate current state.
|
|
27
|
+
private generation = 0;
|
|
28
|
+
|
|
29
|
+
constructor(
|
|
30
|
+
private resolveEngine: () => SpeechEngine | Promise<SpeechEngine> | null
|
|
31
|
+
) {}
|
|
32
|
+
|
|
33
|
+
/** Whether the active engine supports pause/resume (vs. stop-only). */
|
|
34
|
+
get supportsPause(): boolean {
|
|
35
|
+
return this.engine?.supportsPause ?? true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Playback state for a message id (`idle` unless it's the active message). */
|
|
39
|
+
stateFor(id: string): ReadAloudState {
|
|
40
|
+
return this.activeId === id ? this.state : "idle";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The message currently being read aloud, if any. */
|
|
44
|
+
activeMessageId(): string | null {
|
|
45
|
+
return this.activeId;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Subscribe to state changes. Returns an unsubscribe function. */
|
|
49
|
+
onChange(listener: ReadAloudListener): () => void {
|
|
50
|
+
this.listeners.add(listener);
|
|
51
|
+
return () => this.listeners.delete(listener);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Primary entry point for the button: cycle this message through
|
|
56
|
+
* play → pause → resume (or play → stop when the engine can't pause), and
|
|
57
|
+
* start fresh when a different message is requested.
|
|
58
|
+
*/
|
|
59
|
+
toggle(id: string, request: SpeechRequest): void {
|
|
60
|
+
if (this.activeId === id) {
|
|
61
|
+
if (this.state === "playing") {
|
|
62
|
+
if (this.engine?.supportsPause) {
|
|
63
|
+
this.engine.pause();
|
|
64
|
+
this.set(id, "paused");
|
|
65
|
+
} else {
|
|
66
|
+
this.stop();
|
|
67
|
+
}
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (this.state === "paused") {
|
|
71
|
+
this.engine?.resume();
|
|
72
|
+
this.set(id, "playing");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (this.state === "loading") {
|
|
76
|
+
this.stop();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
void this.play(id, request);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Start (or restart) playback for a message, stopping any current playback. */
|
|
84
|
+
async play(id: string, request: SpeechRequest): Promise<void> {
|
|
85
|
+
const generation = ++this.generation;
|
|
86
|
+
this.engine?.stop();
|
|
87
|
+
this.set(id, "loading");
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
if (!this.engine) {
|
|
91
|
+
const resolved = await this.resolveEngine();
|
|
92
|
+
if (generation !== this.generation) return; // superseded mid-resolve
|
|
93
|
+
if (!resolved) {
|
|
94
|
+
this.set(null, "idle");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
this.engine = resolved;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
this.engine.speak(request, {
|
|
101
|
+
onStart: () => {
|
|
102
|
+
if (generation === this.generation) this.set(id, "playing");
|
|
103
|
+
},
|
|
104
|
+
onEnd: () => {
|
|
105
|
+
if (generation === this.generation) this.set(null, "idle");
|
|
106
|
+
},
|
|
107
|
+
onError: () => {
|
|
108
|
+
if (generation === this.generation) this.set(null, "idle");
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
} catch {
|
|
112
|
+
if (generation === this.generation) this.set(null, "idle");
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Stop playback and return to idle. */
|
|
117
|
+
stop(): void {
|
|
118
|
+
this.generation++;
|
|
119
|
+
this.engine?.stop();
|
|
120
|
+
this.set(null, "idle");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Drop the controller and its engine (called on widget teardown). */
|
|
124
|
+
destroy(): void {
|
|
125
|
+
this.stop();
|
|
126
|
+
this.engine?.destroy?.();
|
|
127
|
+
this.engine = null;
|
|
128
|
+
this.listeners.clear();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private set(id: string | null, state: ReadAloudState): void {
|
|
132
|
+
this.activeId = state === "idle" ? null : id;
|
|
133
|
+
this.state = state;
|
|
134
|
+
for (const listener of this.listeners) listener(this.activeId, this.state);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// Tests for the Runtype hosted TTS engine and the browser-fallback wrapper.
|
|
2
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
3
|
+
import type {
|
|
4
|
+
PcmStreamPlayer,
|
|
5
|
+
SpeechCallbacks,
|
|
6
|
+
SpeechEngine,
|
|
7
|
+
SpeechRequest,
|
|
8
|
+
} from "../types";
|
|
9
|
+
import {
|
|
10
|
+
RuntypeSpeechEngine,
|
|
11
|
+
type RuntypeSpeechEngineOptions,
|
|
12
|
+
} from "./runtype-speech-engine";
|
|
13
|
+
import { FallbackSpeechEngine } from "./fallback-speech-engine";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A fake {@link PcmStreamPlayer} whose started/finished callbacks the test can
|
|
17
|
+
* fire. Injected via `createPlaybackEngine` so the engine never touches a real
|
|
18
|
+
* AudioContext (and so the test no longer mocks the worklet module — the engine
|
|
19
|
+
* defaults to the in-bundle AudioPlaybackManager, which we substitute here).
|
|
20
|
+
*/
|
|
21
|
+
function makeFakePlayer() {
|
|
22
|
+
let startedCbs: Array<() => void> = [];
|
|
23
|
+
let finishedCbs: Array<() => void> = [];
|
|
24
|
+
return {
|
|
25
|
+
enqueue: vi.fn(),
|
|
26
|
+
markStreamEnd: vi.fn(),
|
|
27
|
+
flush: vi.fn(() => {
|
|
28
|
+
startedCbs = [];
|
|
29
|
+
finishedCbs = [];
|
|
30
|
+
}),
|
|
31
|
+
resume: vi.fn(),
|
|
32
|
+
pause: vi.fn(),
|
|
33
|
+
destroy: vi.fn(),
|
|
34
|
+
onStarted: vi.fn((cb: () => void) => startedCbs.push(cb)),
|
|
35
|
+
onFinished: vi.fn((cb: () => void) => finishedCbs.push(cb)),
|
|
36
|
+
fireStarted: () => startedCbs.slice().forEach((c) => c()),
|
|
37
|
+
fireFinished: () => finishedCbs.slice().forEach((c) => c()),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Yield to the macrotask queue so the engine's async read loop fully drains. */
|
|
42
|
+
const settle = () => new Promise((r) => setTimeout(r, 0));
|
|
43
|
+
|
|
44
|
+
/** A fetch Response stub that streams the given chunks then ends. */
|
|
45
|
+
function streamingResponse(chunks: Uint8Array[]) {
|
|
46
|
+
let i = 0;
|
|
47
|
+
return {
|
|
48
|
+
ok: true,
|
|
49
|
+
status: 200,
|
|
50
|
+
body: {
|
|
51
|
+
getReader: () => ({
|
|
52
|
+
read: async () =>
|
|
53
|
+
i < chunks.length
|
|
54
|
+
? { done: false, value: chunks[i++] }
|
|
55
|
+
: { done: true, value: undefined },
|
|
56
|
+
cancel: async () => {},
|
|
57
|
+
}),
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe("RuntypeSpeechEngine", () => {
|
|
63
|
+
let fetchMock: ReturnType<typeof vi.fn>;
|
|
64
|
+
let player: ReturnType<typeof makeFakePlayer>;
|
|
65
|
+
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
vi.clearAllMocks();
|
|
68
|
+
fetchMock = vi.fn();
|
|
69
|
+
(globalThis as unknown as { fetch: unknown }).fetch = fetchMock;
|
|
70
|
+
player = makeFakePlayer();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/** Build an engine whose playback is the injected fake (no AudioContext). */
|
|
74
|
+
const makeEngine = (opts: Partial<RuntypeSpeechEngineOptions> = {}) =>
|
|
75
|
+
new RuntypeSpeechEngine({
|
|
76
|
+
host: "https://api.runtype.com",
|
|
77
|
+
agentId: "agent_123",
|
|
78
|
+
clientToken: "ct_live_abc",
|
|
79
|
+
createPlaybackEngine: () => player as unknown as PcmStreamPlayer,
|
|
80
|
+
...opts,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
afterEach(() => {
|
|
84
|
+
delete (globalThis as unknown as { fetch?: unknown }).fetch;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("POSTs to /v1/agents/:id/speak with bearer auth and pcm body", async () => {
|
|
88
|
+
fetchMock.mockResolvedValue(streamingResponse([new Uint8Array([1, 2, 3, 4])]));
|
|
89
|
+
const engine = makeEngine({ host: "https://api.runtype.com/" });
|
|
90
|
+
|
|
91
|
+
engine.speak({ text: "hello there" }, {});
|
|
92
|
+
await settle();
|
|
93
|
+
|
|
94
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
95
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
96
|
+
// Trailing slash on host must not double up.
|
|
97
|
+
expect(url).toBe("https://api.runtype.com/v1/agents/agent_123/speak");
|
|
98
|
+
expect(init.method).toBe("POST");
|
|
99
|
+
expect(init.headers.Authorization).toBe("Bearer ct_live_abc");
|
|
100
|
+
expect(JSON.parse(init.body)).toEqual({
|
|
101
|
+
text: "hello there",
|
|
102
|
+
voice: undefined,
|
|
103
|
+
format: "pcm",
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("streams chunks into the player and marks stream end", async () => {
|
|
108
|
+
const a = new Uint8Array([1, 2]);
|
|
109
|
+
const b = new Uint8Array([3, 4]);
|
|
110
|
+
fetchMock.mockResolvedValue(streamingResponse([a, b]));
|
|
111
|
+
const engine = makeEngine();
|
|
112
|
+
|
|
113
|
+
engine.speak({ text: "hi" }, {});
|
|
114
|
+
await settle();
|
|
115
|
+
|
|
116
|
+
expect(player.enqueue).toHaveBeenCalledTimes(2);
|
|
117
|
+
expect(player.enqueue).toHaveBeenNthCalledWith(1, a);
|
|
118
|
+
expect(player.enqueue).toHaveBeenNthCalledWith(2, b);
|
|
119
|
+
expect(player.markStreamEnd).toHaveBeenCalledTimes(1);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("drives onStart/onEnd from the player, not chunk arrival", async () => {
|
|
123
|
+
fetchMock.mockResolvedValue(streamingResponse([new Uint8Array([1])]));
|
|
124
|
+
const onStart = vi.fn();
|
|
125
|
+
const onEnd = vi.fn();
|
|
126
|
+
const engine = makeEngine();
|
|
127
|
+
|
|
128
|
+
engine.speak({ text: "hi" }, { onStart, onEnd });
|
|
129
|
+
await settle();
|
|
130
|
+
|
|
131
|
+
expect(onStart).not.toHaveBeenCalled();
|
|
132
|
+
player.fireStarted();
|
|
133
|
+
expect(onStart).toHaveBeenCalledTimes(1);
|
|
134
|
+
expect(onEnd).not.toHaveBeenCalled();
|
|
135
|
+
player.fireFinished();
|
|
136
|
+
expect(onEnd).toHaveBeenCalledTimes(1);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("sends the request voice when provided, else the engine default", async () => {
|
|
140
|
+
fetchMock.mockResolvedValue(streamingResponse([]));
|
|
141
|
+
const engine = makeEngine({ voice: "default-voice" });
|
|
142
|
+
|
|
143
|
+
engine.speak({ text: "x", voice: "request-voice" }, {});
|
|
144
|
+
await settle();
|
|
145
|
+
expect(JSON.parse(fetchMock.mock.calls[0][1].body).voice).toBe("request-voice");
|
|
146
|
+
|
|
147
|
+
engine.speak({ text: "y" }, {});
|
|
148
|
+
await settle();
|
|
149
|
+
expect(JSON.parse(fetchMock.mock.calls[1][1].body).voice).toBe("default-voice");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("reports a descriptive error on a non-OK response", async () => {
|
|
153
|
+
fetchMock.mockResolvedValue({
|
|
154
|
+
ok: false,
|
|
155
|
+
status: 401,
|
|
156
|
+
body: null,
|
|
157
|
+
json: async () => ({ error: "Invalid or expired client token." }),
|
|
158
|
+
});
|
|
159
|
+
const onError = vi.fn();
|
|
160
|
+
const optsOnError = vi.fn();
|
|
161
|
+
const engine = makeEngine({ clientToken: "bad", onError: optsOnError });
|
|
162
|
+
|
|
163
|
+
engine.speak({ text: "hi" }, { onError });
|
|
164
|
+
await settle();
|
|
165
|
+
|
|
166
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
167
|
+
expect(onError.mock.calls[0][0]).toBeInstanceOf(Error);
|
|
168
|
+
expect(onError.mock.calls[0][0].message).toContain("Invalid or expired");
|
|
169
|
+
expect(optsOnError).toHaveBeenCalledTimes(1);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("ignores callbacks from a superseded speak()", async () => {
|
|
173
|
+
fetchMock.mockResolvedValue(streamingResponse([new Uint8Array([1])]));
|
|
174
|
+
const firstEnd = vi.fn();
|
|
175
|
+
const engine = makeEngine();
|
|
176
|
+
|
|
177
|
+
engine.speak({ text: "first" }, { onEnd: firstEnd });
|
|
178
|
+
engine.speak({ text: "second" }, {}); // supersedes the first
|
|
179
|
+
await settle();
|
|
180
|
+
|
|
181
|
+
// The first request's onEnd must not fire even if its (stale) finish lands.
|
|
182
|
+
player.fireFinished();
|
|
183
|
+
expect(firstEnd).not.toHaveBeenCalled();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
/** A scriptable SpeechEngine that exposes the callbacks handed to speak(). */
|
|
188
|
+
function fakeEngine(id: string) {
|
|
189
|
+
let cbs: SpeechCallbacks = {};
|
|
190
|
+
return {
|
|
191
|
+
id,
|
|
192
|
+
supportsPause: true,
|
|
193
|
+
speak: vi.fn((_req: SpeechRequest, c: SpeechCallbacks) => {
|
|
194
|
+
cbs = c;
|
|
195
|
+
}),
|
|
196
|
+
pause: vi.fn(),
|
|
197
|
+
resume: vi.fn(),
|
|
198
|
+
stop: vi.fn(),
|
|
199
|
+
destroy: vi.fn(),
|
|
200
|
+
emitStart: () => cbs.onStart?.(),
|
|
201
|
+
emitEnd: () => cbs.onEnd?.(),
|
|
202
|
+
emitError: (e: Error) => cbs.onError?.(e),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
describe("FallbackSpeechEngine", () => {
|
|
207
|
+
it("falls back to the secondary engine when the primary fails before audio", () => {
|
|
208
|
+
const primary = fakeEngine("primary");
|
|
209
|
+
const fallback = fakeEngine("fallback");
|
|
210
|
+
const onFallback = vi.fn();
|
|
211
|
+
const engine = new FallbackSpeechEngine(
|
|
212
|
+
primary as unknown as SpeechEngine,
|
|
213
|
+
fallback as unknown as SpeechEngine,
|
|
214
|
+
{ onFallback },
|
|
215
|
+
);
|
|
216
|
+
const onError = vi.fn();
|
|
217
|
+
const req: SpeechRequest = { text: "hi" };
|
|
218
|
+
|
|
219
|
+
engine.speak(req, { onError });
|
|
220
|
+
primary.emitError(new Error("no endpoint"));
|
|
221
|
+
|
|
222
|
+
expect(onFallback).toHaveBeenCalledTimes(1);
|
|
223
|
+
expect(fallback.speak).toHaveBeenCalledTimes(1);
|
|
224
|
+
expect(fallback.speak.mock.calls[0][0]).toBe(req);
|
|
225
|
+
expect(onError).not.toHaveBeenCalled();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("surfaces a failure that happens after playback has started", () => {
|
|
229
|
+
const primary = fakeEngine("primary");
|
|
230
|
+
const fallback = fakeEngine("fallback");
|
|
231
|
+
const engine = new FallbackSpeechEngine(
|
|
232
|
+
primary as unknown as SpeechEngine,
|
|
233
|
+
fallback as unknown as SpeechEngine,
|
|
234
|
+
);
|
|
235
|
+
const onError = vi.fn();
|
|
236
|
+
|
|
237
|
+
engine.speak({ text: "hi" }, { onError });
|
|
238
|
+
primary.emitStart();
|
|
239
|
+
primary.emitError(new Error("mid-stream drop"));
|
|
240
|
+
|
|
241
|
+
expect(fallback.speak).not.toHaveBeenCalled();
|
|
242
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
243
|
+
expect(onError.mock.calls[0][0].message).toBe("mid-stream drop");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("routes pause/resume/stop to the active engine", () => {
|
|
247
|
+
const primary = fakeEngine("primary");
|
|
248
|
+
const fallback = fakeEngine("fallback");
|
|
249
|
+
const engine = new FallbackSpeechEngine(
|
|
250
|
+
primary as unknown as SpeechEngine,
|
|
251
|
+
fallback as unknown as SpeechEngine,
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
// Before any failure, the primary is active.
|
|
255
|
+
engine.speak({ text: "hi" }, {});
|
|
256
|
+
engine.pause();
|
|
257
|
+
expect(primary.pause).toHaveBeenCalledTimes(1);
|
|
258
|
+
expect(fallback.pause).not.toHaveBeenCalled();
|
|
259
|
+
|
|
260
|
+
// After a pre-start failure the fallback becomes active.
|
|
261
|
+
primary.emitError(new Error("fail"));
|
|
262
|
+
engine.resume();
|
|
263
|
+
engine.stop();
|
|
264
|
+
expect(fallback.resume).toHaveBeenCalledTimes(1);
|
|
265
|
+
expect(fallback.stop).toHaveBeenCalledTimes(1);
|
|
266
|
+
expect(primary.resume).not.toHaveBeenCalled();
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("destroys both wrapped engines", () => {
|
|
270
|
+
const primary = fakeEngine("primary");
|
|
271
|
+
const fallback = fakeEngine("fallback");
|
|
272
|
+
const engine = new FallbackSpeechEngine(
|
|
273
|
+
primary as unknown as SpeechEngine,
|
|
274
|
+
fallback as unknown as SpeechEngine,
|
|
275
|
+
);
|
|
276
|
+
engine.destroy();
|
|
277
|
+
expect(primary.destroy).toHaveBeenCalledTimes(1);
|
|
278
|
+
expect(fallback.destroy).toHaveBeenCalledTimes(1);
|
|
279
|
+
});
|
|
280
|
+
});
|