@usejarvis/brain 0.1.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/LICENSE +153 -0
- package/README.md +278 -0
- package/bin/jarvis.ts +413 -0
- package/package.json +74 -0
- package/scripts/ensure-bun.cjs +8 -0
- package/src/actions/README.md +421 -0
- package/src/actions/app-control/desktop-controller.test.ts +26 -0
- package/src/actions/app-control/desktop-controller.ts +438 -0
- package/src/actions/app-control/interface.ts +64 -0
- package/src/actions/app-control/linux.ts +273 -0
- package/src/actions/app-control/macos.ts +54 -0
- package/src/actions/app-control/sidecar-launcher.test.ts +23 -0
- package/src/actions/app-control/sidecar-launcher.ts +286 -0
- package/src/actions/app-control/windows.ts +44 -0
- package/src/actions/browser/cdp.ts +138 -0
- package/src/actions/browser/chrome-launcher.ts +252 -0
- package/src/actions/browser/session.ts +437 -0
- package/src/actions/browser/stealth.ts +49 -0
- package/src/actions/index.ts +20 -0
- package/src/actions/terminal/executor.ts +157 -0
- package/src/actions/terminal/wsl-bridge.ts +126 -0
- package/src/actions/test.ts +93 -0
- package/src/actions/tools/agents.ts +321 -0
- package/src/actions/tools/builtin.ts +846 -0
- package/src/actions/tools/commitments.ts +192 -0
- package/src/actions/tools/content.ts +217 -0
- package/src/actions/tools/delegate.ts +147 -0
- package/src/actions/tools/desktop.test.ts +55 -0
- package/src/actions/tools/desktop.ts +305 -0
- package/src/actions/tools/goals.ts +376 -0
- package/src/actions/tools/local-tools-guard.ts +20 -0
- package/src/actions/tools/registry.ts +171 -0
- package/src/actions/tools/research.ts +111 -0
- package/src/actions/tools/sidecar-list.ts +57 -0
- package/src/actions/tools/sidecar-route.ts +105 -0
- package/src/actions/tools/workflows.ts +216 -0
- package/src/agents/agent.ts +132 -0
- package/src/agents/delegation.ts +107 -0
- package/src/agents/hierarchy.ts +113 -0
- package/src/agents/index.ts +19 -0
- package/src/agents/messaging.ts +125 -0
- package/src/agents/orchestrator.ts +576 -0
- package/src/agents/role-discovery.ts +61 -0
- package/src/agents/sub-agent-runner.ts +307 -0
- package/src/agents/task-manager.ts +151 -0
- package/src/authority/approval-delivery.ts +59 -0
- package/src/authority/approval.ts +196 -0
- package/src/authority/audit.ts +158 -0
- package/src/authority/authority.test.ts +519 -0
- package/src/authority/deferred-executor.ts +103 -0
- package/src/authority/emergency.ts +66 -0
- package/src/authority/engine.ts +297 -0
- package/src/authority/index.ts +12 -0
- package/src/authority/learning.ts +111 -0
- package/src/authority/tool-action-map.ts +74 -0
- package/src/awareness/analytics.ts +466 -0
- package/src/awareness/awareness.test.ts +332 -0
- package/src/awareness/capture-engine.ts +305 -0
- package/src/awareness/context-graph.ts +130 -0
- package/src/awareness/context-tracker.ts +349 -0
- package/src/awareness/index.ts +25 -0
- package/src/awareness/intelligence.ts +321 -0
- package/src/awareness/ocr-engine.ts +88 -0
- package/src/awareness/service.ts +528 -0
- package/src/awareness/struggle-detector.ts +342 -0
- package/src/awareness/suggestion-engine.ts +476 -0
- package/src/awareness/types.ts +201 -0
- package/src/cli/autostart.ts +241 -0
- package/src/cli/deps.ts +449 -0
- package/src/cli/doctor.ts +230 -0
- package/src/cli/helpers.ts +401 -0
- package/src/cli/onboard.ts +580 -0
- package/src/comms/README.md +329 -0
- package/src/comms/auth-error.html +48 -0
- package/src/comms/channels/discord.ts +228 -0
- package/src/comms/channels/signal.ts +56 -0
- package/src/comms/channels/telegram.ts +316 -0
- package/src/comms/channels/whatsapp.ts +60 -0
- package/src/comms/channels.test.ts +173 -0
- package/src/comms/desktop-notify.ts +114 -0
- package/src/comms/example.ts +129 -0
- package/src/comms/index.ts +129 -0
- package/src/comms/streaming.ts +142 -0
- package/src/comms/voice.test.ts +152 -0
- package/src/comms/voice.ts +291 -0
- package/src/comms/websocket.test.ts +409 -0
- package/src/comms/websocket.ts +473 -0
- package/src/config/README.md +387 -0
- package/src/config/index.ts +6 -0
- package/src/config/loader.test.ts +137 -0
- package/src/config/loader.ts +142 -0
- package/src/config/types.ts +260 -0
- package/src/daemon/README.md +232 -0
- package/src/daemon/agent-service-interface.ts +9 -0
- package/src/daemon/agent-service.ts +600 -0
- package/src/daemon/api-routes.ts +2119 -0
- package/src/daemon/background-agent-service.ts +396 -0
- package/src/daemon/background-agent.test.ts +78 -0
- package/src/daemon/channel-service.ts +201 -0
- package/src/daemon/commitment-executor.ts +297 -0
- package/src/daemon/event-classifier.ts +239 -0
- package/src/daemon/event-coalescer.ts +123 -0
- package/src/daemon/event-reactor.ts +214 -0
- package/src/daemon/health.ts +220 -0
- package/src/daemon/index.ts +1004 -0
- package/src/daemon/llm-settings.ts +316 -0
- package/src/daemon/observer-service.ts +150 -0
- package/src/daemon/pid.ts +98 -0
- package/src/daemon/research-queue.ts +155 -0
- package/src/daemon/services.ts +175 -0
- package/src/daemon/ws-service.ts +788 -0
- package/src/goals/accountability.ts +240 -0
- package/src/goals/awareness-bridge.ts +185 -0
- package/src/goals/estimator.ts +185 -0
- package/src/goals/events.ts +28 -0
- package/src/goals/goals.test.ts +400 -0
- package/src/goals/integration.test.ts +329 -0
- package/src/goals/nl-builder.test.ts +220 -0
- package/src/goals/nl-builder.ts +256 -0
- package/src/goals/rhythm.test.ts +177 -0
- package/src/goals/rhythm.ts +275 -0
- package/src/goals/service.test.ts +135 -0
- package/src/goals/service.ts +348 -0
- package/src/goals/types.ts +106 -0
- package/src/goals/workflow-bridge.ts +96 -0
- package/src/integrations/google-api.ts +134 -0
- package/src/integrations/google-auth.ts +175 -0
- package/src/llm/README.md +291 -0
- package/src/llm/anthropic.ts +386 -0
- package/src/llm/gemini.ts +371 -0
- package/src/llm/index.ts +19 -0
- package/src/llm/manager.ts +153 -0
- package/src/llm/ollama.ts +307 -0
- package/src/llm/openai.ts +350 -0
- package/src/llm/provider.test.ts +231 -0
- package/src/llm/provider.ts +60 -0
- package/src/llm/test.ts +87 -0
- package/src/observers/README.md +278 -0
- package/src/observers/calendar.ts +113 -0
- package/src/observers/clipboard.ts +136 -0
- package/src/observers/email.ts +109 -0
- package/src/observers/example.ts +58 -0
- package/src/observers/file-watcher.ts +124 -0
- package/src/observers/index.ts +159 -0
- package/src/observers/notifications.ts +197 -0
- package/src/observers/observers.test.ts +203 -0
- package/src/observers/processes.ts +225 -0
- package/src/personality/README.md +61 -0
- package/src/personality/adapter.ts +196 -0
- package/src/personality/index.ts +20 -0
- package/src/personality/learner.ts +209 -0
- package/src/personality/model.ts +132 -0
- package/src/personality/personality.test.ts +236 -0
- package/src/roles/README.md +252 -0
- package/src/roles/authority.ts +119 -0
- package/src/roles/example-usage.ts +198 -0
- package/src/roles/index.ts +42 -0
- package/src/roles/loader.ts +143 -0
- package/src/roles/prompt-builder.ts +194 -0
- package/src/roles/test-multi.ts +102 -0
- package/src/roles/test-role.yaml +77 -0
- package/src/roles/test-utils.ts +93 -0
- package/src/roles/test.ts +106 -0
- package/src/roles/tool-guide.ts +190 -0
- package/src/roles/types.ts +36 -0
- package/src/roles/utils.ts +200 -0
- package/src/scripts/google-setup.ts +168 -0
- package/src/sidecar/connection.ts +179 -0
- package/src/sidecar/index.ts +6 -0
- package/src/sidecar/manager.ts +542 -0
- package/src/sidecar/protocol.ts +85 -0
- package/src/sidecar/rpc.ts +161 -0
- package/src/sidecar/scheduler.ts +136 -0
- package/src/sidecar/types.ts +112 -0
- package/src/sidecar/validator.ts +144 -0
- package/src/vault/README.md +110 -0
- package/src/vault/awareness.ts +341 -0
- package/src/vault/commitments.ts +299 -0
- package/src/vault/content-pipeline.ts +260 -0
- package/src/vault/conversations.ts +173 -0
- package/src/vault/entities.ts +180 -0
- package/src/vault/extractor.test.ts +356 -0
- package/src/vault/extractor.ts +345 -0
- package/src/vault/facts.ts +190 -0
- package/src/vault/goals.ts +477 -0
- package/src/vault/index.ts +87 -0
- package/src/vault/keychain.ts +99 -0
- package/src/vault/observations.ts +115 -0
- package/src/vault/relationships.ts +178 -0
- package/src/vault/retrieval.test.ts +126 -0
- package/src/vault/retrieval.ts +227 -0
- package/src/vault/schema.ts +658 -0
- package/src/vault/settings.ts +38 -0
- package/src/vault/vectors.ts +92 -0
- package/src/vault/workflows.ts +403 -0
- package/src/workflows/auto-suggest.ts +290 -0
- package/src/workflows/engine.ts +366 -0
- package/src/workflows/events.ts +24 -0
- package/src/workflows/executor.ts +207 -0
- package/src/workflows/nl-builder.ts +198 -0
- package/src/workflows/nodes/actions/agent-task.ts +73 -0
- package/src/workflows/nodes/actions/calendar-action.ts +85 -0
- package/src/workflows/nodes/actions/code-execution.ts +73 -0
- package/src/workflows/nodes/actions/discord.ts +77 -0
- package/src/workflows/nodes/actions/file-write.ts +73 -0
- package/src/workflows/nodes/actions/gmail.ts +69 -0
- package/src/workflows/nodes/actions/http-request.ts +117 -0
- package/src/workflows/nodes/actions/notification.ts +85 -0
- package/src/workflows/nodes/actions/run-tool.ts +55 -0
- package/src/workflows/nodes/actions/send-message.ts +82 -0
- package/src/workflows/nodes/actions/shell-command.ts +76 -0
- package/src/workflows/nodes/actions/telegram.ts +60 -0
- package/src/workflows/nodes/builtin.ts +119 -0
- package/src/workflows/nodes/error/error-handler.ts +37 -0
- package/src/workflows/nodes/error/fallback.ts +47 -0
- package/src/workflows/nodes/error/retry.ts +82 -0
- package/src/workflows/nodes/logic/delay.ts +42 -0
- package/src/workflows/nodes/logic/if-else.ts +41 -0
- package/src/workflows/nodes/logic/loop.ts +90 -0
- package/src/workflows/nodes/logic/merge.ts +38 -0
- package/src/workflows/nodes/logic/race.ts +40 -0
- package/src/workflows/nodes/logic/switch.ts +59 -0
- package/src/workflows/nodes/logic/template-render.ts +53 -0
- package/src/workflows/nodes/logic/variable-get.ts +37 -0
- package/src/workflows/nodes/logic/variable-set.ts +59 -0
- package/src/workflows/nodes/registry.ts +99 -0
- package/src/workflows/nodes/transform/aggregate.ts +99 -0
- package/src/workflows/nodes/transform/csv-parse.ts +70 -0
- package/src/workflows/nodes/transform/json-parse.ts +63 -0
- package/src/workflows/nodes/transform/map-filter.ts +84 -0
- package/src/workflows/nodes/transform/regex-match.ts +89 -0
- package/src/workflows/nodes/triggers/calendar.ts +33 -0
- package/src/workflows/nodes/triggers/clipboard.ts +32 -0
- package/src/workflows/nodes/triggers/cron.ts +40 -0
- package/src/workflows/nodes/triggers/email.ts +40 -0
- package/src/workflows/nodes/triggers/file-change.ts +45 -0
- package/src/workflows/nodes/triggers/git.ts +46 -0
- package/src/workflows/nodes/triggers/manual.ts +23 -0
- package/src/workflows/nodes/triggers/poll.ts +81 -0
- package/src/workflows/nodes/triggers/process.ts +44 -0
- package/src/workflows/nodes/triggers/screen-event.ts +37 -0
- package/src/workflows/nodes/triggers/webhook.ts +39 -0
- package/src/workflows/safe-eval.ts +139 -0
- package/src/workflows/template.ts +118 -0
- package/src/workflows/triggers/cron.ts +311 -0
- package/src/workflows/triggers/manager.ts +285 -0
- package/src/workflows/triggers/observer-bridge.ts +172 -0
- package/src/workflows/triggers/poller.ts +201 -0
- package/src/workflows/triggers/screen-condition.ts +218 -0
- package/src/workflows/triggers/triggers.test.ts +740 -0
- package/src/workflows/triggers/webhook.ts +191 -0
- package/src/workflows/types.ts +133 -0
- package/src/workflows/variables.ts +72 -0
- package/src/workflows/workflows.test.ts +383 -0
- package/src/workflows/yaml.ts +104 -0
- package/ui/dist/index-j75njzc1.css +1199 -0
- package/ui/dist/index-p2zh407q.js +80603 -0
- package/ui/dist/index.html +13 -0
- package/ui/public/openwakeword/models/embedding_model.onnx +0 -0
- package/ui/public/openwakeword/models/hey_jarvis_v0.1.onnx +0 -0
- package/ui/public/openwakeword/models/melspectrogram.onnx +0 -0
- package/ui/public/openwakeword/models/silero_vad.onnx +0 -0
- package/ui/public/ort/ort-wasm-simd-threaded.jsep.mjs +106 -0
- package/ui/public/ort/ort-wasm-simd-threaded.jsep.wasm +0 -0
- package/ui/public/ort/ort-wasm-simd-threaded.mjs +59 -0
- package/ui/public/ort/ort-wasm-simd-threaded.wasm +0 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import type { STTConfig, TTSConfig } from '../config/types.ts';
|
|
2
|
+
import { Communicate } from 'edge-tts-universal';
|
|
3
|
+
|
|
4
|
+
export interface STTProvider {
|
|
5
|
+
transcribe(audio: Buffer): Promise<string>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface TTSProvider {
|
|
9
|
+
synthesize(text: string): Promise<Buffer>;
|
|
10
|
+
synthesizeStream(text: string): AsyncIterable<Buffer>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* OpenAI Whisper STT — uses the OpenAI /v1/audio/transcriptions endpoint.
|
|
15
|
+
*/
|
|
16
|
+
export class OpenAIWhisperSTT implements STTProvider {
|
|
17
|
+
private apiKey: string;
|
|
18
|
+
private model: string;
|
|
19
|
+
|
|
20
|
+
constructor(apiKey: string, model: string = 'whisper-1') {
|
|
21
|
+
this.apiKey = apiKey;
|
|
22
|
+
this.model = model;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async transcribe(audio: Buffer): Promise<string> {
|
|
26
|
+
const formData = new FormData();
|
|
27
|
+
formData.append('file', new Blob([new Uint8Array(audio)], { type: 'audio/webm' }), 'audio.webm');
|
|
28
|
+
formData.append('model', this.model);
|
|
29
|
+
formData.append('language', 'en');
|
|
30
|
+
|
|
31
|
+
const response = await fetch('https://api.openai.com/v1/audio/transcriptions', {
|
|
32
|
+
method: 'POST',
|
|
33
|
+
headers: { 'Authorization': `Bearer ${this.apiKey}` },
|
|
34
|
+
body: formData,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
if (!response.ok) {
|
|
38
|
+
const err = await response.text();
|
|
39
|
+
throw new Error(`OpenAI STT error (${response.status}): ${err}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const result = await response.json() as any;
|
|
43
|
+
return result.text;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Groq Whisper STT — uses Groq's OpenAI-compatible transcriptions endpoint.
|
|
49
|
+
*/
|
|
50
|
+
export class GroqWhisperSTT implements STTProvider {
|
|
51
|
+
private apiKey: string;
|
|
52
|
+
private model: string;
|
|
53
|
+
|
|
54
|
+
constructor(apiKey: string, model: string = 'whisper-large-v3-turbo') {
|
|
55
|
+
this.apiKey = apiKey;
|
|
56
|
+
this.model = model;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async transcribe(audio: Buffer): Promise<string> {
|
|
60
|
+
const formData = new FormData();
|
|
61
|
+
formData.append('file', new Blob([new Uint8Array(audio)], { type: 'audio/webm' }), 'audio.webm');
|
|
62
|
+
formData.append('model', this.model);
|
|
63
|
+
formData.append('language', 'en');
|
|
64
|
+
|
|
65
|
+
const response = await fetch('https://api.groq.com/openai/v1/audio/transcriptions', {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
headers: { 'Authorization': `Bearer ${this.apiKey}` },
|
|
68
|
+
body: formData,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
if (!response.ok) {
|
|
72
|
+
const err = await response.text();
|
|
73
|
+
throw new Error(`Groq STT error (${response.status}): ${err}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const result = await response.json() as any;
|
|
77
|
+
return result.text;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Local Whisper STT — connects to a whisper.cpp HTTP server or compatible endpoint.
|
|
83
|
+
*/
|
|
84
|
+
export class LocalWhisperSTT implements STTProvider {
|
|
85
|
+
private endpoint: string;
|
|
86
|
+
private model: string;
|
|
87
|
+
|
|
88
|
+
constructor(endpoint: string = 'http://localhost:8080', model?: string) {
|
|
89
|
+
this.endpoint = endpoint;
|
|
90
|
+
this.model = model ?? 'base';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async transcribe(audio: Buffer): Promise<string> {
|
|
94
|
+
const formData = new FormData();
|
|
95
|
+
formData.append('file', new Blob([new Uint8Array(audio)], { type: 'audio/webm' }), 'audio.webm');
|
|
96
|
+
formData.append('model', this.model);
|
|
97
|
+
formData.append('language', 'en');
|
|
98
|
+
|
|
99
|
+
const response = await fetch(`${this.endpoint}/inference`, {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
body: formData,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (!response.ok) {
|
|
105
|
+
const err = await response.text();
|
|
106
|
+
throw new Error(`Local Whisper STT error (${response.status}): ${err}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const result = await response.json() as any;
|
|
110
|
+
return result.text;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Factory: create the right STT provider from config.
|
|
116
|
+
* Returns null if the selected provider lacks required credentials.
|
|
117
|
+
*/
|
|
118
|
+
export function createSTTProvider(config: STTConfig): STTProvider | null {
|
|
119
|
+
switch (config.provider) {
|
|
120
|
+
case 'openai':
|
|
121
|
+
if (!config.openai?.api_key) return null;
|
|
122
|
+
return new OpenAIWhisperSTT(config.openai.api_key, config.openai.model);
|
|
123
|
+
case 'groq':
|
|
124
|
+
if (!config.groq?.api_key) return null;
|
|
125
|
+
return new GroqWhisperSTT(config.groq.api_key, config.groq.model);
|
|
126
|
+
case 'local':
|
|
127
|
+
return new LocalWhisperSTT(config.local?.endpoint, config.local?.model);
|
|
128
|
+
default:
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Edge TTS Provider — uses Microsoft Edge's online TTS service (free, no API key).
|
|
135
|
+
* Runs server-side only (browser WebSocket can't set required headers).
|
|
136
|
+
*/
|
|
137
|
+
export class EdgeTTSProvider implements TTSProvider {
|
|
138
|
+
private voice: string;
|
|
139
|
+
private rate: string;
|
|
140
|
+
private volume: string;
|
|
141
|
+
|
|
142
|
+
constructor(voice = 'en-US-AriaNeural', rate = '+0%', volume = '+0%') {
|
|
143
|
+
this.voice = voice;
|
|
144
|
+
this.rate = rate;
|
|
145
|
+
this.volume = volume;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async synthesize(text: string): Promise<Buffer> {
|
|
149
|
+
const comm = new Communicate(text, {
|
|
150
|
+
voice: this.voice,
|
|
151
|
+
rate: this.rate,
|
|
152
|
+
volume: this.volume,
|
|
153
|
+
});
|
|
154
|
+
const chunks: Buffer[] = [];
|
|
155
|
+
for await (const chunk of comm.stream()) {
|
|
156
|
+
if (chunk.type === 'audio' && chunk.data) {
|
|
157
|
+
chunks.push(chunk.data);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return Buffer.concat(chunks);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Streaming variant: synthesizes text and yields a single complete MP3 buffer.
|
|
165
|
+
* Called per-sentence so the caller can pipeline multiple sentences.
|
|
166
|
+
* Each yielded buffer is a valid, decodable MP3 file.
|
|
167
|
+
*/
|
|
168
|
+
async *synthesizeStream(text: string): AsyncIterable<Buffer> {
|
|
169
|
+
// Collect all chunks into a complete MP3 — individual edge-tts
|
|
170
|
+
// fragments are not valid standalone audio files
|
|
171
|
+
const audio = await this.synthesize(text);
|
|
172
|
+
if (audio.length > 0) {
|
|
173
|
+
yield audio;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* ElevenLabs TTS Provider — high-quality personalized voices via ElevenLabs API.
|
|
180
|
+
* Supports true streaming (chunks are valid playable audio).
|
|
181
|
+
*/
|
|
182
|
+
export class ElevenLabsTTSProvider implements TTSProvider {
|
|
183
|
+
private apiKey: string;
|
|
184
|
+
private voiceId: string;
|
|
185
|
+
private model: string;
|
|
186
|
+
private stability: number;
|
|
187
|
+
private similarityBoost: number;
|
|
188
|
+
|
|
189
|
+
constructor(config: NonNullable<TTSConfig['elevenlabs']>) {
|
|
190
|
+
this.apiKey = config.api_key;
|
|
191
|
+
this.voiceId = config.voice_id ?? '21m00Tcm4TlvDq8ikWAM'; // Rachel (default)
|
|
192
|
+
this.model = config.model ?? 'eleven_flash_v2_5';
|
|
193
|
+
this.stability = config.stability ?? 0.5;
|
|
194
|
+
this.similarityBoost = config.similarity_boost ?? 0.75;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async synthesize(text: string): Promise<Buffer> {
|
|
198
|
+
const response = await fetch(
|
|
199
|
+
`https://api.elevenlabs.io/v1/text-to-speech/${this.voiceId}/stream?output_format=mp3_44100_128`,
|
|
200
|
+
{
|
|
201
|
+
method: 'POST',
|
|
202
|
+
headers: {
|
|
203
|
+
'xi-api-key': this.apiKey,
|
|
204
|
+
'Content-Type': 'application/json',
|
|
205
|
+
},
|
|
206
|
+
body: JSON.stringify({
|
|
207
|
+
text,
|
|
208
|
+
model_id: this.model,
|
|
209
|
+
voice_settings: {
|
|
210
|
+
stability: this.stability,
|
|
211
|
+
similarity_boost: this.similarityBoost,
|
|
212
|
+
},
|
|
213
|
+
}),
|
|
214
|
+
}
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
if (!response.ok) {
|
|
218
|
+
const err = await response.text();
|
|
219
|
+
throw new Error(`ElevenLabs TTS error (${response.status}): ${err}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
223
|
+
return Buffer.from(arrayBuffer);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async *synthesizeStream(text: string): AsyncIterable<Buffer> {
|
|
227
|
+
// Collect into a complete MP3 per sentence — individual streaming
|
|
228
|
+
// fragments are not decodable by the browser's AudioContext.decodeAudioData
|
|
229
|
+
const audio = await this.synthesize(text);
|
|
230
|
+
if (audio.length > 0) {
|
|
231
|
+
yield audio;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Fetch available voices from ElevenLabs API.
|
|
238
|
+
*/
|
|
239
|
+
export async function listElevenLabsVoices(apiKey: string): Promise<{
|
|
240
|
+
voice_id: string;
|
|
241
|
+
name: string;
|
|
242
|
+
category: string;
|
|
243
|
+
}[]> {
|
|
244
|
+
const response = await fetch('https://api.elevenlabs.io/v1/voices', {
|
|
245
|
+
headers: { 'xi-api-key': apiKey },
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
if (!response.ok) {
|
|
249
|
+
const err = await response.text();
|
|
250
|
+
throw new Error(`ElevenLabs voices error (${response.status}): ${err}`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const data = await response.json() as any;
|
|
254
|
+
return (data.voices ?? []).map((v: any) => ({
|
|
255
|
+
voice_id: v.voice_id,
|
|
256
|
+
name: v.name,
|
|
257
|
+
category: v.category ?? 'unknown',
|
|
258
|
+
}));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Factory: create the right TTS provider from config.
|
|
263
|
+
* Returns null if TTS is disabled.
|
|
264
|
+
*/
|
|
265
|
+
export function createTTSProvider(config: TTSConfig): TTSProvider | null {
|
|
266
|
+
if (!config.enabled) return null;
|
|
267
|
+
|
|
268
|
+
if (config.provider === 'elevenlabs') {
|
|
269
|
+
if (!config.elevenlabs?.api_key) return null;
|
|
270
|
+
return new ElevenLabsTTSProvider(config.elevenlabs);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Default: Edge TTS
|
|
274
|
+
return new EdgeTTSProvider(config.voice, config.rate, config.volume);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Split text into sentences for streaming TTS.
|
|
279
|
+
* Each sentence is synthesized and played independently for low latency.
|
|
280
|
+
*/
|
|
281
|
+
export function splitIntoSentences(text: string): string[] {
|
|
282
|
+
// Collapse code blocks to avoid splitting on periods inside code
|
|
283
|
+
const collapsed = text.replace(/```[\s\S]*?```/g, '[code block]');
|
|
284
|
+
// Split on sentence-ending punctuation followed by whitespace + capital letter,
|
|
285
|
+
// or on double newlines (paragraph breaks)
|
|
286
|
+
const sentences = collapsed
|
|
287
|
+
.split(/(?<=[.!?])\s+(?=[A-Z])|(?<=\n\n)/)
|
|
288
|
+
.map(s => s.trim())
|
|
289
|
+
.filter(s => s.length > 0);
|
|
290
|
+
return sentences.length > 0 ? sentences : [text];
|
|
291
|
+
}
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import { test, expect, beforeEach, afterEach } from 'bun:test';
|
|
2
|
+
import { WebSocketServer, type WSMessage } from './websocket.ts';
|
|
3
|
+
|
|
4
|
+
let server: WebSocketServer;
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
server = new WebSocketServer(3143); // Use different port for tests
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
if (server.isRunning()) {
|
|
12
|
+
server.stop();
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('WebSocketServer - initialization', () => {
|
|
17
|
+
expect(server.isRunning()).toBe(false);
|
|
18
|
+
expect(server.getPort()).toBe(3143);
|
|
19
|
+
expect(server.getClientCount()).toBe(0);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('WebSocketServer - start and stop', () => {
|
|
23
|
+
server.start();
|
|
24
|
+
expect(server.isRunning()).toBe(true);
|
|
25
|
+
|
|
26
|
+
server.stop();
|
|
27
|
+
expect(server.isRunning()).toBe(false);
|
|
28
|
+
expect(server.getClientCount()).toBe(0);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('WebSocketServer - health endpoint', async () => {
|
|
32
|
+
server.start();
|
|
33
|
+
|
|
34
|
+
const response = await fetch('http://localhost:3143/health');
|
|
35
|
+
expect(response.ok).toBe(true);
|
|
36
|
+
|
|
37
|
+
const data = await response.json() as any;
|
|
38
|
+
expect(data.status).toBe('ok');
|
|
39
|
+
expect(data.clients).toBe(0);
|
|
40
|
+
expect(typeof data.uptime).toBe('number');
|
|
41
|
+
|
|
42
|
+
server.stop();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('WebSocketServer - root endpoint returns 404 without static dir', async () => {
|
|
46
|
+
server.start();
|
|
47
|
+
|
|
48
|
+
const response = await fetch('http://localhost:3143/');
|
|
49
|
+
expect(response.status).toBe(404);
|
|
50
|
+
|
|
51
|
+
server.stop();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('WebSocketServer - WebSocket connection', async () => {
|
|
55
|
+
let connectCalled = false;
|
|
56
|
+
let disconnectCalled = false;
|
|
57
|
+
|
|
58
|
+
server.setHandler({
|
|
59
|
+
async onMessage(msg, _ws) {
|
|
60
|
+
return {
|
|
61
|
+
type: 'status',
|
|
62
|
+
payload: { echo: msg.payload },
|
|
63
|
+
timestamp: Date.now(),
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
onConnect(_ws) {
|
|
67
|
+
connectCalled = true;
|
|
68
|
+
},
|
|
69
|
+
onDisconnect(_ws) {
|
|
70
|
+
disconnectCalled = true;
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
server.start();
|
|
75
|
+
|
|
76
|
+
// Connect WebSocket client
|
|
77
|
+
const ws = new WebSocket('ws://localhost:3143/ws');
|
|
78
|
+
|
|
79
|
+
await new Promise<void>((resolve) => {
|
|
80
|
+
ws.onopen = () => {
|
|
81
|
+
expect(server.getClientCount()).toBe(1);
|
|
82
|
+
expect(connectCalled).toBe(true);
|
|
83
|
+
resolve();
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Send message and receive response
|
|
88
|
+
const received = await new Promise<WSMessage>((resolve) => {
|
|
89
|
+
ws.onmessage = (event) => {
|
|
90
|
+
resolve(JSON.parse(event.data));
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const testMessage: WSMessage = {
|
|
94
|
+
type: 'chat',
|
|
95
|
+
payload: 'Hello J.A.R.V.I.S.',
|
|
96
|
+
timestamp: Date.now(),
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
ws.send(JSON.stringify(testMessage));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(received.type).toBe('status');
|
|
103
|
+
expect(received.payload).toEqual({ echo: 'Hello J.A.R.V.I.S.' });
|
|
104
|
+
|
|
105
|
+
// Close connection
|
|
106
|
+
ws.close();
|
|
107
|
+
|
|
108
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
109
|
+
expect(disconnectCalled).toBe(true);
|
|
110
|
+
expect(server.getClientCount()).toBe(0);
|
|
111
|
+
|
|
112
|
+
server.stop();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('WebSocketServer - broadcast', async () => {
|
|
116
|
+
server.start();
|
|
117
|
+
|
|
118
|
+
const clients: WebSocket[] = [];
|
|
119
|
+
const messages: WSMessage[][] = [[], []];
|
|
120
|
+
|
|
121
|
+
// Connect two clients
|
|
122
|
+
for (let i = 0; i < 2; i++) {
|
|
123
|
+
const ws = new WebSocket('ws://localhost:3143/ws');
|
|
124
|
+
clients.push(ws);
|
|
125
|
+
|
|
126
|
+
await new Promise<void>((resolve) => {
|
|
127
|
+
ws.onopen = () => resolve();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
ws.onmessage = (event) => {
|
|
131
|
+
messages[i]!.push(JSON.parse(event.data));
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
expect(server.getClientCount()).toBe(2);
|
|
136
|
+
|
|
137
|
+
// Broadcast message
|
|
138
|
+
const broadcastMsg: WSMessage = {
|
|
139
|
+
type: 'status',
|
|
140
|
+
payload: { text: 'Broadcast to all' },
|
|
141
|
+
timestamp: Date.now(),
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
server.broadcast(broadcastMsg);
|
|
145
|
+
|
|
146
|
+
// Wait for messages to arrive
|
|
147
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
148
|
+
|
|
149
|
+
expect(messages[0]!.length).toBe(1);
|
|
150
|
+
expect(messages[1]!.length).toBe(1);
|
|
151
|
+
expect(messages[0]![0]!.payload).toEqual({ text: 'Broadcast to all' });
|
|
152
|
+
expect(messages[1]![0]!.payload).toEqual({ text: 'Broadcast to all' });
|
|
153
|
+
|
|
154
|
+
clients.forEach((ws) => ws.close());
|
|
155
|
+
server.stop();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('WebSocketServer - binary message routing', async () => {
|
|
159
|
+
let receivedBinary: Buffer | null = null;
|
|
160
|
+
let receivedFromWs: any = null;
|
|
161
|
+
|
|
162
|
+
server.setHandler({
|
|
163
|
+
async onMessage(msg, _ws) { return undefined; },
|
|
164
|
+
async onBinaryMessage(data, ws) {
|
|
165
|
+
receivedBinary = data;
|
|
166
|
+
receivedFromWs = ws;
|
|
167
|
+
},
|
|
168
|
+
onConnect(_ws) {},
|
|
169
|
+
onDisconnect(_ws) {},
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
server.start();
|
|
173
|
+
|
|
174
|
+
const ws = new WebSocket('ws://localhost:3143/ws');
|
|
175
|
+
await new Promise<void>((resolve) => { ws.onopen = () => resolve(); });
|
|
176
|
+
|
|
177
|
+
// Send binary data
|
|
178
|
+
const testData = new Uint8Array([1, 2, 3, 4, 5]);
|
|
179
|
+
ws.send(testData.buffer);
|
|
180
|
+
|
|
181
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
182
|
+
expect(receivedBinary).not.toBeNull();
|
|
183
|
+
expect(receivedBinary!.length).toBe(5);
|
|
184
|
+
expect(receivedFromWs).not.toBeNull();
|
|
185
|
+
|
|
186
|
+
ws.close();
|
|
187
|
+
server.stop();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('WebSocketServer - sendBinary reaches client', async () => {
|
|
191
|
+
let serverWsRef: any = null;
|
|
192
|
+
|
|
193
|
+
server.setHandler({
|
|
194
|
+
async onMessage(msg, ws) {
|
|
195
|
+
serverWsRef = ws;
|
|
196
|
+
return { type: 'status', payload: { ok: true }, timestamp: Date.now() };
|
|
197
|
+
},
|
|
198
|
+
onConnect(_ws) {},
|
|
199
|
+
onDisconnect(_ws) {},
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
server.start();
|
|
203
|
+
|
|
204
|
+
const ws = new WebSocket('ws://localhost:3143/ws');
|
|
205
|
+
ws.binaryType = 'arraybuffer';
|
|
206
|
+
|
|
207
|
+
let receivedBinary: ArrayBuffer | null = null;
|
|
208
|
+
|
|
209
|
+
await new Promise<void>((resolve) => { ws.onopen = () => resolve(); });
|
|
210
|
+
|
|
211
|
+
ws.onmessage = (e) => {
|
|
212
|
+
if (e.data instanceof ArrayBuffer) {
|
|
213
|
+
receivedBinary = e.data;
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
// Send a JSON message first to capture the server ws ref
|
|
218
|
+
ws.send(JSON.stringify({ type: 'status', payload: {}, timestamp: Date.now() }));
|
|
219
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
220
|
+
|
|
221
|
+
expect(serverWsRef).not.toBeNull();
|
|
222
|
+
|
|
223
|
+
// Send binary from server to client
|
|
224
|
+
server.sendBinary(serverWsRef, Buffer.from([10, 20, 30]));
|
|
225
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
226
|
+
|
|
227
|
+
expect(receivedBinary).not.toBeNull();
|
|
228
|
+
expect(new Uint8Array(receivedBinary!)).toEqual(new Uint8Array([10, 20, 30]));
|
|
229
|
+
|
|
230
|
+
ws.close();
|
|
231
|
+
server.stop();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// --- Auth tests (use dedicated ports to avoid port reuse timing issues) ---
|
|
235
|
+
|
|
236
|
+
test('WebSocketServer - auth token blocks unauthenticated requests', async () => {
|
|
237
|
+
const authServer = new WebSocketServer(3150);
|
|
238
|
+
authServer.setAuthToken('test-secret-123');
|
|
239
|
+
authServer.start();
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
// Health is public — still accessible
|
|
243
|
+
const health = await fetch('http://localhost:3150/health');
|
|
244
|
+
expect(health.ok).toBe(true);
|
|
245
|
+
|
|
246
|
+
// API without cookie → 401 JSON
|
|
247
|
+
const api = await fetch('http://localhost:3150/api/health');
|
|
248
|
+
expect(api.status).toBe(401);
|
|
249
|
+
const body = await api.json() as any;
|
|
250
|
+
expect(body.error).toBe('Unauthorized');
|
|
251
|
+
|
|
252
|
+
// Dashboard without cookie → 401 HTML with hash-to-query bootstrap script
|
|
253
|
+
const dash = await fetch('http://localhost:3150/');
|
|
254
|
+
expect(dash.status).toBe(401);
|
|
255
|
+
const html = await dash.text();
|
|
256
|
+
expect(html).toContain("location.replace");
|
|
257
|
+
expect(html).toContain(".get('token')");
|
|
258
|
+
|
|
259
|
+
// Query param with valid token → 302 + Set-Cookie
|
|
260
|
+
const withToken = await fetch('http://localhost:3150/?token=test-secret-123', { redirect: 'manual' });
|
|
261
|
+
expect(withToken.status).toBe(302);
|
|
262
|
+
expect(withToken.headers.get('Set-Cookie')).toContain('token=test-secret-123');
|
|
263
|
+
expect(withToken.headers.get('Location')).toBe('/');
|
|
264
|
+
|
|
265
|
+
// Query param with wrong token → 401
|
|
266
|
+
const wrongToken = await fetch('http://localhost:3150/?token=wrong', { redirect: 'manual' });
|
|
267
|
+
expect(wrongToken.status).toBe(401);
|
|
268
|
+
} finally {
|
|
269
|
+
authServer.stop();
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test('WebSocketServer - auth token allows requests with valid cookie', async () => {
|
|
274
|
+
const authServer = new WebSocketServer(3151);
|
|
275
|
+
authServer.setAuthToken('test-secret-123');
|
|
276
|
+
authServer.setApiRoutes({
|
|
277
|
+
'/api/health': {
|
|
278
|
+
GET: () => Response.json({ status: 'ok' }),
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
authServer.start();
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
const res = await fetch('http://localhost:3151/api/health', {
|
|
285
|
+
headers: { Cookie: 'token=test-secret-123' },
|
|
286
|
+
});
|
|
287
|
+
expect(res.ok).toBe(true);
|
|
288
|
+
const data = await res.json() as any;
|
|
289
|
+
expect(data.status).toBe('ok');
|
|
290
|
+
} finally {
|
|
291
|
+
authServer.stop();
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('WebSocketServer - auth token rejects wrong cookie', async () => {
|
|
296
|
+
const authServer = new WebSocketServer(3152);
|
|
297
|
+
authServer.setAuthToken('test-secret-123');
|
|
298
|
+
authServer.start();
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
const res = await fetch('http://localhost:3152/api/health', {
|
|
302
|
+
headers: { Cookie: 'token=wrong-token' },
|
|
303
|
+
});
|
|
304
|
+
expect(res.status).toBe(401);
|
|
305
|
+
} finally {
|
|
306
|
+
authServer.stop();
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test('WebSocketServer - public routes bypass auth', async () => {
|
|
311
|
+
const authServer = new WebSocketServer(3153);
|
|
312
|
+
authServer.setAuthToken('test-secret-123');
|
|
313
|
+
authServer.start();
|
|
314
|
+
|
|
315
|
+
try {
|
|
316
|
+
// /health is public
|
|
317
|
+
const health = await fetch('http://localhost:3153/health');
|
|
318
|
+
expect(health.ok).toBe(true);
|
|
319
|
+
|
|
320
|
+
// OPTIONS (CORS preflight) is public
|
|
321
|
+
const options = await fetch('http://localhost:3153/api/anything', { method: 'OPTIONS' });
|
|
322
|
+
expect(options.status).not.toBe(401);
|
|
323
|
+
} finally {
|
|
324
|
+
authServer.stop();
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test('WebSocketServer - WebSocket blocked without auth cookie', async () => {
|
|
329
|
+
const authServer = new WebSocketServer(3154);
|
|
330
|
+
authServer.setAuthToken('test-secret-123');
|
|
331
|
+
authServer.start();
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
// Regular HTTP fetch to /ws without cookie → 401 JSON
|
|
335
|
+
const res = await fetch('http://localhost:3154/ws');
|
|
336
|
+
expect(res.status).toBe(401);
|
|
337
|
+
} finally {
|
|
338
|
+
authServer.stop();
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test('WebSocketServer - WebSocket allowed with auth cookie', async () => {
|
|
343
|
+
const authServer = new WebSocketServer(3155);
|
|
344
|
+
authServer.setAuthToken('test-secret-123');
|
|
345
|
+
authServer.start();
|
|
346
|
+
|
|
347
|
+
try {
|
|
348
|
+
const ws = new WebSocket('ws://localhost:3155/ws', {
|
|
349
|
+
headers: { Cookie: 'token=test-secret-123' },
|
|
350
|
+
} as any);
|
|
351
|
+
|
|
352
|
+
const connected = await new Promise<boolean>((resolve) => {
|
|
353
|
+
ws.onopen = () => resolve(true);
|
|
354
|
+
ws.onerror = () => resolve(false);
|
|
355
|
+
setTimeout(() => resolve(false), 2000);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
expect(connected).toBe(true);
|
|
359
|
+
ws.close();
|
|
360
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
361
|
+
} finally {
|
|
362
|
+
authServer.stop();
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test('WebSocketServer - sendToClient unicasts JSON', async () => {
|
|
367
|
+
let serverWsRef: any = null;
|
|
368
|
+
|
|
369
|
+
server.setHandler({
|
|
370
|
+
async onMessage(msg, ws) {
|
|
371
|
+
serverWsRef = ws;
|
|
372
|
+
return undefined; // No auto-response
|
|
373
|
+
},
|
|
374
|
+
onConnect(_ws) {},
|
|
375
|
+
onDisconnect(_ws) {},
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
server.start();
|
|
379
|
+
|
|
380
|
+
const ws = new WebSocket('ws://localhost:3143/ws');
|
|
381
|
+
const received: WSMessage[] = [];
|
|
382
|
+
|
|
383
|
+
await new Promise<void>((resolve) => { ws.onopen = () => resolve(); });
|
|
384
|
+
|
|
385
|
+
ws.onmessage = (e) => {
|
|
386
|
+
if (typeof e.data === 'string') {
|
|
387
|
+
received.push(JSON.parse(e.data));
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
// Trigger to get ws ref
|
|
392
|
+
ws.send(JSON.stringify({ type: 'command', payload: {}, timestamp: Date.now() }));
|
|
393
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
394
|
+
|
|
395
|
+
// Unicast a tts_start message
|
|
396
|
+
server.sendToClient(serverWsRef, {
|
|
397
|
+
type: 'tts_start',
|
|
398
|
+
payload: { requestId: 'test-123' },
|
|
399
|
+
timestamp: Date.now(),
|
|
400
|
+
});
|
|
401
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
402
|
+
|
|
403
|
+
expect(received.length).toBe(1);
|
|
404
|
+
expect(received[0]!.type).toBe('tts_start');
|
|
405
|
+
expect((received[0]!.payload as any).requestId).toBe('test-123');
|
|
406
|
+
|
|
407
|
+
ws.close();
|
|
408
|
+
server.stop();
|
|
409
|
+
});
|