@stage-labs/metro 0.1.0-beta.236 → 0.1.0-beta.237
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/claude.js +1 -1
- package/dist/cli.js +7 -0
- package/dist/voice.js +39 -0
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/package.json +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +5 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +8 -4
- package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/voice/api.ts +14 -0
- package/runtime/node_modules/@metro-labs/daemon/src/voice/audio.ts +153 -0
- package/runtime/node_modules/@metro-labs/daemon/src/voice/brain.ts +95 -0
- package/runtime/node_modules/@metro-labs/daemon/src/voice/call.ts +154 -0
- package/runtime/node_modules/@metro-labs/daemon/src/voice/calls.ts +54 -0
- package/runtime/node_modules/@metro-labs/daemon/src/voice/peer.ts +84 -0
- package/runtime/node_modules/@metro-labs/daemon/src/voice/scribe.ts +102 -0
- package/runtime/node_modules/@metro-labs/daemon/src/voice/speakable.ts +54 -0
- package/runtime/node_modules/@metro-labs/daemon/src/voice/speech.ts +91 -0
- package/runtime/node_modules/@metro-labs/daemon/src/voice/store.ts +97 -0
- package/runtime/node_modules/@metro-labs/daemon/src/voice/talk.ts +234 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/actions-conv.ts +2 -1
- package/runtime/node_modules/@metro-labs/xmtp/src/actions.ts +11 -1
- package/runtime/node_modules/@metro-labs/xmtp/src/codecs.ts +21 -3
- package/runtime/node_modules/@metro-labs/xmtp/src/emit-payloads.ts +2 -1
- package/runtime/runtime.json +1 -1
- package/runtime/stations.json +2 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { RTCPeerConnection, RtpHeader, RtpPacket, type RTCDataChannel, type RTCRtpSender } from 'werift';
|
|
2
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
|
+
|
|
4
|
+
const ICE_SERVERS = [{ urls: 'stun:stun.cloudflare.com:3478' }, { urls: 'stun:stun.l.google.com:19302' }];
|
|
5
|
+
const UDP_PORTS: [number, number] = [40_000, 40_100];
|
|
6
|
+
const GATHER_MS = 2_500;
|
|
7
|
+
const OPUS_PT = 111;
|
|
8
|
+
const MEDIA = JSON.stringify({ audio: true, video: false, screen: false });
|
|
9
|
+
|
|
10
|
+
interface PeerEvents {
|
|
11
|
+
audio(opus: Buffer): void;
|
|
12
|
+
state(state: string): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function gathered(pc: RTCPeerConnection): Promise<void> {
|
|
16
|
+
if (pc.iceGatheringState === 'complete') return Promise.resolve();
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
const timer = setTimeout(resolve, GATHER_MS);
|
|
19
|
+
pc.iceGatheringStateChange.subscribe((state) => {
|
|
20
|
+
if (state !== 'complete') return;
|
|
21
|
+
clearTimeout(timer);
|
|
22
|
+
resolve();
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class Peer {
|
|
28
|
+
private readonly pc = new RTCPeerConnection({
|
|
29
|
+
iceServers: ICE_SERVERS,
|
|
30
|
+
bundlePolicy: 'max-bundle',
|
|
31
|
+
icePortRange: UDP_PORTS,
|
|
32
|
+
iceUseIpv6: false,
|
|
33
|
+
});
|
|
34
|
+
private readonly channel: RTCDataChannel;
|
|
35
|
+
private sender: RTCRtpSender | null = null;
|
|
36
|
+
private sequence = Math.floor(Math.random() * 0xffff);
|
|
37
|
+
|
|
38
|
+
constructor(private readonly events: PeerEvents) {
|
|
39
|
+
this.channel = this.pc.createDataChannel('media', { negotiated: true, id: 0 });
|
|
40
|
+
this.channel.stateChanged.subscribe((state) => {
|
|
41
|
+
if (state === 'open') this.channel.send(MEDIA);
|
|
42
|
+
});
|
|
43
|
+
this.pc.onTrack.subscribe((track) => {
|
|
44
|
+
if (track.kind === 'audio')
|
|
45
|
+
track.onReceiveRtp.subscribe((rtp) => {
|
|
46
|
+
this.events.audio(rtp.payload);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
this.pc.connectionStateChange.subscribe((state) => {
|
|
50
|
+
this.events.state(state);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async answer(offer: string): Promise<string> {
|
|
55
|
+
await this.pc.setRemoteDescription({ type: 'offer', sdp: offer });
|
|
56
|
+
for (const transceiver of this.pc.getTransceivers()) {
|
|
57
|
+
if (transceiver.kind === 'audio') {
|
|
58
|
+
transceiver.setDirection('sendrecv');
|
|
59
|
+
this.sender = transceiver.sender;
|
|
60
|
+
} else transceiver.setDirection('inactive');
|
|
61
|
+
}
|
|
62
|
+
await this.pc.setLocalDescription(await this.pc.createAnswer());
|
|
63
|
+
await gathered(this.pc);
|
|
64
|
+
const sdp = this.pc.localDescription?.sdp;
|
|
65
|
+
if (sdp === undefined) throw new Error('no local description after the answer');
|
|
66
|
+
return sdp;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
sendOpus(payload: Buffer, timestamp: number, marker: boolean): void {
|
|
70
|
+
const sender = this.sender;
|
|
71
|
+
if (sender === null) return;
|
|
72
|
+
this.sequence = (this.sequence + 1) & 0xffff;
|
|
73
|
+
const header = new RtpHeader({ payloadType: OPUS_PT, sequenceNumber: this.sequence, timestamp, marker });
|
|
74
|
+
sender.sendRtp(new RtpPacket(header, payload)).catch((err: unknown) => {
|
|
75
|
+
log.debug({ err: errMsg(err) }, 'voice: an audio packet was not sent');
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
close(): void {
|
|
80
|
+
this.pc.close().catch((err: unknown) => {
|
|
81
|
+
log.debug({ err: errMsg(err) }, 'voice: closing the call connection failed');
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
4
|
+
import { bytesOf } from './audio.js';
|
|
5
|
+
|
|
6
|
+
export const elevenLabs = (): string => process.env.METRO_ELEVENLABS_URL ?? 'wss://api.elevenlabs.io';
|
|
7
|
+
const STT_RATE = 16_000;
|
|
8
|
+
const CHUNK_SAMPLES = STT_RATE / 10;
|
|
9
|
+
const HELD_SAMPLES = STT_RATE * 2;
|
|
10
|
+
export const VAD_SILENCE_SECS = 0.5;
|
|
11
|
+
|
|
12
|
+
interface Heard {
|
|
13
|
+
partial(text: string): void;
|
|
14
|
+
committed(text: string): void;
|
|
15
|
+
failed(reason: string): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const FATAL = new Set([
|
|
19
|
+
'auth_error',
|
|
20
|
+
'quota_exceeded',
|
|
21
|
+
'unaccepted_terms',
|
|
22
|
+
'rate_limited',
|
|
23
|
+
'resource_exhausted',
|
|
24
|
+
'session_time_limit_exceeded',
|
|
25
|
+
'error',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
export function wsText(data: WebSocket.RawData): string {
|
|
29
|
+
if (Array.isArray(data)) return Buffer.concat(data).toString('utf8');
|
|
30
|
+
return (Buffer.isBuffer(data) ? data : Buffer.from(data)).toString('utf8');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function parsed(raw: string): Record<string, unknown> {
|
|
34
|
+
try {
|
|
35
|
+
const msg: unknown = JSON.parse(raw);
|
|
36
|
+
return isRecord(msg) ? msg : {};
|
|
37
|
+
} catch {
|
|
38
|
+
return {};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function sttUrl(): string {
|
|
43
|
+
const query = new URLSearchParams({
|
|
44
|
+
model_id: 'scribe_v2_realtime',
|
|
45
|
+
audio_format: 'pcm_16000',
|
|
46
|
+
commit_strategy: 'vad',
|
|
47
|
+
vad_silence_threshold_secs: String(VAD_SILENCE_SECS),
|
|
48
|
+
});
|
|
49
|
+
return `${elevenLabs()}/v1/speech-to-text/realtime?${query.toString()}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class Scribe {
|
|
53
|
+
private readonly ws: WebSocket;
|
|
54
|
+
private buffer: Int16Array[] = [];
|
|
55
|
+
private buffered = 0;
|
|
56
|
+
|
|
57
|
+
constructor(apiKey: string, private readonly heard: Heard) {
|
|
58
|
+
this.ws = new WebSocket(sttUrl(), { headers: { 'xi-api-key': apiKey } });
|
|
59
|
+
this.ws.on('message', (data) => {
|
|
60
|
+
this.onMessage(wsText(data));
|
|
61
|
+
});
|
|
62
|
+
this.ws.on('error', (err) => {
|
|
63
|
+
log.warn({ err: errMsg(err) }, 'voice: speech to text connection error');
|
|
64
|
+
});
|
|
65
|
+
this.ws.on('close', (code) => {
|
|
66
|
+
if (code !== 1000) this.heard.failed(`speech to text closed (${String(code)})`);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
send(pcm16k: Int16Array): void {
|
|
71
|
+
this.buffer.push(pcm16k);
|
|
72
|
+
this.buffered += pcm16k.length;
|
|
73
|
+
while (this.buffered > HELD_SAMPLES) this.buffered -= this.buffer.shift()?.length ?? this.buffered;
|
|
74
|
+
if (this.buffered < CHUNK_SAMPLES || this.ws.readyState !== WebSocket.OPEN) return;
|
|
75
|
+
const chunk = new Int16Array(this.buffered);
|
|
76
|
+
let at = 0;
|
|
77
|
+
for (const part of this.buffer) {
|
|
78
|
+
chunk.set(part, at);
|
|
79
|
+
at += part.length;
|
|
80
|
+
}
|
|
81
|
+
this.buffer = [];
|
|
82
|
+
this.buffered = 0;
|
|
83
|
+
this.ws.send(
|
|
84
|
+
JSON.stringify({ message_type: 'input_audio_chunk', audio_base_64: bytesOf(chunk).toString('base64'), commit: false, sample_rate: STT_RATE }),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
close(): void {
|
|
89
|
+
this.ws.close(1000);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private onMessage(raw: string): void {
|
|
93
|
+
const msg = parsed(raw);
|
|
94
|
+
const type = typeof msg.message_type === 'string' ? msg.message_type : '';
|
|
95
|
+
const text = typeof msg.text === 'string' ? msg.text.trim() : '';
|
|
96
|
+
if (type === 'partial_transcript') this.heard.partial(text);
|
|
97
|
+
else if (type === 'committed_transcript') {
|
|
98
|
+
if (text !== '') this.heard.committed(text);
|
|
99
|
+
} else if (FATAL.has(type)) this.heard.failed(`speech to text: ${typeof msg.error === 'string' ? msg.error : type}`);
|
|
100
|
+
else log.debug({ type }, 'voice: speech to text said');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const FIRST_CLAUSE_MIN = 24;
|
|
2
|
+
const LONGEST = 220;
|
|
3
|
+
const SENTENCE_END = /[.!?…]+["')\]]*\s+|\n+/g;
|
|
4
|
+
const CLAUSE_END = /[,;:]\s+/g;
|
|
5
|
+
|
|
6
|
+
function speakable(text: string): string {
|
|
7
|
+
return text
|
|
8
|
+
.replace(/https?:\/\/\S+/g, 'the link')
|
|
9
|
+
.replace(/`{1,3}[^`]*`{1,3}/g, (code) => code.replace(/`/g, ''))
|
|
10
|
+
.replace(/^\s*(?:#+|[-*•]|\d+\.)\s+/gm, '')
|
|
11
|
+
.replace(/[*_~#|<>[\]{}]/g, '')
|
|
12
|
+
.replace(/\s+/g, ' ')
|
|
13
|
+
.trim();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function lastEnd(text: string, pattern: RegExp): number {
|
|
17
|
+
let end = -1;
|
|
18
|
+
for (const match of text.matchAll(pattern)) end = match.index + match[0].length;
|
|
19
|
+
return end;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function cutAt(text: string, first: boolean): number {
|
|
23
|
+
const sentence = lastEnd(text, SENTENCE_END);
|
|
24
|
+
if (sentence > 0) return sentence;
|
|
25
|
+
if (first && text.length >= FIRST_CLAUSE_MIN) {
|
|
26
|
+
const clause = lastEnd(text, CLAUSE_END);
|
|
27
|
+
if (clause > 0) return clause;
|
|
28
|
+
}
|
|
29
|
+
if (text.length < LONGEST) return -1;
|
|
30
|
+
const space = text.lastIndexOf(' ');
|
|
31
|
+
return space > 0 ? space + 1 : text.length;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class Chunker {
|
|
35
|
+
private buffer = '';
|
|
36
|
+
private first = true;
|
|
37
|
+
|
|
38
|
+
add(delta: string): string[] {
|
|
39
|
+
this.buffer += delta;
|
|
40
|
+
const at = cutAt(this.buffer, this.first);
|
|
41
|
+
if (at < 0) return [];
|
|
42
|
+
const ready = speakable(this.buffer.slice(0, at));
|
|
43
|
+
this.buffer = this.buffer.slice(at);
|
|
44
|
+
if (ready === '') return [];
|
|
45
|
+
this.first = false;
|
|
46
|
+
return [ready];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
flush(): string[] {
|
|
50
|
+
const rest = speakable(this.buffer);
|
|
51
|
+
this.buffer = '';
|
|
52
|
+
return rest === '' ? [] : [rest];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
4
|
+
import { pcmOf, upsample2 } from './audio.js';
|
|
5
|
+
import { elevenLabs, parsed, wsText } from './scribe.js';
|
|
6
|
+
|
|
7
|
+
const TTS_MODEL = 'eleven_flash_v2_5';
|
|
8
|
+
|
|
9
|
+
interface Voiced {
|
|
10
|
+
audio(pcm48k: Int16Array, chars: number): void;
|
|
11
|
+
done(): void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function ttsUrl(voiceId: string): string {
|
|
15
|
+
const query = new URLSearchParams({
|
|
16
|
+
model_id: TTS_MODEL,
|
|
17
|
+
output_format: 'pcm_24000',
|
|
18
|
+
auto_mode: 'true',
|
|
19
|
+
inactivity_timeout: '180',
|
|
20
|
+
});
|
|
21
|
+
return `${elevenLabs()}/v1/text-to-speech/${encodeURIComponent(voiceId)}/stream-input?${query.toString()}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const charsOf = (msg: Record<string, unknown>): number => {
|
|
25
|
+
const alignment = msg.alignment;
|
|
26
|
+
return isRecord(alignment) && Array.isArray(alignment.chars) ? alignment.chars.length : 0;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export class Utterance {
|
|
30
|
+
private readonly ws: WebSocket;
|
|
31
|
+
private readonly outbox: string[] = [];
|
|
32
|
+
private listener: Voiced | null = null;
|
|
33
|
+
private carry = 0;
|
|
34
|
+
private closed = false;
|
|
35
|
+
|
|
36
|
+
constructor(apiKey: string, voiceId: string) {
|
|
37
|
+
this.ws = new WebSocket(ttsUrl(voiceId), { headers: { 'xi-api-key': apiKey } });
|
|
38
|
+
this.ws.on('open', () => {
|
|
39
|
+
this.ws.send(JSON.stringify({ text: ' ' }));
|
|
40
|
+
for (const line of this.outbox.splice(0)) this.ws.send(line);
|
|
41
|
+
});
|
|
42
|
+
this.ws.on('message', (data) => {
|
|
43
|
+
this.onMessage(wsText(data));
|
|
44
|
+
});
|
|
45
|
+
this.ws.on('error', (err) => {
|
|
46
|
+
log.warn({ err: errMsg(err) }, 'voice: text to speech connection error');
|
|
47
|
+
});
|
|
48
|
+
this.ws.on('close', () => {
|
|
49
|
+
this.closed = true;
|
|
50
|
+
this.listener?.done();
|
|
51
|
+
this.listener = null;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get usable(): boolean {
|
|
56
|
+
return !this.closed && this.listener === null && this.ws.readyState <= WebSocket.OPEN;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
attach(listener: Voiced): void {
|
|
60
|
+
this.listener = listener;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
say(text: string): void {
|
|
64
|
+
this.post({ text: `${text} ` });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
end(): void {
|
|
68
|
+
this.post({ text: '' });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
abort(): void {
|
|
72
|
+
this.listener = null;
|
|
73
|
+
this.ws.terminate();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private post(msg: Record<string, unknown>): void {
|
|
77
|
+
const line = JSON.stringify(msg);
|
|
78
|
+
if (this.ws.readyState === WebSocket.OPEN) this.ws.send(line);
|
|
79
|
+
else this.outbox.push(line);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private onMessage(raw: string): void {
|
|
83
|
+
const msg = parsed(raw);
|
|
84
|
+
if (typeof msg.audio === 'string' && msg.audio !== '') {
|
|
85
|
+
const pcm = pcmOf(Buffer.from(msg.audio, 'base64'));
|
|
86
|
+
const up = upsample2(pcm, this.carry);
|
|
87
|
+
this.carry = pcm.at(-1) ?? this.carry;
|
|
88
|
+
this.listener?.audio(up, charsOf(msg));
|
|
89
|
+
} else if (msg.isFinal !== true) log.warn({ msg: raw.slice(0, 300) }, 'voice: text to speech said');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { readJson, writeSecure } from '@metro-labs/core/secure-fs';
|
|
3
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
4
|
+
import { ApiError } from '@metro-labs/http/api-error';
|
|
5
|
+
import { agentsDir } from '../agents/files.js';
|
|
6
|
+
import { readModelConfig, type ModelConfig } from '../gateway/model-config.js';
|
|
7
|
+
|
|
8
|
+
const VOICE_PROVIDERS = ['elevenlabs'] as const;
|
|
9
|
+
type VoiceProvider = (typeof VOICE_PROVIDERS)[number];
|
|
10
|
+
|
|
11
|
+
export interface VoiceConfig {
|
|
12
|
+
provider: VoiceProvider;
|
|
13
|
+
apiKey: string;
|
|
14
|
+
voiceId: string;
|
|
15
|
+
model: string;
|
|
16
|
+
enabled: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const DEFAULT_VOICE_ID = 'EXAVITQu4vr4xnSDxMaL';
|
|
20
|
+
const DEFAULT_VOICE_MODEL = 'anthropic:claude-sonnet-5-5';
|
|
21
|
+
|
|
22
|
+
const VOICE_FILE = 'voice.json';
|
|
23
|
+
const MAX_FIELD = 512;
|
|
24
|
+
|
|
25
|
+
const text = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
|
|
26
|
+
|
|
27
|
+
const isProvider = (value: unknown): value is VoiceProvider =>
|
|
28
|
+
typeof value === 'string' && (VOICE_PROVIDERS as readonly string[]).includes(value);
|
|
29
|
+
|
|
30
|
+
export function parseVoice(raw: unknown): VoiceConfig {
|
|
31
|
+
const r = isRecord(raw) ? raw : {};
|
|
32
|
+
return {
|
|
33
|
+
provider: isProvider(r.provider) ? r.provider : 'elevenlabs',
|
|
34
|
+
apiKey: text(r.apiKey),
|
|
35
|
+
voiceId: text(r.voiceId),
|
|
36
|
+
model: text(r.model),
|
|
37
|
+
enabled: r.enabled !== false,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const readVoice = (dir = agentsDir()): VoiceConfig =>
|
|
42
|
+
parseVoice(readJson<unknown>(join(dir, VOICE_FILE), null, { warn: 'voice: voice.json is not readable' }));
|
|
43
|
+
|
|
44
|
+
export function writeVoice(cfg: VoiceConfig, dir = agentsDir()): void {
|
|
45
|
+
writeSecure(join(dir, VOICE_FILE), JSON.stringify(cfg, null, 2));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const voiceIdOf = (cfg: VoiceConfig): string => (cfg.voiceId === '' ? DEFAULT_VOICE_ID : cfg.voiceId);
|
|
49
|
+
const voiceModelOf = (cfg: VoiceConfig): string => (cfg.model === '' ? DEFAULT_VOICE_MODEL : cfg.model);
|
|
50
|
+
export function brainModel(cfg: VoiceConfig, models: ModelConfig = readModelConfig()): string {
|
|
51
|
+
const model = voiceModelOf(cfg);
|
|
52
|
+
const anthropic = /^anthropic:(.+)$/.exec(model)?.[1];
|
|
53
|
+
if (anthropic === undefined) return model;
|
|
54
|
+
return models.connections.some((c) => c.provider === 'anthropic') ? model : anthropic;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const voiceReady = (cfg: VoiceConfig): boolean => cfg.enabled && cfg.apiKey !== '';
|
|
58
|
+
|
|
59
|
+
export function publicVoice(cfg: VoiceConfig): Record<string, unknown> {
|
|
60
|
+
return {
|
|
61
|
+
provider: cfg.provider,
|
|
62
|
+
providers: VOICE_PROVIDERS,
|
|
63
|
+
hasKey: cfg.apiKey !== '',
|
|
64
|
+
voiceId: cfg.voiceId,
|
|
65
|
+
model: cfg.model,
|
|
66
|
+
enabled: cfg.enabled,
|
|
67
|
+
defaults: { voiceId: DEFAULT_VOICE_ID, model: DEFAULT_VOICE_MODEL },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function field(body: Record<string, unknown>, key: string, current: string): string {
|
|
72
|
+
const value = body[key];
|
|
73
|
+
if (value === undefined) return current;
|
|
74
|
+
if (typeof value !== 'string') throw new ApiError(`${key} must be a string`, 400);
|
|
75
|
+
if (value.length > MAX_FIELD) throw new ApiError(`${key} is too long`, 400);
|
|
76
|
+
return value.trim();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function keyOf(body: Record<string, unknown>, current: string): string {
|
|
80
|
+
if (body.apiKey === null) return '';
|
|
81
|
+
const next = field(body, 'apiKey', current);
|
|
82
|
+
return next === '' ? current : next;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function patchVoice(cfg: VoiceConfig, body: unknown): VoiceConfig {
|
|
86
|
+
if (!isRecord(body)) throw new ApiError('the body must be a JSON object', 400);
|
|
87
|
+
if (body.provider !== undefined && !isProvider(body.provider))
|
|
88
|
+
throw new ApiError(`provider must be one of ${VOICE_PROVIDERS.join(', ')}`, 400);
|
|
89
|
+
if (body.enabled !== undefined && typeof body.enabled !== 'boolean') throw new ApiError('enabled must be true or false', 400);
|
|
90
|
+
return {
|
|
91
|
+
provider: isProvider(body.provider) ? body.provider : cfg.provider,
|
|
92
|
+
apiKey: keyOf(body, cfg.apiKey),
|
|
93
|
+
voiceId: field(body, 'voiceId', cfg.voiceId),
|
|
94
|
+
model: field(body, 'model', cfg.model),
|
|
95
|
+
enabled: typeof body.enabled === 'boolean' ? body.enabled : cfg.enabled,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { log } from '@metro-labs/core/log';
|
|
2
|
+
import { downsample3, OpusIn, Player, type Playback } from './audio.js';
|
|
3
|
+
import { Brain, type Thinking } from './brain.js';
|
|
4
|
+
import { Scribe, VAD_SILENCE_SECS } from './scribe.js';
|
|
5
|
+
import { Utterance } from './speech.js';
|
|
6
|
+
import { Chunker } from './speakable.js';
|
|
7
|
+
import { brainModel, voiceIdOf, type VoiceConfig } from './store.js';
|
|
8
|
+
|
|
9
|
+
const BARGE_MIN_CHARS = 4;
|
|
10
|
+
const NOTES_WAIT_MS = 60_000;
|
|
11
|
+
const NOTES_PROMPT =
|
|
12
|
+
'[The call has ended. Write short call notes for the chat: what was discussed, what was decided and what you will follow up on. Plain text, at most 6 short lines, no title and no greeting. Reply with the notes only.]';
|
|
13
|
+
|
|
14
|
+
interface Reply {
|
|
15
|
+
utterance: Utterance;
|
|
16
|
+
said: string;
|
|
17
|
+
heard: number;
|
|
18
|
+
chunker: Chunker;
|
|
19
|
+
askedAt: number | null;
|
|
20
|
+
firstText: number | null;
|
|
21
|
+
firstAudio: number | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const since = (start: number | null, at: number | null): number | null =>
|
|
25
|
+
start === null || at === null ? null : Math.round(at - start);
|
|
26
|
+
|
|
27
|
+
function heardNote(reply: Reply | null): string | null {
|
|
28
|
+
if (reply === null || reply.said.trim() === '' || reply.heard >= reply.said.trim().length - 2) return null;
|
|
29
|
+
const part = reply.said.slice(0, reply.heard).trim();
|
|
30
|
+
return part === ''
|
|
31
|
+
? '[The caller interrupted you before hearing your reply.]'
|
|
32
|
+
: `[The caller interrupted you. They heard only: "${part}". The rest of your reply was not heard.]`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class Talk implements Thinking {
|
|
36
|
+
private readonly brain: Brain;
|
|
37
|
+
private readonly scribe: Scribe;
|
|
38
|
+
private readonly opus = new OpusIn();
|
|
39
|
+
private readonly player: Player;
|
|
40
|
+
private spare: Utterance | null = null;
|
|
41
|
+
private reply: Reply | null = null;
|
|
42
|
+
private cut: string | null = null;
|
|
43
|
+
private muted = false;
|
|
44
|
+
private busy = false;
|
|
45
|
+
private askedAt: number | null = null;
|
|
46
|
+
private notes: ((text: string) => void) | null = null;
|
|
47
|
+
private notesStarted = false;
|
|
48
|
+
private live = false;
|
|
49
|
+
private finished = false;
|
|
50
|
+
private heardCaller = false;
|
|
51
|
+
|
|
52
|
+
constructor(
|
|
53
|
+
private readonly cfg: VoiceConfig,
|
|
54
|
+
out: (opus: Buffer, timestamp: number, marker: boolean) => void,
|
|
55
|
+
private readonly ended: (reason: string) => void,
|
|
56
|
+
) {
|
|
57
|
+
const playback: Playback = {
|
|
58
|
+
send: out,
|
|
59
|
+
played: (chars) => {
|
|
60
|
+
this.played(chars);
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
this.player = new Player(playback);
|
|
64
|
+
this.player.whenFirstPlayed(() => {
|
|
65
|
+
this.firstPlayed();
|
|
66
|
+
});
|
|
67
|
+
this.brain = new Brain(brainModel(cfg), this);
|
|
68
|
+
this.scribe = new Scribe(cfg.apiKey, {
|
|
69
|
+
partial: (text) => {
|
|
70
|
+
this.partial(text);
|
|
71
|
+
},
|
|
72
|
+
committed: (text) => {
|
|
73
|
+
this.committed(text);
|
|
74
|
+
},
|
|
75
|
+
failed: (reason) => {
|
|
76
|
+
this.ended(reason);
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
this.spare = new Utterance(cfg.apiKey, voiceIdOf(cfg));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
prime(context: string): void {
|
|
83
|
+
this.busy = true;
|
|
84
|
+
this.brain.tell(context);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
connect(): void {
|
|
88
|
+
this.live = true;
|
|
89
|
+
this.spare ??= new Utterance(this.cfg.apiKey, voiceIdOf(this.cfg));
|
|
90
|
+
this.player.start();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
hear(packet: Buffer): void {
|
|
94
|
+
if (this.finished) return;
|
|
95
|
+
const pcm = this.opus.decode(packet);
|
|
96
|
+
if (pcm !== null) this.scribe.send(downsample3(pcm));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async finish(): Promise<string | null> {
|
|
100
|
+
if (this.finished) return null;
|
|
101
|
+
this.finished = true;
|
|
102
|
+
this.live = false;
|
|
103
|
+
this.player.stop();
|
|
104
|
+
this.scribe.close();
|
|
105
|
+
this.reply?.utterance.abort();
|
|
106
|
+
this.spare?.abort();
|
|
107
|
+
this.opus.close();
|
|
108
|
+
const notes = this.heardCaller && this.brain.alive ? await this.callNotes() : null;
|
|
109
|
+
this.brain.close();
|
|
110
|
+
return notes;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
turnStarted(): void {
|
|
114
|
+
this.muted = false;
|
|
115
|
+
this.busy = true;
|
|
116
|
+
if (this.notes !== null) this.notesStarted = true;
|
|
117
|
+
if (this.notes !== null || this.finished) return;
|
|
118
|
+
this.reply = { utterance: this.takeUtterance(), said: '', heard: 0, chunker: new Chunker(), askedAt: this.askedAt, firstText: null, firstAudio: null };
|
|
119
|
+
this.askedAt = null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
text(delta: string): void {
|
|
123
|
+
const reply = this.reply;
|
|
124
|
+
if (this.muted || reply === null || this.notes !== null) return;
|
|
125
|
+
reply.firstText ??= performance.now();
|
|
126
|
+
this.speak(reply, reply.chunker.add(delta));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
tool(name: string): void {
|
|
130
|
+
log.info({ tool: name }, 'voice: the agent uses a tool during the call');
|
|
131
|
+
if (this.reply !== null && !this.muted) this.speak(this.reply, this.reply.chunker.flush());
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
turnEnded(text: string): void {
|
|
135
|
+
this.busy = false;
|
|
136
|
+
const notes = this.notes;
|
|
137
|
+
if (notes !== null) {
|
|
138
|
+
if (!this.notesStarted) return;
|
|
139
|
+
this.notes = null;
|
|
140
|
+
notes(text);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const reply = this.reply;
|
|
144
|
+
if (reply === null || this.muted) return;
|
|
145
|
+
this.speak(reply, reply.chunker.flush());
|
|
146
|
+
reply.utterance.end();
|
|
147
|
+
log.info(
|
|
148
|
+
{ firstText: since(reply.askedAt, reply.firstText), firstAudio: since(reply.askedAt, reply.firstAudio), vadSilenceMs: VAD_SILENCE_SECS * 1000 },
|
|
149
|
+
'voice: reply timing in ms from the caller’s words being committed',
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
exited(reason: string): void {
|
|
154
|
+
log.warn({ reason }, 'voice: the agent session for the call ended');
|
|
155
|
+
this.notes?.('');
|
|
156
|
+
this.notes = null;
|
|
157
|
+
this.ended(`agent session ${reason}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private takeUtterance(): Utterance {
|
|
161
|
+
const ready = this.spare?.usable === true ? this.spare : new Utterance(this.cfg.apiKey, voiceIdOf(this.cfg));
|
|
162
|
+
this.spare = this.live ? new Utterance(this.cfg.apiKey, voiceIdOf(this.cfg)) : null;
|
|
163
|
+
if (ready === this.spare) this.spare = null;
|
|
164
|
+
const reply = (): Reply | null => this.reply;
|
|
165
|
+
ready.attach({
|
|
166
|
+
audio: (pcm, chars) => {
|
|
167
|
+
const current = reply();
|
|
168
|
+
if (current?.utterance !== ready) return;
|
|
169
|
+
current.firstAudio ??= performance.now();
|
|
170
|
+
this.player.push(pcm, chars);
|
|
171
|
+
},
|
|
172
|
+
done: () => {
|
|
173
|
+
this.player.finish();
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
return ready;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private speak(reply: Reply, parts: string[]): void {
|
|
180
|
+
for (const part of parts) {
|
|
181
|
+
reply.said += `${part} `;
|
|
182
|
+
reply.utterance.say(part);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private played(chars: number): void {
|
|
187
|
+
if (this.reply !== null) this.reply.heard += chars;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private firstPlayed(): void {
|
|
191
|
+
const reply = this.reply;
|
|
192
|
+
if (reply?.askedAt != null) log.info({ firstPlayed: since(reply.askedAt, performance.now()) }, 'voice: first reply audio sent to the caller');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private partial(text: string): void {
|
|
196
|
+
if (text.replace(/\s/g, '').length < BARGE_MIN_CHARS || this.muted) return;
|
|
197
|
+
if (!this.player.busy && !this.busy) return;
|
|
198
|
+
const reply = this.reply;
|
|
199
|
+
this.cut = heardNote(reply);
|
|
200
|
+
reply?.utterance.abort();
|
|
201
|
+
this.player.clear();
|
|
202
|
+
this.muted = true;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private committed(text: string): void {
|
|
206
|
+
this.heardCaller = true;
|
|
207
|
+
const note = this.cut;
|
|
208
|
+
const now = this.busy || note !== null;
|
|
209
|
+
this.cut = null;
|
|
210
|
+
if (now && !this.muted) {
|
|
211
|
+
this.reply?.utterance.abort();
|
|
212
|
+
this.player.clear();
|
|
213
|
+
this.muted = true;
|
|
214
|
+
}
|
|
215
|
+
this.askedAt = performance.now();
|
|
216
|
+
this.busy = true;
|
|
217
|
+
this.brain.tell(note === null ? text : `${note}\n${text}`, now);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private callNotes(): Promise<string | null> {
|
|
221
|
+
return new Promise((resolve) => {
|
|
222
|
+
const timer = setTimeout(() => {
|
|
223
|
+
this.notes = null;
|
|
224
|
+
resolve(null);
|
|
225
|
+
}, NOTES_WAIT_MS);
|
|
226
|
+
this.notesStarted = false;
|
|
227
|
+
this.notes = (text) => {
|
|
228
|
+
clearTimeout(timer);
|
|
229
|
+
resolve(text.trim() === '' ? null : text.trim());
|
|
230
|
+
};
|
|
231
|
+
this.brain.tell(NOTES_PROMPT, this.busy);
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from './group.js';
|
|
16
16
|
import { updateChannelMeta } from './actions-meta.js';
|
|
17
17
|
import { deletedByRequests, isDeleteRequest, superAdminCheck } from './delete-requests.js';
|
|
18
|
+
import { isCallSignal } from './codecs.js';
|
|
18
19
|
|
|
19
20
|
type Args = Record<string, unknown>;
|
|
20
21
|
type Handler = (id: string, args: Args) => Promise<void>;
|
|
@@ -63,7 +64,7 @@ async function read(id: string, args: Args): Promise<void> {
|
|
|
63
64
|
await conv.sync().catch(() => undefined);
|
|
64
65
|
const all = await conv.messages();
|
|
65
66
|
const deleted = deletedByRequests(all, superAdminCheck(conv));
|
|
66
|
-
const slice = upTo(all.filter((m) => !isDeleteRequest(m)), before).slice(-lim);
|
|
67
|
+
const slice = upTo(all.filter((m) => !isDeleteRequest(m) && !isCallSignal(m)), before).slice(-lim);
|
|
67
68
|
const parsed = parseLine(line);
|
|
68
69
|
if (!parsed)
|
|
69
70
|
throw new TrainError('NOT_FOUND', `could not parse line ${line}`);
|