felona-voice 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/dist/agent.d.ts +105 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +287 -0
- package/dist/agent.js.map +1 -0
- package/dist/analytics/index.d.ts +3 -0
- package/dist/analytics/index.d.ts.map +1 -0
- package/dist/analytics/index.js +2 -0
- package/dist/analytics/index.js.map +1 -0
- package/dist/analytics/logger.d.ts +92 -0
- package/dist/analytics/logger.d.ts.map +1 -0
- package/dist/analytics/logger.js +75 -0
- package/dist/analytics/logger.js.map +1 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +54 -0
- package/dist/index.js.map +1 -0
- package/dist/jev/action-space.d.ts +38 -0
- package/dist/jev/action-space.d.ts.map +1 -0
- package/dist/jev/action-space.js +92 -0
- package/dist/jev/action-space.js.map +1 -0
- package/dist/jev/embeddings.d.ts +23 -0
- package/dist/jev/embeddings.d.ts.map +1 -0
- package/dist/jev/embeddings.js +63 -0
- package/dist/jev/embeddings.js.map +1 -0
- package/dist/jev/engine.d.ts +101 -0
- package/dist/jev/engine.d.ts.map +1 -0
- package/dist/jev/engine.js +173 -0
- package/dist/jev/engine.js.map +1 -0
- package/dist/jev/index.d.ts +4 -0
- package/dist/jev/index.d.ts.map +1 -0
- package/dist/jev/index.js +4 -0
- package/dist/jev/index.js.map +1 -0
- package/dist/llm/index.d.ts +2 -0
- package/dist/llm/index.d.ts.map +1 -0
- package/dist/llm/index.js +2 -0
- package/dist/llm/index.js.map +1 -0
- package/dist/llm/openai.d.ts +22 -0
- package/dist/llm/openai.d.ts.map +1 -0
- package/dist/llm/openai.js +140 -0
- package/dist/llm/openai.js.map +1 -0
- package/dist/memory/context.d.ts +53 -0
- package/dist/memory/context.d.ts.map +1 -0
- package/dist/memory/context.js +95 -0
- package/dist/memory/context.js.map +1 -0
- package/dist/memory/index.d.ts +2 -0
- package/dist/memory/index.d.ts.map +1 -0
- package/dist/memory/index.js +2 -0
- package/dist/memory/index.js.map +1 -0
- package/dist/pipeline.d.ts +93 -0
- package/dist/pipeline.d.ts.map +1 -0
- package/dist/pipeline.js +255 -0
- package/dist/pipeline.js.map +1 -0
- package/dist/stt/deepgram.d.ts +27 -0
- package/dist/stt/deepgram.d.ts.map +1 -0
- package/dist/stt/deepgram.js +137 -0
- package/dist/stt/deepgram.js.map +1 -0
- package/dist/stt/index.d.ts +2 -0
- package/dist/stt/index.d.ts.map +1 -0
- package/dist/stt/index.js +2 -0
- package/dist/stt/index.js.map +1 -0
- package/dist/tools/index.d.ts +2 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +2 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/registry.d.ts +38 -0
- package/dist/tools/registry.d.ts.map +1 -0
- package/dist/tools/registry.js +77 -0
- package/dist/tools/registry.js.map +1 -0
- package/dist/transport/index.d.ts +2 -0
- package/dist/transport/index.d.ts.map +1 -0
- package/dist/transport/index.js +2 -0
- package/dist/transport/index.js.map +1 -0
- package/dist/transport/websocket.d.ts +38 -0
- package/dist/transport/websocket.d.ts.map +1 -0
- package/dist/transport/websocket.js +180 -0
- package/dist/transport/websocket.js.map +1 -0
- package/dist/tts/eleven-labs.d.ts +27 -0
- package/dist/tts/eleven-labs.d.ts.map +1 -0
- package/dist/tts/eleven-labs.js +73 -0
- package/dist/tts/eleven-labs.js.map +1 -0
- package/dist/tts/index.d.ts +2 -0
- package/dist/tts/index.d.ts.map +1 -0
- package/dist/tts/index.js +2 -0
- package/dist/tts/index.js.map +1 -0
- package/dist/types.d.ts +370 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +13 -0
- package/dist/types.js.map +1 -0
- package/dist/vad/energy.d.ts +49 -0
- package/dist/vad/energy.d.ts.map +1 -0
- package/dist/vad/energy.js +108 -0
- package/dist/vad/energy.js.map +1 -0
- package/dist/vad/index.d.ts +2 -0
- package/dist/vad/index.d.ts.map +1 -0
- package/dist/vad/index.js +2 -0
- package/dist/vad/index.js.map +1 -0
- package/package.json +51 -0
- package/src/agent.ts +373 -0
- package/src/analytics/index.ts +7 -0
- package/src/analytics/logger.ts +152 -0
- package/src/index.ts +121 -0
- package/src/jev/action-space.ts +119 -0
- package/src/jev/embeddings.ts +81 -0
- package/src/jev/engine.ts +225 -0
- package/src/jev/index.ts +3 -0
- package/src/llm/index.ts +1 -0
- package/src/llm/openai.ts +178 -0
- package/src/memory/context.ts +124 -0
- package/src/memory/index.ts +1 -0
- package/src/pipeline.ts +331 -0
- package/src/stt/deepgram.ts +182 -0
- package/src/stt/index.ts +1 -0
- package/src/tools/index.ts +1 -0
- package/src/tools/registry.ts +99 -0
- package/src/transport/index.ts +1 -0
- package/src/transport/websocket.ts +227 -0
- package/src/tts/eleven-labs.ts +100 -0
- package/src/tts/index.ts +1 -0
- package/src/types.ts +429 -0
- package/src/vad/energy.ts +135 -0
- package/src/vad/index.ts +1 -0
- package/tests/jev.test.ts +198 -0
- package/tests/memory.test.ts +169 -0
- package/tests/vad.test.ts +94 -0
- package/tsconfig.json +9 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { WebSocket } from "ws";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import type {
|
|
4
|
+
STTProvider,
|
|
5
|
+
STTStream,
|
|
6
|
+
STTStreamOptions,
|
|
7
|
+
STTResult,
|
|
8
|
+
AudioChunk,
|
|
9
|
+
} from "../types.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* DeepgramSTT — Streaming Speech-to-Text using Deepgram's WebSocket API.
|
|
13
|
+
*
|
|
14
|
+
* Deepgram is the default STT provider because:
|
|
15
|
+
* - Sub-300ms latency for streaming recognition
|
|
16
|
+
* - Excellent accuracy with Nova-2 model
|
|
17
|
+
* - Native WebSocket API (no HTTP polling)
|
|
18
|
+
* - Supports interim results for real-time feedback
|
|
19
|
+
*/
|
|
20
|
+
export class DeepgramSTT implements STTProvider {
|
|
21
|
+
readonly name = "deepgram";
|
|
22
|
+
private readonly apiKey: string;
|
|
23
|
+
private readonly model: string;
|
|
24
|
+
private readonly baseUrl: string;
|
|
25
|
+
|
|
26
|
+
constructor(options: {
|
|
27
|
+
apiKey: string;
|
|
28
|
+
model?: string;
|
|
29
|
+
baseUrl?: string;
|
|
30
|
+
}) {
|
|
31
|
+
this.apiKey = options.apiKey;
|
|
32
|
+
this.model = options.model ?? "nova-2";
|
|
33
|
+
this.baseUrl =
|
|
34
|
+
options.baseUrl ?? "wss://api.deepgram.com/v1/listen";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
createStream(options?: STTStreamOptions): STTStream {
|
|
38
|
+
return new DeepgramSTTStream({
|
|
39
|
+
apiKey: this.apiKey,
|
|
40
|
+
model: this.model,
|
|
41
|
+
baseUrl: this.baseUrl,
|
|
42
|
+
language: options?.language ?? "en-US",
|
|
43
|
+
interimResults: options?.interimResults ?? true,
|
|
44
|
+
keywords: options?.keywords,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
class DeepgramSTTStream extends EventEmitter implements STTStream {
|
|
50
|
+
private ws: WebSocket | null = null;
|
|
51
|
+
private resultHandler: ((result: STTResult) => void) | null = null;
|
|
52
|
+
private readonly config: {
|
|
53
|
+
apiKey: string;
|
|
54
|
+
model: string;
|
|
55
|
+
baseUrl: string;
|
|
56
|
+
language: string;
|
|
57
|
+
interimResults: boolean;
|
|
58
|
+
keywords?: string[];
|
|
59
|
+
};
|
|
60
|
+
private connected = false;
|
|
61
|
+
private connectPromise: Promise<void> | null = null;
|
|
62
|
+
|
|
63
|
+
constructor(config: {
|
|
64
|
+
apiKey: string;
|
|
65
|
+
model: string;
|
|
66
|
+
baseUrl: string;
|
|
67
|
+
language: string;
|
|
68
|
+
interimResults: boolean;
|
|
69
|
+
keywords?: string[];
|
|
70
|
+
}) {
|
|
71
|
+
super();
|
|
72
|
+
this.config = config;
|
|
73
|
+
this.connectPromise = this.connect();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private async connect(): Promise<void> {
|
|
77
|
+
const params = new URLSearchParams({
|
|
78
|
+
model: this.config.model,
|
|
79
|
+
language: this.config.language,
|
|
80
|
+
interim_results: String(this.config.interimResults),
|
|
81
|
+
punctuate: "true",
|
|
82
|
+
encoding: "linear16",
|
|
83
|
+
sample_rate: "16000",
|
|
84
|
+
channels: "1",
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
if (this.config.keywords?.length) {
|
|
88
|
+
for (const kw of this.config.keywords) {
|
|
89
|
+
params.append("keywords", kw);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const url = `${this.config.baseUrl}?${params.toString()}`;
|
|
94
|
+
|
|
95
|
+
return new Promise<void>((resolve, reject) => {
|
|
96
|
+
this.ws = new WebSocket(url, {
|
|
97
|
+
headers: {
|
|
98
|
+
Authorization: `Token ${this.config.apiKey}`,
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
this.ws.on("open", () => {
|
|
103
|
+
this.connected = true;
|
|
104
|
+
resolve();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
this.ws.on("message", (data) => {
|
|
108
|
+
try {
|
|
109
|
+
const response = JSON.parse(data.toString());
|
|
110
|
+
this.handleResponse(response);
|
|
111
|
+
} catch {
|
|
112
|
+
// Ignore non-JSON messages
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
this.ws.on("error", (error) => {
|
|
117
|
+
console.error("[STT/Deepgram] WebSocket error:", error.message);
|
|
118
|
+
if (!this.connected) {
|
|
119
|
+
reject(error);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
this.ws.on("close", () => {
|
|
124
|
+
this.connected = false;
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
write(chunk: AudioChunk): void {
|
|
130
|
+
if (this.ws && this.connected && this.ws.readyState === WebSocket.OPEN) {
|
|
131
|
+
this.ws.send(chunk.data);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
onResult(handler: (result: STTResult) => void): void {
|
|
136
|
+
this.resultHandler = handler;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async close(): Promise<void> {
|
|
140
|
+
if (this.ws) {
|
|
141
|
+
// Send close message per Deepgram protocol
|
|
142
|
+
if (this.ws.readyState === WebSocket.OPEN) {
|
|
143
|
+
this.ws.send(JSON.stringify({ type: "CloseStream" }));
|
|
144
|
+
}
|
|
145
|
+
this.ws.close();
|
|
146
|
+
this.ws = null;
|
|
147
|
+
}
|
|
148
|
+
this.connected = false;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private handleResponse(response: Record<string, unknown>): void {
|
|
152
|
+
// Deepgram response format
|
|
153
|
+
if (response.type === "Results") {
|
|
154
|
+
const channel = (response as any).channel;
|
|
155
|
+
if (!channel?.alternatives?.length) return;
|
|
156
|
+
|
|
157
|
+
const alt = channel.alternatives[0];
|
|
158
|
+
const result: STTResult = {
|
|
159
|
+
text: alt.transcript ?? "",
|
|
160
|
+
isFinal: (response as any).is_final ?? false,
|
|
161
|
+
confidence: alt.confidence ?? 0,
|
|
162
|
+
words: alt.words?.map((w: any) => ({
|
|
163
|
+
word: w.word,
|
|
164
|
+
startMs: Math.round(w.start * 1000),
|
|
165
|
+
endMs: Math.round(w.end * 1000),
|
|
166
|
+
})),
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// Only emit non-empty results
|
|
170
|
+
if (result.text.trim()) {
|
|
171
|
+
this.resultHandler?.(result);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function createDeepgramSTT(options: {
|
|
178
|
+
apiKey: string;
|
|
179
|
+
model?: string;
|
|
180
|
+
}): DeepgramSTT {
|
|
181
|
+
return new DeepgramSTT(options);
|
|
182
|
+
}
|
package/src/stt/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { DeepgramSTT, createDeepgramSTT } from "./deepgram.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ToolRegistry, defineTool, createToolRegistry } from "./registry.js";
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { AgentTool, ToolExecutor } from "../types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ToolRegistry — Manages tool registration and execution.
|
|
5
|
+
*
|
|
6
|
+
* Tools are external capabilities the agent can invoke during a call
|
|
7
|
+
* (e.g., booking appointments, looking up orders, transferring calls).
|
|
8
|
+
*/
|
|
9
|
+
export class ToolRegistry implements ToolExecutor {
|
|
10
|
+
private tools: Map<string, AgentTool> = new Map();
|
|
11
|
+
|
|
12
|
+
/** Register a tool */
|
|
13
|
+
register(tool: AgentTool): void {
|
|
14
|
+
if (this.tools.has(tool.name)) {
|
|
15
|
+
throw new Error(`Tool "${tool.name}" is already registered`);
|
|
16
|
+
}
|
|
17
|
+
this.tools.set(tool.name, tool);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Register multiple tools at once */
|
|
21
|
+
registerAll(tools: AgentTool[]): void {
|
|
22
|
+
for (const tool of tools) {
|
|
23
|
+
this.register(tool);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Call a registered tool by name */
|
|
28
|
+
async call(
|
|
29
|
+
toolName: string,
|
|
30
|
+
params?: Record<string, unknown>,
|
|
31
|
+
): Promise<unknown> {
|
|
32
|
+
const tool = this.tools.get(toolName);
|
|
33
|
+
if (!tool) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
`Tool "${toolName}" not found. Available: ${[...this.tools.keys()].join(", ")}`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const result = await tool.execute(params ?? {});
|
|
41
|
+
return result;
|
|
42
|
+
} catch (error) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`Tool "${toolName}" execution failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** List all registered tools */
|
|
50
|
+
list(): AgentTool[] {
|
|
51
|
+
return [...this.tools.values()];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Check if a tool is registered */
|
|
55
|
+
has(toolName: string): boolean {
|
|
56
|
+
return this.tools.has(toolName);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Get a tool's schema (for LLM function calling) */
|
|
60
|
+
getSchema(
|
|
61
|
+
toolName: string,
|
|
62
|
+
): { name: string; description: string; parameters: Record<string, unknown> } | undefined {
|
|
63
|
+
const tool = this.tools.get(toolName);
|
|
64
|
+
if (!tool) return undefined;
|
|
65
|
+
return {
|
|
66
|
+
name: tool.name,
|
|
67
|
+
description: tool.description,
|
|
68
|
+
parameters: tool.parameters,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Get all tool schemas (for LLM function calling) */
|
|
73
|
+
getAllSchemas(): Array<{
|
|
74
|
+
name: string;
|
|
75
|
+
description: string;
|
|
76
|
+
parameters: Record<string, unknown>;
|
|
77
|
+
}> {
|
|
78
|
+
return this.list().map((t) => ({
|
|
79
|
+
name: t.name,
|
|
80
|
+
description: t.description,
|
|
81
|
+
parameters: t.parameters,
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Helper to define a tool with type safety.
|
|
88
|
+
*/
|
|
89
|
+
export function defineTool(tool: AgentTool): AgentTool {
|
|
90
|
+
return tool;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function createToolRegistry(tools?: AgentTool[]): ToolRegistry {
|
|
94
|
+
const registry = new ToolRegistry();
|
|
95
|
+
if (tools) {
|
|
96
|
+
registry.registerAll(tools);
|
|
97
|
+
}
|
|
98
|
+
return registry;
|
|
99
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { WebSocketTransport, createWebSocketTransport } from "./websocket.js";
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
4
|
+
import type {
|
|
5
|
+
Transport,
|
|
6
|
+
TransportOptions,
|
|
7
|
+
AudioChunk,
|
|
8
|
+
Session,
|
|
9
|
+
DEFAULT_AUDIO_FORMAT,
|
|
10
|
+
} from "../types.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* WebSocketTransport — Handles real-time audio streaming over WebSocket.
|
|
14
|
+
*
|
|
15
|
+
* Protocol:
|
|
16
|
+
* - Client connects to ws://host:port
|
|
17
|
+
* - Binary frames = raw PCM audio data
|
|
18
|
+
* - JSON text frames = control messages (metadata, config, etc.)
|
|
19
|
+
*
|
|
20
|
+
* This is the default transport for Phase 1. WebRTC transport
|
|
21
|
+
* will be added in Phase 3 for lower-latency applications.
|
|
22
|
+
*/
|
|
23
|
+
export class WebSocketTransport extends EventEmitter implements Transport {
|
|
24
|
+
private wss: WebSocketServer | null = null;
|
|
25
|
+
private sessions: Map<string, { ws: WebSocket; session: Session }> =
|
|
26
|
+
new Map();
|
|
27
|
+
private audioHandler:
|
|
28
|
+
| ((sessionId: string, chunk: AudioChunk) => void)
|
|
29
|
+
| null = null;
|
|
30
|
+
private connectHandler: ((session: Session) => void) | null = null;
|
|
31
|
+
private disconnectHandler: ((session: Session) => void) | null = null;
|
|
32
|
+
|
|
33
|
+
async start(options: TransportOptions): Promise<void> {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
try {
|
|
36
|
+
this.wss = new WebSocketServer({
|
|
37
|
+
port: options.port,
|
|
38
|
+
host: options.host ?? "0.0.0.0",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
this.wss.on("connection", (ws, req) => {
|
|
42
|
+
this.handleConnection(ws, req);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
this.wss.on("listening", () => {
|
|
46
|
+
console.log(
|
|
47
|
+
`[Transport] WebSocket server listening on ${options.host ?? "0.0.0.0"}:${options.port}`,
|
|
48
|
+
);
|
|
49
|
+
resolve();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
this.wss.on("error", (error) => {
|
|
53
|
+
reject(error);
|
|
54
|
+
});
|
|
55
|
+
} catch (error) {
|
|
56
|
+
reject(error);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async stop(): Promise<void> {
|
|
62
|
+
// Close all active sessions
|
|
63
|
+
for (const [sessionId, { ws, session }] of this.sessions) {
|
|
64
|
+
session.state = "ended";
|
|
65
|
+
ws.close();
|
|
66
|
+
this.disconnectHandler?.(session);
|
|
67
|
+
}
|
|
68
|
+
this.sessions.clear();
|
|
69
|
+
|
|
70
|
+
// Shut down the server
|
|
71
|
+
return new Promise((resolve) => {
|
|
72
|
+
if (this.wss) {
|
|
73
|
+
this.wss.close(() => {
|
|
74
|
+
console.log("[Transport] WebSocket server stopped");
|
|
75
|
+
resolve();
|
|
76
|
+
});
|
|
77
|
+
} else {
|
|
78
|
+
resolve();
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
onAudioChunk(
|
|
84
|
+
handler: (sessionId: string, chunk: AudioChunk) => void,
|
|
85
|
+
): void {
|
|
86
|
+
this.audioHandler = handler;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
onConnect(handler: (session: Session) => void): void {
|
|
90
|
+
this.connectHandler = handler;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
onDisconnect(handler: (session: Session) => void): void {
|
|
94
|
+
this.disconnectHandler = handler;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async sendAudio(sessionId: string, chunk: AudioChunk): Promise<void> {
|
|
98
|
+
const entry = this.sessions.get(sessionId);
|
|
99
|
+
if (!entry) {
|
|
100
|
+
throw new Error(`Session "${sessionId}" not found`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (entry.ws.readyState === WebSocket.OPEN) {
|
|
104
|
+
entry.ws.send(chunk.data);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Send a JSON control message to a session.
|
|
110
|
+
*/
|
|
111
|
+
async sendMessage(
|
|
112
|
+
sessionId: string,
|
|
113
|
+
message: Record<string, unknown>,
|
|
114
|
+
): Promise<void> {
|
|
115
|
+
const entry = this.sessions.get(sessionId);
|
|
116
|
+
if (!entry) return;
|
|
117
|
+
|
|
118
|
+
if (entry.ws.readyState === WebSocket.OPEN) {
|
|
119
|
+
entry.ws.send(JSON.stringify(message));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Get active session count */
|
|
124
|
+
get activeSessionCount(): number {
|
|
125
|
+
return this.sessions.size;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Get a session by ID */
|
|
129
|
+
getSession(sessionId: string): Session | undefined {
|
|
130
|
+
return this.sessions.get(sessionId)?.session;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private handleConnection(
|
|
134
|
+
ws: WebSocket,
|
|
135
|
+
req: import("http").IncomingMessage,
|
|
136
|
+
): void {
|
|
137
|
+
const sessionId = randomUUID();
|
|
138
|
+
const session: Session = {
|
|
139
|
+
id: sessionId,
|
|
140
|
+
startedAt: new Date(),
|
|
141
|
+
metadata: {
|
|
142
|
+
remoteAddress: req.socket.remoteAddress,
|
|
143
|
+
headers: req.headers,
|
|
144
|
+
},
|
|
145
|
+
state: "active",
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
this.sessions.set(sessionId, { ws, session });
|
|
149
|
+
console.log(`[Transport] New session: ${sessionId}`);
|
|
150
|
+
|
|
151
|
+
// Notify connection handler
|
|
152
|
+
this.connectHandler?.(session);
|
|
153
|
+
|
|
154
|
+
// Send session info to the client
|
|
155
|
+
ws.send(
|
|
156
|
+
JSON.stringify({
|
|
157
|
+
type: "session.created",
|
|
158
|
+
sessionId,
|
|
159
|
+
timestamp: Date.now(),
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const sessionStartTime = Date.now();
|
|
164
|
+
|
|
165
|
+
ws.on("message", (data, isBinary) => {
|
|
166
|
+
if (isBinary) {
|
|
167
|
+
// Binary = audio data
|
|
168
|
+
const chunk: AudioChunk = {
|
|
169
|
+
data: Buffer.from(data as Buffer),
|
|
170
|
+
sampleRate: 16000,
|
|
171
|
+
channels: 1,
|
|
172
|
+
bitDepth: 16,
|
|
173
|
+
timestampMs: Date.now() - sessionStartTime,
|
|
174
|
+
};
|
|
175
|
+
this.audioHandler?.(sessionId, chunk);
|
|
176
|
+
} else {
|
|
177
|
+
// Text = control message
|
|
178
|
+
try {
|
|
179
|
+
const message = JSON.parse(data.toString());
|
|
180
|
+
this.handleControlMessage(sessionId, message);
|
|
181
|
+
} catch {
|
|
182
|
+
console.warn(`[Transport] Invalid control message from ${sessionId}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
ws.on("close", () => {
|
|
188
|
+
console.log(`[Transport] Session ended: ${sessionId}`);
|
|
189
|
+
session.state = "ended";
|
|
190
|
+
this.disconnectHandler?.(session);
|
|
191
|
+
this.sessions.delete(sessionId);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
ws.on("error", (error) => {
|
|
195
|
+
console.error(`[Transport] Session error (${sessionId}):`, error.message);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private handleControlMessage(
|
|
200
|
+
sessionId: string,
|
|
201
|
+
message: Record<string, unknown>,
|
|
202
|
+
): void {
|
|
203
|
+
const entry = this.sessions.get(sessionId);
|
|
204
|
+
if (!entry) return;
|
|
205
|
+
|
|
206
|
+
switch (message.type) {
|
|
207
|
+
case "session.update":
|
|
208
|
+
// Client can update session metadata
|
|
209
|
+
if (message.metadata && typeof message.metadata === "object") {
|
|
210
|
+
Object.assign(entry.session.metadata, message.metadata);
|
|
211
|
+
}
|
|
212
|
+
break;
|
|
213
|
+
|
|
214
|
+
case "audio.config":
|
|
215
|
+
// Client specifying audio format (future: handle format conversion)
|
|
216
|
+
this.emit("audioConfig", sessionId, message);
|
|
217
|
+
break;
|
|
218
|
+
|
|
219
|
+
default:
|
|
220
|
+
this.emit("controlMessage", sessionId, message);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function createWebSocketTransport(): WebSocketTransport {
|
|
226
|
+
return new WebSocketTransport();
|
|
227
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { TTSProvider, TTSOptions, AudioChunk } from "../types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ElevenLabsTTS — Streaming Text-to-Speech using ElevenLabs' API.
|
|
5
|
+
*
|
|
6
|
+
* Uses the streaming endpoint for chunk-by-chunk audio delivery,
|
|
7
|
+
* so the agent can start speaking before the full response is generated.
|
|
8
|
+
*/
|
|
9
|
+
export class ElevenLabsTTS implements TTSProvider {
|
|
10
|
+
readonly name = "elevenlabs";
|
|
11
|
+
private readonly apiKey: string;
|
|
12
|
+
private readonly defaultVoice: string;
|
|
13
|
+
private readonly baseUrl: string;
|
|
14
|
+
private readonly modelId: string;
|
|
15
|
+
|
|
16
|
+
constructor(options: {
|
|
17
|
+
apiKey: string;
|
|
18
|
+
voice?: string;
|
|
19
|
+
modelId?: string;
|
|
20
|
+
baseUrl?: string;
|
|
21
|
+
}) {
|
|
22
|
+
this.apiKey = options.apiKey;
|
|
23
|
+
this.defaultVoice = options.voice ?? "21m00Tcm4TlvDq8ikWAM"; // Rachel
|
|
24
|
+
this.modelId = options.modelId ?? "eleven_turbo_v2_5";
|
|
25
|
+
this.baseUrl = options.baseUrl ?? "https://api.elevenlabs.io/v1";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async *synthesize(
|
|
29
|
+
text: string,
|
|
30
|
+
options?: TTSOptions,
|
|
31
|
+
): AsyncIterable<AudioChunk> {
|
|
32
|
+
const voiceId = options?.voice ?? this.defaultVoice;
|
|
33
|
+
|
|
34
|
+
const response = await fetch(
|
|
35
|
+
`${this.baseUrl}/text-to-speech/${voiceId}/stream`,
|
|
36
|
+
{
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: {
|
|
39
|
+
"Content-Type": "application/json",
|
|
40
|
+
"xi-api-key": this.apiKey,
|
|
41
|
+
},
|
|
42
|
+
body: JSON.stringify({
|
|
43
|
+
text,
|
|
44
|
+
model_id: this.modelId,
|
|
45
|
+
voice_settings: {
|
|
46
|
+
stability: 0.5,
|
|
47
|
+
similarity_boost: 0.75,
|
|
48
|
+
speed: options?.speed ?? 1.0,
|
|
49
|
+
},
|
|
50
|
+
output_format: "pcm_16000",
|
|
51
|
+
}),
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
if (!response.ok) {
|
|
56
|
+
const error = await response.text();
|
|
57
|
+
throw new Error(
|
|
58
|
+
`ElevenLabs TTS failed: ${response.status} ${error}`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!response.body) {
|
|
63
|
+
throw new Error("ElevenLabs TTS returned no body");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const reader = response.body.getReader();
|
|
67
|
+
let timestampMs = 0;
|
|
68
|
+
const bytesPerSecond = 16000 * 2; // 16kHz * 16-bit = 32000 bytes/sec
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
while (true) {
|
|
72
|
+
const { done, value } = await reader.read();
|
|
73
|
+
if (done) break;
|
|
74
|
+
|
|
75
|
+
const chunk: AudioChunk = {
|
|
76
|
+
data: Buffer.from(value),
|
|
77
|
+
sampleRate: 16000,
|
|
78
|
+
channels: 1,
|
|
79
|
+
bitDepth: 16,
|
|
80
|
+
timestampMs,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Calculate timestamp based on audio duration
|
|
84
|
+
timestampMs += (value.length / bytesPerSecond) * 1000;
|
|
85
|
+
|
|
86
|
+
yield chunk;
|
|
87
|
+
}
|
|
88
|
+
} finally {
|
|
89
|
+
reader.releaseLock();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function createElevenLabsTTS(options: {
|
|
95
|
+
apiKey: string;
|
|
96
|
+
voice?: string;
|
|
97
|
+
modelId?: string;
|
|
98
|
+
}): ElevenLabsTTS {
|
|
99
|
+
return new ElevenLabsTTS(options);
|
|
100
|
+
}
|
package/src/tts/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ElevenLabsTTS, createElevenLabsTTS } from "./eleven-labs.js";
|