@stage-labs/metro 0.1.0-beta.264 → 0.1.0-beta.265
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/cli.js +2 -2
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/core/package.json +1 -0
- package/runtime/node_modules/@metro-labs/core/src/call.ts +81 -0
- package/runtime/node_modules/@metro-labs/core/src/runner-activity.ts +37 -1
- package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +35 -2
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +14 -11
- package/runtime/node_modules/@metro-labs/daemon/src/channels/pending.ts +3 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/call-bridge.ts +45 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/call-permission.ts +25 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/call-tools.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/event-store.ts +9 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +2 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-relay.ts +15 -4
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +20 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/speech-schema.ts +11 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/speech-send.ts +15 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-schemas.ts +3 -1
- package/runtime/node_modules/@metro-labs/daemon/src/voice/audio.ts +50 -10
- package/runtime/node_modules/@metro-labs/daemon/src/voice/brain.ts +20 -9
- package/runtime/node_modules/@metro-labs/daemon/src/voice/call.ts +125 -51
- package/runtime/node_modules/@metro-labs/daemon/src/voice/calls.ts +41 -15
- package/runtime/node_modules/@metro-labs/daemon/src/voice/peer.ts +9 -8
- package/runtime/node_modules/@metro-labs/daemon/src/voice/scribe.ts +15 -3
- package/runtime/node_modules/@metro-labs/daemon/src/voice/shared-talk.ts +85 -0
- package/runtime/node_modules/@metro-labs/daemon/src/voice/shared.ts +199 -0
- package/runtime/node_modules/@metro-labs/daemon/src/voice/speech-queue.ts +173 -0
- package/runtime/node_modules/@metro-labs/daemon/src/voice/speech.ts +40 -15
- package/runtime/node_modules/@metro-labs/daemon/src/voice/talk.ts +15 -7
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +1 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/call.ts +81 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/runner-activity.ts +37 -1
- package/runtime/sdk-runner/runner.json +1 -1
- package/runtime/sdk-runner/src/activity.ts +14 -2
- package/runtime/sdk-runner/src/app.ts +5 -4
- package/runtime/sdk-runner/src/approvals.ts +14 -5
- package/runtime/sdk-runner/src/calls.ts +133 -0
- package/runtime/sdk-runner/src/inbox.ts +58 -26
- package/runtime/sdk-runner/src/input-queue.ts +70 -0
- package/runtime/sdk-runner/src/input-timing.ts +68 -0
- package/runtime/sdk-runner/src/link.ts +27 -4
- package/runtime/sdk-runner/src/main.ts +0 -4
- package/runtime/sdk-runner/src/rules.ts +19 -5
- package/runtime/sdk-runner/src/runner.ts +64 -88
- package/runtime/sdk-runner/src/session-store.ts +14 -1
- package/runtime/sdk-runner/src/session-watch.ts +27 -20
- package/runtime/sdk-runner/src/speech.ts +0 -79
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
2
3
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
4
|
import type { TrainEvent } from '@metro-labs/core/trains/protocol';
|
|
4
|
-
import { lineReceives, mayApprove } from '../agents/map.js';
|
|
5
|
-
import { Call, trainCall } from './call.js';
|
|
6
|
-
import { readVoice, voiceReady } from './store.js';
|
|
5
|
+
import { agentIdForLine, allowlistForLine, lineReceives, mayApprove, senderPermitted } from '../agents/map.js';
|
|
6
|
+
import { Call, trainCall, type CallStart } from './call.js';
|
|
7
|
+
import { readVoice, voiceReady, type VoiceConfig } from './store.js';
|
|
7
8
|
|
|
8
9
|
const RING_MS = 40_000;
|
|
9
10
|
const CALL_TYPES = new Set(['callInvite', 'callSignal']);
|
|
@@ -12,21 +13,30 @@ const SWEEP_FIRST_MS = 5_000;
|
|
|
12
13
|
const SWEEP_EVERY_MS = 10_000;
|
|
13
14
|
const SWEEP_TRIES = 12;
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
type CallHandle = Pick<Call, 'start' | 'begin' | 'hangUp' | 'leave' | 'signalled'>;
|
|
17
|
+
type MakeCall = (start: CallStart, cfg: VoiceConfig, onOver: () => void) => CallHandle;
|
|
18
|
+
|
|
19
|
+
let current: CallHandle | null = null;
|
|
20
|
+
const invites = new Set<string>();
|
|
21
|
+
const INVITES_MAX = 256;
|
|
22
|
+
const makeCall: MakeCall = (start, cfg, onOver) => new Call(start, cfg, onOver);
|
|
16
23
|
|
|
17
24
|
const record = (value: unknown): Record<string, unknown> => (isRecord(value) ? value : {});
|
|
18
25
|
const str = (value: unknown): string => (typeof value === 'string' ? value : '');
|
|
19
26
|
|
|
20
27
|
const contentTypeOf = (env: TrainEvent): string => str(record(env.payload).contentType);
|
|
28
|
+
const sourceIdOf = (env: TrainEvent): string => str(env.message_id) || str(env.id) || randomUUID();
|
|
29
|
+
const callerNameOf = (env: TrainEvent): string => str(env.from_display_name) || str(env.from_name) || 'the caller';
|
|
21
30
|
|
|
22
31
|
export const isCallEvent = (env: TrainEvent): boolean => CALL_TYPES.has(contentTypeOf(env));
|
|
23
32
|
|
|
24
|
-
const sameCaller = (call:
|
|
33
|
+
const sameCaller = (call: CallHandle, line: string, from: string): boolean => call.start.line === line && call.start.from === from;
|
|
25
34
|
|
|
26
35
|
function refusal(line: string, from: string): string | null {
|
|
27
36
|
if (current !== null && !sameCaller(current, line, from)) return 'already in a call';
|
|
28
37
|
if (!voiceReady(readVoice())) return 'voice is off or has no API key on the Voice page';
|
|
29
|
-
if (!lineReceives(line) || !mayApprove(line, from)) return 'the caller is not someone who can approve for this channel';
|
|
38
|
+
if (!lineReceives(line) || !mayApprove(line, from) || !senderPermitted(allowlistForLine(line), from)) return 'the caller is not someone who can approve for this channel';
|
|
39
|
+
if (agentIdForLine(line) === undefined) return 'the channel does not belong to this agent';
|
|
30
40
|
return null;
|
|
31
41
|
}
|
|
32
42
|
|
|
@@ -38,15 +48,17 @@ function decline(line: string, callId: string): void {
|
|
|
38
48
|
|
|
39
49
|
function ringing(env: TrainEvent): boolean {
|
|
40
50
|
const sent = Date.parse(str(env.ts));
|
|
41
|
-
|
|
51
|
+
const age = Date.now() - sent;
|
|
52
|
+
return Number.isFinite(age) && age >= -5_000 && age < RING_MS;
|
|
42
53
|
}
|
|
43
54
|
|
|
44
|
-
function invite(env: TrainEvent, body: Record<string, unknown
|
|
55
|
+
function invite(env: TrainEvent, body: Record<string, unknown>, create: MakeCall): void {
|
|
45
56
|
const line = str(env.line);
|
|
46
57
|
const from = str(env.from);
|
|
47
58
|
const callId = str(body.callId);
|
|
48
59
|
const callerPeer = str(body.from);
|
|
49
|
-
|
|
60
|
+
const key = JSON.stringify([line, from, callId]);
|
|
61
|
+
if (callId === '' || callerPeer === '' || invites.has(key)) return;
|
|
50
62
|
if (!ringing(env)) {
|
|
51
63
|
log.info({ line }, 'voice: an invite came in after the caller stopped ringing, not answered');
|
|
52
64
|
return;
|
|
@@ -57,10 +69,15 @@ function invite(env: TrainEvent, body: Record<string, unknown>): void {
|
|
|
57
69
|
decline(line, callId);
|
|
58
70
|
return;
|
|
59
71
|
}
|
|
72
|
+
const agentId = agentIdForLine(line);
|
|
73
|
+
if (agentId === undefined) return;
|
|
74
|
+
invites.add(key);
|
|
75
|
+
if (invites.size > INVITES_MAX) invites.delete(invites.values().next().value ?? '');
|
|
60
76
|
current?.hangUp('the caller started a new call');
|
|
61
|
-
const callerName =
|
|
62
|
-
const
|
|
63
|
-
const
|
|
77
|
+
const callerName = callerNameOf(env);
|
|
78
|
+
const sourceId = sourceIdOf(env);
|
|
79
|
+
const start = { agentId, sourceId, line, lineName: str(env.line_name), direct: env.is_private === true, from, callerName, callId, callerPeer };
|
|
80
|
+
const call = create(start, readVoice(), () => {
|
|
64
81
|
if (current === call) current = null;
|
|
65
82
|
});
|
|
66
83
|
current = call;
|
|
@@ -69,10 +86,19 @@ function invite(env: TrainEvent, body: Record<string, unknown>): void {
|
|
|
69
86
|
});
|
|
70
87
|
}
|
|
71
88
|
|
|
72
|
-
|
|
89
|
+
function authenticated(env: TrainEvent): boolean {
|
|
90
|
+
if (env.sender_verified === false || (env.station !== undefined && env.station !== 'xmtp')) return false;
|
|
91
|
+
const line = str(env.line);
|
|
92
|
+
const match = /^metro:\/\/xmtp\/([^/]+)\/[^/]+$/.exec(line);
|
|
93
|
+
return match !== null && str(env.from).startsWith(`metro://xmtp/${String(match[1])}/user/`) && str(env.from).split('/').length === 6;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function onCallEvent(env: TrainEvent, train = 'xmtp', create: MakeCall = makeCall): void {
|
|
97
|
+
if (train !== 'xmtp' || !authenticated(env)) return;
|
|
73
98
|
const body = record(record(env.payload).call);
|
|
74
|
-
if (contentTypeOf(env) === 'callInvite') invite(env, body);
|
|
75
|
-
else if (current !== null && env.line
|
|
99
|
+
if (contentTypeOf(env) === 'callInvite') invite(env, body, create);
|
|
100
|
+
else if (contentTypeOf(env) === 'callSignal' && current !== null && sameCaller(current, str(env.line), str(env.from)))
|
|
101
|
+
current.signalled(body);
|
|
76
102
|
}
|
|
77
103
|
|
|
78
104
|
export function leaveCallsForShutdown(): Promise<void> {
|
|
@@ -7,7 +7,7 @@ const GATHER_MS = 2_500;
|
|
|
7
7
|
const OPUS_PT = 111;
|
|
8
8
|
const MEDIA = JSON.stringify({ audio: true, video: false, screen: false });
|
|
9
9
|
|
|
10
|
-
interface PeerEvents {
|
|
10
|
+
export interface PeerEvents {
|
|
11
11
|
audio(opus: Buffer): void;
|
|
12
12
|
state(state: string): void;
|
|
13
13
|
}
|
|
@@ -34,6 +34,7 @@ export class Peer {
|
|
|
34
34
|
private readonly channel: RTCDataChannel;
|
|
35
35
|
private sender: RTCRtpSender | null = null;
|
|
36
36
|
private sequence = Math.floor(Math.random() * 0xffff);
|
|
37
|
+
private closed = false;
|
|
37
38
|
|
|
38
39
|
constructor(private readonly events: PeerEvents) {
|
|
39
40
|
this.channel = this.pc.createDataChannel('media', { negotiated: true, id: 0 });
|
|
@@ -43,11 +44,11 @@ export class Peer {
|
|
|
43
44
|
this.pc.onTrack.subscribe((track) => {
|
|
44
45
|
if (track.kind === 'audio')
|
|
45
46
|
track.onReceiveRtp.subscribe((rtp) => {
|
|
46
|
-
this.events.audio(rtp.payload);
|
|
47
|
+
if (!this.closed) this.events.audio(rtp.payload);
|
|
47
48
|
});
|
|
48
49
|
});
|
|
49
50
|
this.pc.connectionStateChange.subscribe((state) => {
|
|
50
|
-
this.events.state(state);
|
|
51
|
+
if (!this.closed) this.events.state(state);
|
|
51
52
|
});
|
|
52
53
|
}
|
|
53
54
|
|
|
@@ -66,17 +67,17 @@ export class Peer {
|
|
|
66
67
|
return sdp;
|
|
67
68
|
}
|
|
68
69
|
|
|
69
|
-
sendOpus(payload: Buffer, timestamp: number, marker: boolean): void {
|
|
70
|
+
sendOpus(payload: Buffer, timestamp: number, marker: boolean): Promise<void> {
|
|
70
71
|
const sender = this.sender;
|
|
71
|
-
if (sender === null) return;
|
|
72
|
+
if (this.closed || sender === null) return Promise.reject(new Error('call audio transport is closed'));
|
|
72
73
|
this.sequence = (this.sequence + 1) & 0xffff;
|
|
73
74
|
const header = new RtpHeader({ payloadType: OPUS_PT, sequenceNumber: this.sequence, timestamp, marker });
|
|
74
|
-
sender.sendRtp(new RtpPacket(header, payload))
|
|
75
|
-
log.debug({ err: errMsg(err) }, 'voice: an audio packet was not sent');
|
|
76
|
-
});
|
|
75
|
+
return sender.sendRtp(new RtpPacket(header, payload));
|
|
77
76
|
}
|
|
78
77
|
|
|
79
78
|
close(): void {
|
|
79
|
+
if (this.closed) return;
|
|
80
|
+
this.closed = true;
|
|
80
81
|
this.pc.close().catch((err: unknown) => {
|
|
81
82
|
log.debug({ err: errMsg(err) }, 'voice: closing the call connection failed');
|
|
82
83
|
});
|
|
@@ -9,7 +9,7 @@ const CHUNK_SAMPLES = STT_RATE / 10;
|
|
|
9
9
|
const HELD_SAMPLES = STT_RATE * 2;
|
|
10
10
|
export const VAD_SILENCE_SECS = 0.5;
|
|
11
11
|
|
|
12
|
-
interface Heard {
|
|
12
|
+
export interface Heard {
|
|
13
13
|
partial(text: string): void;
|
|
14
14
|
committed(text: string): void;
|
|
15
15
|
failed(reason: string): void;
|
|
@@ -54,6 +54,7 @@ export class Scribe {
|
|
|
54
54
|
private readonly ws: WebSocket;
|
|
55
55
|
private buffer: Int16Array[] = [];
|
|
56
56
|
private buffered = 0;
|
|
57
|
+
private closed = false;
|
|
57
58
|
|
|
58
59
|
constructor(apiKey: string, language: string | null, private readonly heard: Heard) {
|
|
59
60
|
this.ws = new WebSocket(sttUrl(language), { headers: { 'xi-api-key': apiKey } });
|
|
@@ -61,14 +62,20 @@ export class Scribe {
|
|
|
61
62
|
this.onMessage(wsText(data));
|
|
62
63
|
});
|
|
63
64
|
this.ws.on('error', (err) => {
|
|
65
|
+
if (this.closed) return;
|
|
64
66
|
log.warn({ err: errMsg(err) }, 'voice: speech to text connection error');
|
|
65
67
|
});
|
|
66
68
|
this.ws.on('close', (code) => {
|
|
67
|
-
if (
|
|
69
|
+
if (this.closed) return;
|
|
70
|
+
this.closed = true;
|
|
71
|
+
this.buffer = [];
|
|
72
|
+
this.buffered = 0;
|
|
73
|
+
this.heard.failed(`speech to text closed (${String(code)})`);
|
|
68
74
|
});
|
|
69
75
|
}
|
|
70
76
|
|
|
71
77
|
send(pcm16k: Int16Array): void {
|
|
78
|
+
if (this.closed) return;
|
|
72
79
|
this.buffer.push(pcm16k);
|
|
73
80
|
this.buffered += pcm16k.length;
|
|
74
81
|
while (this.buffered > HELD_SAMPLES) this.buffered -= this.buffer.shift()?.length ?? this.buffered;
|
|
@@ -87,10 +94,15 @@ export class Scribe {
|
|
|
87
94
|
}
|
|
88
95
|
|
|
89
96
|
close(): void {
|
|
90
|
-
this.
|
|
97
|
+
if (this.closed) return;
|
|
98
|
+
this.closed = true;
|
|
99
|
+
this.buffer = [];
|
|
100
|
+
this.buffered = 0;
|
|
101
|
+
this.ws.terminate();
|
|
91
102
|
}
|
|
92
103
|
|
|
93
104
|
private onMessage(raw: string): void {
|
|
105
|
+
if (this.closed) return;
|
|
94
106
|
const msg = parsed(raw);
|
|
95
107
|
const type = typeof msg.message_type === 'string' ? msg.message_type : '';
|
|
96
108
|
const text = typeof msg.text === 'string' ? msg.text.trim() : '';
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { downsample3, OpusIn, type Playback } from './audio.js';
|
|
3
|
+
import { Scribe, type Heard } from './scribe.js';
|
|
4
|
+
import type { SharedCallTransport, SharedSpeechAction } from './shared.js';
|
|
5
|
+
import { SpeechQueue } from './speech-queue.js';
|
|
6
|
+
import { languageOf, type VoiceConfig } from './store.js';
|
|
7
|
+
|
|
8
|
+
const BARGE_MIN_WORDS = 2;
|
|
9
|
+
const wordsIn = (text: string): number => text.split(/\s+/).filter((word) => /\p{L}/u.test(word)).length;
|
|
10
|
+
|
|
11
|
+
type MakeScribe = (heard: Heard) => Pick<Scribe, 'send' | 'close'>;
|
|
12
|
+
|
|
13
|
+
export class SharedTalk implements SharedCallTransport {
|
|
14
|
+
private readonly opus = new OpusIn();
|
|
15
|
+
private readonly scribe: Pick<Scribe, 'send' | 'close'>;
|
|
16
|
+
private readonly speech: SpeechQueue;
|
|
17
|
+
private closed = false;
|
|
18
|
+
|
|
19
|
+
constructor(
|
|
20
|
+
cfg: VoiceConfig,
|
|
21
|
+
send: Playback['send'],
|
|
22
|
+
private readonly ended: (reason: string) => void,
|
|
23
|
+
heard: (text: string, sourceId: string) => void,
|
|
24
|
+
makeScribe: MakeScribe = (events) => new Scribe(cfg.apiKey, languageOf(cfg), events),
|
|
25
|
+
speech = new SpeechQueue(cfg, send),
|
|
26
|
+
) {
|
|
27
|
+
this.speech = speech;
|
|
28
|
+
this.speech.whenFailed(() => {
|
|
29
|
+
this.stop('speech is unavailable');
|
|
30
|
+
});
|
|
31
|
+
this.scribe = makeScribe({
|
|
32
|
+
partial: (text) => {
|
|
33
|
+
this.barge(text);
|
|
34
|
+
},
|
|
35
|
+
committed: (text) => {
|
|
36
|
+
if (this.closed || text.trim() === '') return;
|
|
37
|
+
this.barge(text);
|
|
38
|
+
heard(text, randomUUID());
|
|
39
|
+
},
|
|
40
|
+
failed: (reason) => {
|
|
41
|
+
if (!this.closed) ended(reason);
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
enqueue(action: SharedSpeechAction): boolean {
|
|
47
|
+
return !this.closed && this.speech.enqueue(action);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
cancel(): void {
|
|
51
|
+
this.speech.cancel();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
terminate(): void {
|
|
55
|
+
this.stop('the shared conversation ended');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
connect(): void {
|
|
59
|
+
if (!this.closed) this.speech.connect();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
hear(packet: Buffer): void {
|
|
63
|
+
if (this.closed) return;
|
|
64
|
+
const pcm = this.opus.decode(packet);
|
|
65
|
+
if (pcm !== null) this.scribe.send(downsample3(pcm));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
finish(): void {
|
|
69
|
+
if (this.closed) return;
|
|
70
|
+
this.closed = true;
|
|
71
|
+
this.speech.close();
|
|
72
|
+
this.scribe.close();
|
|
73
|
+
this.opus.close();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private stop(reason: string): void {
|
|
77
|
+
if (this.closed) return;
|
|
78
|
+
this.finish();
|
|
79
|
+
this.ended(reason);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private barge(text: string): void {
|
|
83
|
+
if (!this.closed && this.speech.busy && wordsIn(text) >= BARGE_MIN_WORDS) this.speech.cancel();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { sameCall, type CallNotice, type CallRoute, type CallSource, type SpeechStatus, type SpeechTarget } from '@metro-labs/core/call';
|
|
3
|
+
import { str } from '@metro-labs/core/str';
|
|
4
|
+
import { log } from '@metro-labs/core/log';
|
|
5
|
+
import { agentIdForLine, allowlistForLine, lineReceives, mayApprove, senderPermitted, accountFromLine } from '../agents/map.js';
|
|
6
|
+
import { eventInScope } from '../agents/scope.js';
|
|
7
|
+
import { harnessRunner } from '../claude/runner.js';
|
|
8
|
+
import { policyFor, type Access } from '../policy/policy.js';
|
|
9
|
+
|
|
10
|
+
export interface SharedSpeechAction {
|
|
11
|
+
actionId: string;
|
|
12
|
+
text: string;
|
|
13
|
+
isValid(): boolean;
|
|
14
|
+
status(status: SpeechStatus): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SharedCallTransport {
|
|
18
|
+
enqueue(action: SharedSpeechAction): boolean;
|
|
19
|
+
terminate(): void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SpeechReceipt {
|
|
23
|
+
actionId: string;
|
|
24
|
+
sourceId: string;
|
|
25
|
+
status: SpeechStatus;
|
|
26
|
+
duplicate: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface Action extends SpeechReceipt {
|
|
30
|
+
text: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface Active {
|
|
34
|
+
route: CallRoute;
|
|
35
|
+
at: number;
|
|
36
|
+
transport: SharedCallTransport;
|
|
37
|
+
sources: Set<string>;
|
|
38
|
+
actions: Map<string, Action>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SharedCallBridge {
|
|
42
|
+
available(route: CallRoute): boolean;
|
|
43
|
+
notify(notice: CallNotice): boolean;
|
|
44
|
+
revoked(route: CallRoute): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const SOURCES_MAX = 1024;
|
|
48
|
+
const TEXT_MAX = 32_000;
|
|
49
|
+
const sourceValid = (sourceId: string): boolean => sourceId.length > 0 && sourceId.length <= 2048;
|
|
50
|
+
|
|
51
|
+
const authorized = (route: CallRoute): boolean =>
|
|
52
|
+
agentIdForLine(route.line) === route.agentId && eventInScope(new Set([route.agentId]), route.line) && lineReceives(route.line) &&
|
|
53
|
+
mayApprove(route.line, route.from) && senderPermitted(allowlistForLine(route.line), route.from);
|
|
54
|
+
|
|
55
|
+
function access(route: CallRoute): Access {
|
|
56
|
+
const account = accountFromLine(route.line);
|
|
57
|
+
return account === undefined ? 'deny' : policyFor({ kind: 'channel', station: account.station, account: account.accountId }, { name: 'send', group: 'write' });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class SharedCalls {
|
|
61
|
+
private active: Active | null = null;
|
|
62
|
+
private bridge: SharedCallBridge | null = null;
|
|
63
|
+
|
|
64
|
+
constructor(private readonly permitted = authorized, private readonly policy = access) {}
|
|
65
|
+
|
|
66
|
+
connect(bridge: SharedCallBridge): void {
|
|
67
|
+
this.disconnect();
|
|
68
|
+
this.bridge = bridge;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
disconnect(): void {
|
|
72
|
+
this.revoke();
|
|
73
|
+
this.bridge = null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
revoke(): void {
|
|
77
|
+
const active = this.active;
|
|
78
|
+
if (active !== null) this.end(active.route);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
open(route: CallRoute, sourceId: string, transport: SharedCallTransport): boolean {
|
|
82
|
+
if (!this.permitted(route) || this.bridge?.available(route) !== true || !sourceValid(sourceId)) return false;
|
|
83
|
+
if (this.active !== null && sameCall(this.active.route, route)) return this.active.transport === transport && this.active.sources.has(sourceId);
|
|
84
|
+
if (this.active !== null) this.end(this.active.route);
|
|
85
|
+
this.active = { route, at: Date.now(), transport, sources: new Set([sourceId]), actions: new Map() };
|
|
86
|
+
if (this.bridge.notify({ type: 'started', route, sourceId })) return true;
|
|
87
|
+
this.end(route);
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
snapshot(): CallRoute | null {
|
|
92
|
+
const route = this.active?.route;
|
|
93
|
+
return route !== undefined && this.current(route) !== null ? route : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
valid(source: CallSource): boolean {
|
|
97
|
+
return this.current(source.route)?.sources.has(source.sourceId) === true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private current(route: CallRoute): Active | null {
|
|
101
|
+
const active = this.active;
|
|
102
|
+
return active !== null && sameCall(active.route, route) && this.permitted(route) && this.bridge?.available(route) === true ? active : null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
heard(route: CallRoute, text: string, sourceId: string): void {
|
|
106
|
+
if (text.trim() === '' || text.length > TEXT_MAX || !this.addSource(route, sourceId)) return;
|
|
107
|
+
if (this.bridge?.notify({ type: 'heard', route, text, sourceId }) !== true) this.end(route);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
chat(event: Record<string, unknown>, scope: Set<string>): Record<string, string> {
|
|
111
|
+
const active = this.active;
|
|
112
|
+
if (active === null || event.senderVerified === false || !scope.has(active.route.agentId)) return {};
|
|
113
|
+
const { route } = active;
|
|
114
|
+
if (event.line !== route.line || event.from !== route.from || Date.parse(str(event.ts)) < active.at || !Number.isFinite(Date.parse(str(event.ts)))) return {};
|
|
115
|
+
const sourceId = str(event.messageId);
|
|
116
|
+
if (!this.addSource(route, sourceId, true)) return {};
|
|
117
|
+
return { call_id: route.callId, call_generation: route.generation, call_source_id: sourceId };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
bindChat(event: Record<string, unknown>, scope: Set<string>): () => Record<string, string> {
|
|
121
|
+
const meta = this.chat(event, scope);
|
|
122
|
+
const route = this.active?.route;
|
|
123
|
+
const sourceId = meta.call_source_id;
|
|
124
|
+
return () => route !== undefined && sourceId !== undefined && this.valid({ route, sourceId }) ? meta : {};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
source(line: string, target: SpeechTarget, scope: Set<string>): CallSource | null {
|
|
128
|
+
const route = this.active?.route;
|
|
129
|
+
if (route === undefined || route.line !== line || route.callId !== target.callId || route.generation !== target.generation || !scope.has(route.agentId)) return null;
|
|
130
|
+
const source = { route, sourceId: target.sourceId };
|
|
131
|
+
return this.valid(source) ? source : null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
speak(line: string, target: SpeechTarget, text: string, scope: Set<string>): SpeechReceipt {
|
|
135
|
+
const source = this.source(line, target, scope);
|
|
136
|
+
const active = this.active;
|
|
137
|
+
if (source === null || active === null) throw new Error('The call or its authorized source has ended or changed. Nothing was spoken.');
|
|
138
|
+
if (text.trim() === '' || text.length > TEXT_MAX) throw new Error('Call speech needs 1 to 32000 characters of text.');
|
|
139
|
+
const previous = active.actions.get(source.sourceId);
|
|
140
|
+
if (previous !== undefined) {
|
|
141
|
+
if (previous.text !== text) throw new Error('This source already has a different speech action. Nothing was repeated.');
|
|
142
|
+
return { actionId: previous.actionId, sourceId: previous.sourceId, status: previous.status, duplicate: true };
|
|
143
|
+
}
|
|
144
|
+
const accessAtAcceptance = this.policy(source.route);
|
|
145
|
+
if (accessAtAcceptance === 'deny') throw new Error('The owner blocked sending on this channel.');
|
|
146
|
+
const action: Action = { actionId: randomUUID(), sourceId: source.sourceId, text, status: 'accepted', duplicate: false };
|
|
147
|
+
active.actions.set(source.sourceId, action);
|
|
148
|
+
this.status(source.route, action, 'accepted');
|
|
149
|
+
const queued = active.transport.enqueue({
|
|
150
|
+
actionId: action.actionId,
|
|
151
|
+
text,
|
|
152
|
+
isValid: () => this.valid(source) && this.policy(source.route) === accessAtAcceptance,
|
|
153
|
+
status: (status) => { this.status(source.route, action, status); },
|
|
154
|
+
});
|
|
155
|
+
if (!queued) this.status(source.route, action, 'failed');
|
|
156
|
+
else if (action.status === 'accepted') this.status(source.route, action, 'queued');
|
|
157
|
+
return { actionId: action.actionId, sourceId: action.sourceId, status: action.status, duplicate: false };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
end(route: CallRoute): void {
|
|
161
|
+
const active = this.active;
|
|
162
|
+
if (active === null || !sameCall(active.route, route)) return;
|
|
163
|
+
this.active = null;
|
|
164
|
+
this.bridge?.revoked(route);
|
|
165
|
+
active.transport.terminate();
|
|
166
|
+
for (const action of active.actions.values())
|
|
167
|
+
if (!this.terminal(action.status)) this.status(route, action, 'interrupted');
|
|
168
|
+
this.bridge?.notify({ type: 'ended', route });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private addSource(route: CallRoute, sourceId: string, duplicate = false): boolean {
|
|
172
|
+
const active = this.current(route);
|
|
173
|
+
if (active === null || !sourceValid(sourceId)) return false;
|
|
174
|
+
if (active.sources.has(sourceId)) return duplicate;
|
|
175
|
+
if (active.sources.size >= SOURCES_MAX) {
|
|
176
|
+
log.warn('sdk-call: the call source limit was reached; ending the call');
|
|
177
|
+
this.end(route);
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
active.sources.add(sourceId);
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private terminal(status: SpeechStatus): boolean {
|
|
185
|
+
return status === 'completed' || status === 'interrupted' || status === 'failed';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private status(route: CallRoute, action: Action, status: SpeechStatus): void {
|
|
189
|
+
if (this.terminal(action.status) || (status === 'started' && action.status === 'started')) return;
|
|
190
|
+
action.status = status;
|
|
191
|
+
this.bridge?.notify({ type: 'speech', route, sourceId: action.sourceId, actionId: action.actionId, status });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export const sharedCalls = new SharedCalls();
|
|
196
|
+
export const sharedCallsSelected = (): boolean => harnessRunner() === 'sdk';
|
|
197
|
+
export const openSharedCall = (route: CallRoute, sourceId: string, transport: SharedCallTransport): boolean => sharedCalls.open(route, sourceId, transport);
|
|
198
|
+
export const hearSharedCall = (route: CallRoute, text: string, sourceId: string): void => { sharedCalls.heard(route, text, sourceId); };
|
|
199
|
+
export const endSharedCall = (route: CallRoute): void => { sharedCalls.end(route); };
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import type { SpeechStatus } from '@metro-labs/core/call';
|
|
2
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
|
+
import { Player, type Playback } from './audio.js';
|
|
4
|
+
import { Utterance } from './speech.js';
|
|
5
|
+
import type { SharedSpeechAction } from './shared.js';
|
|
6
|
+
import { voiceIdOf, type VoiceConfig } from './store.js';
|
|
7
|
+
|
|
8
|
+
const MAX_QUEUED = 8;
|
|
9
|
+
const MAX_TEXT = 32_000;
|
|
10
|
+
const SPEECH_MS = 60_000;
|
|
11
|
+
|
|
12
|
+
type Speech = Pick<Utterance, 'attach' | 'say' | 'end' | 'abort'>;
|
|
13
|
+
type MakeSpeech = (failed: (reason: string) => void) => Speech;
|
|
14
|
+
|
|
15
|
+
interface Playing {
|
|
16
|
+
action: SharedSpeechAction;
|
|
17
|
+
utterance: Speech | null;
|
|
18
|
+
timer: ReturnType<typeof setTimeout>;
|
|
19
|
+
synthesized: boolean;
|
|
20
|
+
started: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class SpeechQueue {
|
|
24
|
+
private readonly player: Player;
|
|
25
|
+
private readonly waiting: SharedSpeechAction[] = [];
|
|
26
|
+
private current: Playing | null = null;
|
|
27
|
+
private live = false;
|
|
28
|
+
private closed = false;
|
|
29
|
+
private onFailed: () => void = () => undefined;
|
|
30
|
+
|
|
31
|
+
constructor(
|
|
32
|
+
cfg: VoiceConfig,
|
|
33
|
+
send: Playback['send'],
|
|
34
|
+
private readonly makeSpeech: MakeSpeech = (failed) => new Utterance(cfg.apiKey, voiceIdOf(cfg), failed),
|
|
35
|
+
private readonly timeoutMs = SPEECH_MS,
|
|
36
|
+
) {
|
|
37
|
+
this.player = new Player({
|
|
38
|
+
send: (opus, timestamp, marker) => {
|
|
39
|
+
if (this.current !== null && !this.valid(this.current)) return;
|
|
40
|
+
return send(opus, timestamp, marker);
|
|
41
|
+
},
|
|
42
|
+
played: () => undefined,
|
|
43
|
+
});
|
|
44
|
+
this.player.whenFirstPlayed(() => {
|
|
45
|
+
const current = this.current;
|
|
46
|
+
if (current === null || current.started) return;
|
|
47
|
+
current.started = true;
|
|
48
|
+
current.action.status('started');
|
|
49
|
+
});
|
|
50
|
+
this.player.whenIdle(() => {
|
|
51
|
+
this.drained();
|
|
52
|
+
});
|
|
53
|
+
this.player.whenFailed((reason) => {
|
|
54
|
+
this.fail(reason);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
get busy(): boolean {
|
|
59
|
+
return this.player.busy;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
whenFailed(failed: () => void): void {
|
|
63
|
+
this.onFailed = failed;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
enqueue(action: SharedSpeechAction): boolean {
|
|
67
|
+
if (this.closed || action.text.trim() === '' || action.text.length > MAX_TEXT) return false;
|
|
68
|
+
if (this.waiting.length + (this.current === null ? 0 : 1) >= MAX_QUEUED || !action.isValid()) return false;
|
|
69
|
+
this.waiting.push(action);
|
|
70
|
+
queueMicrotask(() => {
|
|
71
|
+
this.next();
|
|
72
|
+
});
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
connect(): void {
|
|
77
|
+
if (this.closed || this.live) return;
|
|
78
|
+
this.live = true;
|
|
79
|
+
this.player.start();
|
|
80
|
+
this.next();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
cancel(): void {
|
|
84
|
+
const waiting = this.waiting.splice(0);
|
|
85
|
+
this.settle('interrupted');
|
|
86
|
+
for (const action of waiting) action.status('interrupted');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
close(): void {
|
|
90
|
+
if (this.closed) return;
|
|
91
|
+
this.closed = true;
|
|
92
|
+
this.live = false;
|
|
93
|
+
this.cancel();
|
|
94
|
+
this.player.stop();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private next(): void {
|
|
98
|
+
if (!this.live || this.closed || this.current !== null) return;
|
|
99
|
+
const action = this.waiting.shift();
|
|
100
|
+
if (action === undefined) return;
|
|
101
|
+
if (!action.isValid()) {
|
|
102
|
+
action.status('failed');
|
|
103
|
+
this.next();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const playing: Playing = {
|
|
107
|
+
action, utterance: null, synthesized: false, started: false,
|
|
108
|
+
timer: setTimeout(() => {
|
|
109
|
+
if (this.current === playing) this.fail('speech timed out');
|
|
110
|
+
}, this.timeoutMs),
|
|
111
|
+
};
|
|
112
|
+
this.current = playing;
|
|
113
|
+
try {
|
|
114
|
+
const utterance = this.makeSpeech((reason) => {
|
|
115
|
+
if (this.current === playing) this.fail(reason);
|
|
116
|
+
});
|
|
117
|
+
if (this.current !== playing) {
|
|
118
|
+
utterance.abort();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
playing.utterance = utterance;
|
|
122
|
+
utterance.attach({
|
|
123
|
+
audio: (pcm, chars) => {
|
|
124
|
+
if (this.valid(playing)) this.player.push(pcm, chars);
|
|
125
|
+
},
|
|
126
|
+
done: () => {
|
|
127
|
+
if (!this.valid(playing)) return;
|
|
128
|
+
playing.synthesized = true;
|
|
129
|
+
this.player.finish();
|
|
130
|
+
this.drained();
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
utterance.say(action.text);
|
|
134
|
+
utterance.end();
|
|
135
|
+
} catch (err) {
|
|
136
|
+
if (this.current === playing) this.fail(errMsg(err));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private valid(playing: Playing): boolean {
|
|
141
|
+
if (this.closed || this.current !== playing) return false;
|
|
142
|
+
if (playing.action.isValid()) return true;
|
|
143
|
+
this.settle('failed');
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private drained(): void {
|
|
148
|
+
const current = this.current;
|
|
149
|
+
if (current === null || !current.synthesized || this.player.busy) return;
|
|
150
|
+
if (current.started) this.settle('completed');
|
|
151
|
+
else this.fail('no audio received');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private fail(reason: string): void {
|
|
155
|
+
log.warn({ reason }, 'voice: queued speech failed');
|
|
156
|
+
this.settle('failed');
|
|
157
|
+
this.onFailed();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private settle(status: SpeechStatus): void {
|
|
161
|
+
const current = this.current;
|
|
162
|
+
this.current = null;
|
|
163
|
+
this.player.clear();
|
|
164
|
+
if (current !== null) {
|
|
165
|
+
clearTimeout(current.timer);
|
|
166
|
+
current.utterance?.abort();
|
|
167
|
+
current.action.status(status);
|
|
168
|
+
}
|
|
169
|
+
queueMicrotask(() => {
|
|
170
|
+
this.next();
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|