@athenaintel/react 0.48.0 → 0.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -10
- package/dist/agent-runtime/src/call-recap.cjs +319 -0
- package/dist/agent-runtime/src/call-recap.cjs.map +1 -0
- package/dist/agent-runtime/src/call-recap.d.ts +142 -0
- package/dist/agent-runtime/src/call-recap.js +319 -0
- package/dist/agent-runtime/src/call-recap.js.map +1 -0
- package/dist/agent-runtime/src/chat-outbox-store.cjs +1 -0
- package/dist/agent-runtime/src/chat-outbox-store.cjs.map +1 -1
- package/dist/agent-runtime/src/chat-outbox-store.d.ts +7 -0
- package/dist/agent-runtime/src/chat-outbox-store.js +1 -0
- package/dist/agent-runtime/src/chat-outbox-store.js.map +1 -1
- package/dist/agent-runtime/src/chat-outbox.cjs +46 -5
- package/dist/agent-runtime/src/chat-outbox.cjs.map +1 -1
- package/dist/agent-runtime/src/chat-outbox.d.ts +29 -0
- package/dist/agent-runtime/src/chat-outbox.js +46 -5
- package/dist/agent-runtime/src/chat-outbox.js.map +1 -1
- package/dist/agent-runtime/src/conversation-index.cjs.map +1 -1
- package/dist/agent-runtime/src/conversation-index.d.ts +7 -0
- package/dist/agent-runtime/src/conversation-index.js.map +1 -1
- package/dist/agent-runtime/src/conversation-list.cjs +2 -1
- package/dist/agent-runtime/src/conversation-list.cjs.map +1 -1
- package/dist/agent-runtime/src/conversation-list.d.ts +19 -3
- package/dist/agent-runtime/src/conversation-list.js +2 -1
- package/dist/agent-runtime/src/conversation-list.js.map +1 -1
- package/dist/agent-runtime/src/index.d.ts +6 -5
- package/dist/agent-runtime/src/interrupts.cjs +28 -1
- package/dist/agent-runtime/src/interrupts.cjs.map +1 -1
- package/dist/agent-runtime/src/interrupts.d.ts +21 -1
- package/dist/agent-runtime/src/interrupts.js +28 -1
- package/dist/agent-runtime/src/interrupts.js.map +1 -1
- package/dist/agent-runtime/src/model-context.cjs +3 -2
- package/dist/agent-runtime/src/model-context.cjs.map +1 -1
- package/dist/agent-runtime/src/model-context.d.ts +6 -1
- package/dist/agent-runtime/src/model-context.js +3 -2
- package/dist/agent-runtime/src/model-context.js.map +1 -1
- package/dist/agent-runtime/src/runtime.d.ts +3 -2
- package/dist/agent-runtime/src/send-recovery.cjs +37 -3
- package/dist/agent-runtime/src/send-recovery.cjs.map +1 -1
- package/dist/agent-runtime/src/send-recovery.d.ts +19 -0
- package/dist/agent-runtime/src/send-recovery.js +37 -3
- package/dist/agent-runtime/src/send-recovery.js.map +1 -1
- package/dist/agent-runtime/src/thread-handoff-flow-machine.d.ts +8 -8
- package/dist/agent-runtime/src/thread-handoff-protocol.d.ts +113 -1
- package/dist/agent-runtime/src/thread-handoff.d.ts +1 -1
- package/dist/agent-runtime/src/use-conversation-list.cjs +3 -2
- package/dist/agent-runtime/src/use-conversation-list.cjs.map +1 -1
- package/dist/agent-runtime/src/use-conversation-list.d.ts +6 -0
- package/dist/agent-runtime/src/use-conversation-list.js +3 -2
- package/dist/agent-runtime/src/use-conversation-list.js.map +1 -1
- package/dist/agent-runtime/src/use-deep-agent-chat-outbox.cjs +21 -1
- package/dist/agent-runtime/src/use-deep-agent-chat-outbox.cjs.map +1 -1
- package/dist/agent-runtime/src/use-deep-agent-chat-outbox.d.ts +8 -2
- package/dist/agent-runtime/src/use-deep-agent-chat-outbox.js +21 -1
- package/dist/agent-runtime/src/use-deep-agent-chat-outbox.js.map +1 -1
- package/dist/agent-runtime/src/use-deep-agent-runtime.cjs +24 -6
- package/dist/agent-runtime/src/use-deep-agent-runtime.cjs.map +1 -1
- package/dist/agent-runtime/src/use-deep-agent-runtime.d.ts +17 -0
- package/dist/agent-runtime/src/use-deep-agent-runtime.js +24 -6
- package/dist/agent-runtime/src/use-deep-agent-runtime.js.map +1 -1
- package/dist/agent-runtime/src/use-deep-agent-voice.cjs +44 -9
- package/dist/agent-runtime/src/use-deep-agent-voice.cjs.map +1 -1
- package/dist/agent-runtime/src/use-deep-agent-voice.d.ts +34 -1
- package/dist/agent-runtime/src/use-deep-agent-voice.js +44 -9
- package/dist/agent-runtime/src/use-deep-agent-voice.js.map +1 -1
- package/dist/agent-runtime/src/use-thread-handoff-flow.d.ts +8 -2
- package/dist/agent-runtime/src/voice-earcon.cjs +139 -0
- package/dist/agent-runtime/src/voice-earcon.cjs.map +1 -0
- package/dist/agent-runtime/src/voice-earcon.d.ts +38 -0
- package/dist/agent-runtime/src/voice-earcon.js +139 -0
- package/dist/agent-runtime/src/voice-earcon.js.map +1 -0
- package/dist/agent-runtime/src/voice-media.cjs.map +1 -1
- package/dist/agent-runtime/src/voice-media.d.ts +14 -0
- package/dist/agent-runtime/src/voice-media.js.map +1 -1
- package/dist/agent-runtime/src/voice-presentation.cjs +2 -0
- package/dist/agent-runtime/src/voice-presentation.cjs.map +1 -1
- package/dist/agent-runtime/src/voice-presentation.js +2 -0
- package/dist/agent-runtime/src/voice-presentation.js.map +1 -1
- package/dist/assets/AthenaAssetEmbed.cjs.map +1 -1
- package/dist/assets/AthenaAssetEmbed.d.ts +11 -5
- package/dist/assets/AthenaAssetEmbed.js.map +1 -1
- package/dist/assets/use-asset-embed.cjs.map +1 -1
- package/dist/assets/use-asset-embed.d.ts +12 -5
- package/dist/assets/use-asset-embed.js.map +1 -1
- package/dist/chat/StatewireApprovalCard.cjs +41 -6
- package/dist/chat/StatewireApprovalCard.cjs.map +1 -1
- package/dist/chat/StatewireApprovalCard.d.ts +18 -1
- package/dist/chat/StatewireApprovalCard.js +42 -7
- package/dist/chat/StatewireApprovalCard.js.map +1 -1
- package/dist/chat/StatewireQueuedMessages.cjs +56 -6
- package/dist/chat/StatewireQueuedMessages.cjs.map +1 -1
- package/dist/chat/StatewireQueuedMessages.d.ts +14 -0
- package/dist/chat/StatewireQueuedMessages.js +58 -8
- package/dist/chat/StatewireQueuedMessages.js.map +1 -1
- package/dist/chat/WaitingOnYouBanner.cjs +6 -2
- package/dist/chat/WaitingOnYouBanner.cjs.map +1 -1
- package/dist/chat/WaitingOnYouBanner.js +7 -3
- package/dist/chat/WaitingOnYouBanner.js.map +1 -1
- package/dist/chat/chat-voice-controls.cjs +3 -1
- package/dist/chat/chat-voice-controls.cjs.map +1 -1
- package/dist/chat/chat-voice-controls.d.ts +6 -1
- package/dist/chat/chat-voice-controls.js +3 -1
- package/dist/chat/chat-voice-controls.js.map +1 -1
- package/dist/chat-ui/index.cjs +3 -0
- package/dist/chat-ui/index.cjs.map +1 -1
- package/dist/chat-ui/index.d.ts +1 -0
- package/dist/chat-ui/index.js +3 -0
- package/dist/chat-ui/index.js.map +1 -1
- package/dist/chat-ui/messages/from-the-call-card.cjs +124 -0
- package/dist/chat-ui/messages/from-the-call-card.cjs.map +1 -0
- package/dist/chat-ui/messages/from-the-call-card.d.ts +17 -0
- package/dist/chat-ui/messages/from-the-call-card.js +124 -0
- package/dist/chat-ui/messages/from-the-call-card.js.map +1 -0
- package/dist/chat-ui/rich-text/chat-link.cjs +1 -0
- package/dist/chat-ui/rich-text/chat-link.cjs.map +1 -1
- package/dist/chat-ui/rich-text/chat-link.d.ts +4 -0
- package/dist/chat-ui/rich-text/chat-link.js +4 -3
- package/dist/chat-ui/rich-text/chat-link.js.map +1 -1
- package/dist/chat-ui/rich-text/chat-message-body.cjs +1 -0
- package/dist/chat-ui/rich-text/chat-message-body.cjs.map +1 -1
- package/dist/chat-ui/rich-text/chat-message-body.d.ts +12 -0
- package/dist/chat-ui/rich-text/chat-message-body.js +2 -1
- package/dist/chat-ui/rich-text/chat-message-body.js.map +1 -1
- package/dist/chat-ui/thread/chat-thread-shell.cjs +2 -0
- package/dist/chat-ui/thread/chat-thread-shell.cjs.map +1 -1
- package/dist/chat-ui/thread/chat-thread-shell.js +2 -0
- package/dist/chat-ui/thread/chat-thread-shell.js.map +1 -1
- package/dist/collab/client.cjs +15 -3
- package/dist/collab/client.cjs.map +1 -1
- package/dist/collab/client.d.ts +4 -2
- package/dist/collab/client.js +16 -4
- package/dist/collab/client.js.map +1 -1
- package/dist/composer/index.d.ts +1 -1
- package/dist/composer/useReadComposerDraft.cjs +26 -7
- package/dist/composer/useReadComposerDraft.cjs.map +1 -1
- package/dist/composer/useReadComposerDraft.d.ts +23 -1
- package/dist/composer/useReadComposerDraft.js +27 -8
- package/dist/composer/useReadComposerDraft.js.map +1 -1
- package/dist/index.cjs +3 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/keryx-http/src/unconfirmed-update-attributions.cjs +190 -0
- package/dist/keryx-http/src/unconfirmed-update-attributions.cjs.map +1 -0
- package/dist/keryx-http/src/unconfirmed-update-attributions.d.ts +132 -0
- package/dist/keryx-http/src/unconfirmed-update-attributions.js +190 -0
- package/dist/keryx-http/src/unconfirmed-update-attributions.js.map +1 -0
- package/dist/keryx-http/src/update-attributions.cjs +34 -4
- package/dist/keryx-http/src/update-attributions.cjs.map +1 -1
- package/dist/keryx-http/src/update-attributions.d.ts +36 -4
- package/dist/keryx-http/src/update-attributions.js +35 -5
- package/dist/keryx-http/src/update-attributions.js.map +1 -1
- package/dist/provider/AthenaProvider.cjs +4 -0
- package/dist/provider/AthenaProvider.cjs.map +1 -1
- package/dist/provider/AthenaProvider.d.ts +11 -1
- package/dist/provider/AthenaProvider.js +4 -0
- package/dist/provider/AthenaProvider.js.map +1 -1
- package/dist/react/package.json.cjs +1 -1
- package/dist/react/package.json.js +1 -1
- package/dist/threads/api.cjs.map +1 -1
- package/dist/threads/api.d.ts +15 -0
- package/dist/threads/api.js.map +1 -1
- package/dist/threads/conversation-list-fetcher.cjs +13 -5
- package/dist/threads/conversation-list-fetcher.cjs.map +1 -1
- package/dist/threads/conversation-list-fetcher.d.ts +4 -1
- package/dist/threads/conversation-list-fetcher.js +13 -5
- package/dist/threads/conversation-list-fetcher.js.map +1 -1
- package/dist/threads/statewire-thread-list.cjs +28 -6
- package/dist/threads/statewire-thread-list.cjs.map +1 -1
- package/dist/threads/statewire-thread-list.d.ts +20 -8
- package/dist/threads/statewire-thread-list.js +28 -6
- package/dist/threads/statewire-thread-list.js.map +1 -1
- package/dist/threads/use-athena-sessions.cjs +27 -5
- package/dist/threads/use-athena-sessions.cjs.map +1 -1
- package/dist/threads/use-athena-sessions.d.ts +10 -5
- package/dist/threads/use-athena-sessions.js +28 -6
- package/dist/threads/use-athena-sessions.js.map +1 -1
- package/dist/threads/use-rest-conversation-history.d.ts +1 -0
- package/package.json +1 -1
|
@@ -43,8 +43,14 @@ export interface StartThreadHandoffInput<D extends ThreadHandoffDestination = Se
|
|
|
43
43
|
destination: D;
|
|
44
44
|
officeLaunchMode?: OfficeLaunchMode;
|
|
45
45
|
expectedWorkspaceId?: string;
|
|
46
|
-
/**
|
|
47
|
-
|
|
46
|
+
/**
|
|
47
|
+
* What the source's composer holds, read at the click: the destination
|
|
48
|
+
* prefills it and never sends it. Pass it as read, blank included: a blank
|
|
49
|
+
* composer clears the draft of an open ticket the server reuses, and an
|
|
50
|
+
* Office pane never gets one ({@link handoffDraftToCarry}). `null` (or
|
|
51
|
+
* omitted) means the surface could not read a composer.
|
|
52
|
+
*/
|
|
53
|
+
draft?: string | null;
|
|
48
54
|
/** `voice` offers "Continue by voice" on the destination; omitted means text. */
|
|
49
55
|
mode?: HandoffMode;
|
|
50
56
|
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
|
+
const VOICE_MOVE_EARCON_SAMPLE_RATE = 24e3;
|
|
4
|
+
const VOICE_MOVE_EARCON_MS = 300;
|
|
5
|
+
const VOICE_MOVE_EARCON_PEAK = 0.1;
|
|
6
|
+
const NOTES = [
|
|
7
|
+
{ hz: 587.33, startMs: 0, durationMs: 170 },
|
|
8
|
+
{ hz: 880, startMs: 100, durationMs: 200 }
|
|
9
|
+
];
|
|
10
|
+
const ATTACK_MS = 10;
|
|
11
|
+
const OVERTONE_GAIN = 0.18;
|
|
12
|
+
let samplesCache = null;
|
|
13
|
+
let dataUriCache = null;
|
|
14
|
+
const voiceMoveEarconSamples = () => {
|
|
15
|
+
if (samplesCache) return samplesCache;
|
|
16
|
+
const rate = VOICE_MOVE_EARCON_SAMPLE_RATE;
|
|
17
|
+
const samples = new Float32Array(Math.round(VOICE_MOVE_EARCON_MS / 1e3 * rate));
|
|
18
|
+
for (const note of NOTES) {
|
|
19
|
+
const start = Math.round(note.startMs / 1e3 * rate);
|
|
20
|
+
const length = Math.round(note.durationMs / 1e3 * rate);
|
|
21
|
+
const attack = Math.round(ATTACK_MS / 1e3 * rate);
|
|
22
|
+
for (let i = 0; i < length && start + i < samples.length; i += 1) {
|
|
23
|
+
const t = i / rate;
|
|
24
|
+
const decay = (1 - i / length) ** 2;
|
|
25
|
+
const envelope = i < attack ? i / attack * decay : decay;
|
|
26
|
+
const phase = 2 * Math.PI * note.hz * t;
|
|
27
|
+
samples[start + i] += envelope * (Math.sin(phase) + OVERTONE_GAIN * Math.sin(2 * phase));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
let peak = 0;
|
|
31
|
+
for (const sample of samples) peak = Math.max(peak, Math.abs(sample));
|
|
32
|
+
const scale = peak > 0 ? VOICE_MOVE_EARCON_PEAK / peak : 0;
|
|
33
|
+
for (let i = 0; i < samples.length; i += 1) samples[i] *= scale;
|
|
34
|
+
samplesCache = samples;
|
|
35
|
+
return samples;
|
|
36
|
+
};
|
|
37
|
+
const voiceMoveEarconWav = () => {
|
|
38
|
+
const samples = voiceMoveEarconSamples();
|
|
39
|
+
const dataBytes = samples.length * 2;
|
|
40
|
+
const bytes = new Uint8Array(44 + dataBytes);
|
|
41
|
+
const view = new DataView(bytes.buffer);
|
|
42
|
+
const ascii = (offset, text) => {
|
|
43
|
+
for (let i = 0; i < text.length; i += 1) view.setUint8(offset + i, text.charCodeAt(i));
|
|
44
|
+
};
|
|
45
|
+
ascii(0, "RIFF");
|
|
46
|
+
view.setUint32(4, 36 + dataBytes, true);
|
|
47
|
+
ascii(8, "WAVE");
|
|
48
|
+
ascii(12, "fmt ");
|
|
49
|
+
view.setUint32(16, 16, true);
|
|
50
|
+
view.setUint16(20, 1, true);
|
|
51
|
+
view.setUint16(22, 1, true);
|
|
52
|
+
view.setUint32(24, VOICE_MOVE_EARCON_SAMPLE_RATE, true);
|
|
53
|
+
view.setUint32(28, VOICE_MOVE_EARCON_SAMPLE_RATE * 2, true);
|
|
54
|
+
view.setUint16(32, 2, true);
|
|
55
|
+
view.setUint16(34, 16, true);
|
|
56
|
+
ascii(36, "data");
|
|
57
|
+
view.setUint32(40, dataBytes, true);
|
|
58
|
+
for (let i = 0; i < samples.length; i += 1) {
|
|
59
|
+
const clamped = Math.max(-1, Math.min(1, samples[i] ?? 0));
|
|
60
|
+
view.setInt16(44 + i * 2, Math.round(clamped * 32767), true);
|
|
61
|
+
}
|
|
62
|
+
return bytes;
|
|
63
|
+
};
|
|
64
|
+
const BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
65
|
+
const base64 = (bytes) => {
|
|
66
|
+
let out = "";
|
|
67
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
68
|
+
const a = bytes[i] ?? 0;
|
|
69
|
+
const b = bytes[i + 1];
|
|
70
|
+
const c = bytes[i + 2];
|
|
71
|
+
const triple = a << 16 | (b ?? 0) << 8 | (c ?? 0);
|
|
72
|
+
out += BASE64[triple >> 18 & 63];
|
|
73
|
+
out += BASE64[triple >> 12 & 63];
|
|
74
|
+
out += b === void 0 ? "=" : BASE64[triple >> 6 & 63];
|
|
75
|
+
out += c === void 0 ? "=" : BASE64[triple & 63];
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
};
|
|
79
|
+
const voiceMoveEarconDataUri = () => {
|
|
80
|
+
dataUriCache ?? (dataUriCache = `data:audio/wav;base64,${base64(voiceMoveEarconWav())}`);
|
|
81
|
+
return dataUriCache;
|
|
82
|
+
};
|
|
83
|
+
const earconGlobals = globalThis;
|
|
84
|
+
const PLAYBACK_BOUND_MS = VOICE_MOVE_EARCON_MS + 400;
|
|
85
|
+
const playVoiceMoveEarcon = () => new Promise((resolve) => {
|
|
86
|
+
var _a;
|
|
87
|
+
let finished = false;
|
|
88
|
+
let release = () => void 0;
|
|
89
|
+
const finish = () => {
|
|
90
|
+
if (finished) return;
|
|
91
|
+
finished = true;
|
|
92
|
+
clearTimeout(bound);
|
|
93
|
+
try {
|
|
94
|
+
release();
|
|
95
|
+
} catch {
|
|
96
|
+
}
|
|
97
|
+
resolve();
|
|
98
|
+
};
|
|
99
|
+
const bound = setTimeout(finish, PLAYBACK_BOUND_MS);
|
|
100
|
+
try {
|
|
101
|
+
const AudioContextCtor = earconGlobals.AudioContext ?? earconGlobals.webkitAudioContext;
|
|
102
|
+
if (AudioContextCtor) {
|
|
103
|
+
const context = new AudioContextCtor();
|
|
104
|
+
release = () => void context.close().catch(() => void 0);
|
|
105
|
+
const samples = voiceMoveEarconSamples();
|
|
106
|
+
const buffer = context.createBuffer(1, samples.length, VOICE_MOVE_EARCON_SAMPLE_RATE);
|
|
107
|
+
buffer.getChannelData(0).set(samples);
|
|
108
|
+
const source = context.createBufferSource();
|
|
109
|
+
source.buffer = buffer;
|
|
110
|
+
source.connect(context.destination);
|
|
111
|
+
source.onended = finish;
|
|
112
|
+
if (context.state === "suspended") void ((_a = context.resume) == null ? void 0 : _a.call(context).catch(() => void 0));
|
|
113
|
+
source.start();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const AudioCtor = earconGlobals.Audio;
|
|
117
|
+
if (AudioCtor) {
|
|
118
|
+
const element = new AudioCtor(voiceMoveEarconDataUri());
|
|
119
|
+
element.volume = 1;
|
|
120
|
+
element.onended = finish;
|
|
121
|
+
release = () => {
|
|
122
|
+
element.onended = null;
|
|
123
|
+
};
|
|
124
|
+
void Promise.resolve(element.play()).catch(finish);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
finish();
|
|
128
|
+
} catch {
|
|
129
|
+
finish();
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
exports.VOICE_MOVE_EARCON_MS = VOICE_MOVE_EARCON_MS;
|
|
133
|
+
exports.VOICE_MOVE_EARCON_PEAK = VOICE_MOVE_EARCON_PEAK;
|
|
134
|
+
exports.VOICE_MOVE_EARCON_SAMPLE_RATE = VOICE_MOVE_EARCON_SAMPLE_RATE;
|
|
135
|
+
exports.playVoiceMoveEarcon = playVoiceMoveEarcon;
|
|
136
|
+
exports.voiceMoveEarconDataUri = voiceMoveEarconDataUri;
|
|
137
|
+
exports.voiceMoveEarconSamples = voiceMoveEarconSamples;
|
|
138
|
+
exports.voiceMoveEarconWav = voiceMoveEarconWav;
|
|
139
|
+
//# sourceMappingURL=voice-earcon.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"voice-earcon.cjs","sources":["../../../../agent-runtime/src/voice-earcon.ts"],"sourcesContent":["/**\n * The earcon a call makes when it moves between a person's own devices: two\n * soft rising notes, played on the device that now holds the call and on the\n * device it left. One sound for every app, defined here once: the samples are\n * synthesized (no binary asset to bundle per host), web hosts play them\n * through WebAudio ({@link playVoiceMoveEarcon}), and native hosts play the\n * same sound as a WAV ({@link voiceMoveEarconDataUri}) through their own audio\n * session. Platform-free at import: nothing here touches a browser global\n * until a web host asks it to play.\n */\n\n/** Sample rate of the earcon (Hz); the realtime voice path's own rate. */\nexport const VOICE_MOVE_EARCON_SAMPLE_RATE = 24_000;\n\n/** How long the earcon lasts, tail included (ms). Short enough to never talk over a turn. */\nexport const VOICE_MOVE_EARCON_MS = 300;\n\n/**\n * The earcon's loudest sample (linear, full scale = 1): about -20 dBFS, well\n * under the agent's speech, so it marks the move without startling anyone.\n */\nexport const VOICE_MOVE_EARCON_PEAK = 0.1;\n\n/** The two notes: D5, then A5 over its tail (a fifth up, read as \"arrived\"). */\nconst NOTES = [\n { hz: 587.33, startMs: 0, durationMs: 170 },\n { hz: 880, startMs: 100, durationMs: 200 },\n] as const;\n/** A soft onset (no click) and a quiet octave for warmth. */\nconst ATTACK_MS = 10;\nconst OVERTONE_GAIN = 0.18;\n\nlet samplesCache: Float32Array | null = null;\nlet dataUriCache: string | null = null;\n\n/** The earcon as mono float samples at {@link VOICE_MOVE_EARCON_SAMPLE_RATE}. Do not mutate. */\nexport const voiceMoveEarconSamples = (): Float32Array => {\n if (samplesCache) return samplesCache;\n const rate = VOICE_MOVE_EARCON_SAMPLE_RATE;\n const samples = new Float32Array(Math.round((VOICE_MOVE_EARCON_MS / 1000) * rate));\n for (const note of NOTES) {\n const start = Math.round((note.startMs / 1000) * rate);\n const length = Math.round((note.durationMs / 1000) * rate);\n const attack = Math.round((ATTACK_MS / 1000) * rate);\n for (let i = 0; i < length && start + i < samples.length; i += 1) {\n const t = i / rate;\n // Linear attack, then a squared decay that lands on exactly zero.\n const decay = (1 - i / length) ** 2;\n const envelope = i < attack ? (i / attack) * decay : decay;\n const phase = 2 * Math.PI * note.hz * t;\n samples[start + i] += envelope * (Math.sin(phase) + OVERTONE_GAIN * Math.sin(2 * phase));\n }\n }\n let peak = 0;\n for (const sample of samples) peak = Math.max(peak, Math.abs(sample));\n const scale = peak > 0 ? VOICE_MOVE_EARCON_PEAK / peak : 0;\n for (let i = 0; i < samples.length; i += 1) samples[i] *= scale;\n samplesCache = samples;\n return samples;\n};\n\n/** The earcon as a 16-bit PCM mono WAV file. */\nexport const voiceMoveEarconWav = (): Uint8Array => {\n const samples = voiceMoveEarconSamples();\n const dataBytes = samples.length * 2;\n const bytes = new Uint8Array(44 + dataBytes);\n const view = new DataView(bytes.buffer);\n const ascii = (offset: number, text: string): void => {\n for (let i = 0; i < text.length; i += 1) view.setUint8(offset + i, text.charCodeAt(i));\n };\n ascii(0, 'RIFF');\n view.setUint32(4, 36 + dataBytes, true);\n ascii(8, 'WAVE');\n ascii(12, 'fmt ');\n view.setUint32(16, 16, true);\n view.setUint16(20, 1, true); // PCM\n view.setUint16(22, 1, true); // mono\n view.setUint32(24, VOICE_MOVE_EARCON_SAMPLE_RATE, true);\n view.setUint32(28, VOICE_MOVE_EARCON_SAMPLE_RATE * 2, true);\n view.setUint16(32, 2, true);\n view.setUint16(34, 16, true);\n ascii(36, 'data');\n view.setUint32(40, dataBytes, true);\n for (let i = 0; i < samples.length; i += 1) {\n const clamped = Math.max(-1, Math.min(1, samples[i] ?? 0));\n view.setInt16(44 + i * 2, Math.round(clamped * 0x7fff), true);\n }\n return bytes;\n};\n\nconst BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';\n\n/** Standard base64, with no dependency on `btoa` or `Buffer` (React Native has neither for sure). */\nconst base64 = (bytes: Uint8Array): string => {\n let out = '';\n for (let i = 0; i < bytes.length; i += 3) {\n const a = bytes[i] ?? 0;\n const b = bytes[i + 1];\n const c = bytes[i + 2];\n const triple = (a << 16) | ((b ?? 0) << 8) | (c ?? 0);\n out += BASE64[(triple >> 18) & 63];\n out += BASE64[(triple >> 12) & 63];\n out += b === undefined ? '=' : BASE64[(triple >> 6) & 63];\n out += c === undefined ? '=' : BASE64[triple & 63];\n }\n return out;\n};\n\n/**\n * The earcon WAV as a `data:audio/wav;base64,…` URI, for a native player that\n * takes a source URI (expo-audio writes it to a temporary file on iOS and\n * reads it directly on Android and the web).\n */\nexport const voiceMoveEarconDataUri = (): string => {\n dataUriCache ??= `data:audio/wav;base64,${base64(voiceMoveEarconWav())}`;\n return dataUriCache;\n};\n\ninterface EarconAudioNode {\n connect(destination: EarconAudioNode): unknown;\n}\ninterface EarconAudioBuffer {\n getChannelData(channel: number): Float32Array;\n}\ninterface EarconBufferSource extends EarconAudioNode {\n buffer: EarconAudioBuffer | null;\n onended: (() => void) | null;\n start(when?: number): void;\n}\ninterface EarconAudioContext {\n readonly state: string;\n readonly destination: EarconAudioNode;\n createBuffer(channels: number, length: number, sampleRate: number): EarconAudioBuffer;\n createBufferSource(): EarconBufferSource;\n resume?(): Promise<void>;\n close(): Promise<void>;\n}\ninterface EarconAudioElement {\n volume: number;\n onended: (() => void) | null;\n play(): Promise<void> | void;\n}\n\nconst earconGlobals = globalThis as {\n AudioContext?: new () => EarconAudioContext;\n webkitAudioContext?: new () => EarconAudioContext;\n Audio?: new (src?: string) => EarconAudioElement;\n};\n\n/** A cue that never ends (a blocked autoplay) still lets go this long after it started. */\nconst PLAYBACK_BOUND_MS = VOICE_MOVE_EARCON_MS + 400;\n\n/**\n * Play the earcon on a web host (Spaces, the Chrome side panel, the SDK the\n * desktop app renders): its own short-lived `AudioContext`, apart from the\n * call's media, or an `<audio>` element where WebAudio is missing. Resolves\n * once it has played, or within {@link PLAYBACK_BOUND_MS} when the page may\n * not play audio yet (autoplay policy). Never throws; a no-op where neither\n * exists (React Native, tests).\n */\nexport const playVoiceMoveEarcon = (): Promise<void> =>\n new Promise<void>((resolve) => {\n let finished = false;\n let release: () => void = () => undefined;\n const finish = (): void => {\n if (finished) return;\n finished = true;\n clearTimeout(bound);\n try {\n release();\n } catch {\n // Already closed.\n }\n resolve();\n };\n const bound = setTimeout(finish, PLAYBACK_BOUND_MS);\n try {\n const AudioContextCtor = earconGlobals.AudioContext ?? earconGlobals.webkitAudioContext;\n if (AudioContextCtor) {\n const context = new AudioContextCtor();\n release = () => void context.close().catch(() => undefined);\n const samples = voiceMoveEarconSamples();\n const buffer = context.createBuffer(1, samples.length, VOICE_MOVE_EARCON_SAMPLE_RATE);\n buffer.getChannelData(0).set(samples);\n const source = context.createBufferSource();\n source.buffer = buffer;\n source.connect(context.destination);\n source.onended = finish;\n if (context.state === 'suspended') void context.resume?.().catch(() => undefined);\n source.start();\n return;\n }\n const AudioCtor = earconGlobals.Audio;\n if (AudioCtor) {\n const element = new AudioCtor(voiceMoveEarconDataUri());\n element.volume = 1;\n element.onended = finish;\n release = () => {\n element.onended = null;\n };\n void Promise.resolve(element.play()).catch(finish);\n return;\n }\n finish();\n } catch {\n finish();\n }\n });\n"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The earcon a call makes when it moves between a person's own devices: two
|
|
3
|
+
* soft rising notes, played on the device that now holds the call and on the
|
|
4
|
+
* device it left. One sound for every app, defined here once: the samples are
|
|
5
|
+
* synthesized (no binary asset to bundle per host), web hosts play them
|
|
6
|
+
* through WebAudio ({@link playVoiceMoveEarcon}), and native hosts play the
|
|
7
|
+
* same sound as a WAV ({@link voiceMoveEarconDataUri}) through their own audio
|
|
8
|
+
* session. Platform-free at import: nothing here touches a browser global
|
|
9
|
+
* until a web host asks it to play.
|
|
10
|
+
*/
|
|
11
|
+
/** Sample rate of the earcon (Hz); the realtime voice path's own rate. */
|
|
12
|
+
export declare const VOICE_MOVE_EARCON_SAMPLE_RATE = 24000;
|
|
13
|
+
/** How long the earcon lasts, tail included (ms). Short enough to never talk over a turn. */
|
|
14
|
+
export declare const VOICE_MOVE_EARCON_MS = 300;
|
|
15
|
+
/**
|
|
16
|
+
* The earcon's loudest sample (linear, full scale = 1): about -20 dBFS, well
|
|
17
|
+
* under the agent's speech, so it marks the move without startling anyone.
|
|
18
|
+
*/
|
|
19
|
+
export declare const VOICE_MOVE_EARCON_PEAK = 0.1;
|
|
20
|
+
/** The earcon as mono float samples at {@link VOICE_MOVE_EARCON_SAMPLE_RATE}. Do not mutate. */
|
|
21
|
+
export declare const voiceMoveEarconSamples: () => Float32Array;
|
|
22
|
+
/** The earcon as a 16-bit PCM mono WAV file. */
|
|
23
|
+
export declare const voiceMoveEarconWav: () => Uint8Array;
|
|
24
|
+
/**
|
|
25
|
+
* The earcon WAV as a `data:audio/wav;base64,…` URI, for a native player that
|
|
26
|
+
* takes a source URI (expo-audio writes it to a temporary file on iOS and
|
|
27
|
+
* reads it directly on Android and the web).
|
|
28
|
+
*/
|
|
29
|
+
export declare const voiceMoveEarconDataUri: () => string;
|
|
30
|
+
/**
|
|
31
|
+
* Play the earcon on a web host (Spaces, the Chrome side panel, the SDK the
|
|
32
|
+
* desktop app renders): its own short-lived `AudioContext`, apart from the
|
|
33
|
+
* call's media, or an `<audio>` element where WebAudio is missing. Resolves
|
|
34
|
+
* once it has played, or within {@link PLAYBACK_BOUND_MS} when the page may
|
|
35
|
+
* not play audio yet (autoplay policy). Never throws; a no-op where neither
|
|
36
|
+
* exists (React Native, tests).
|
|
37
|
+
*/
|
|
38
|
+
export declare const playVoiceMoveEarcon: () => Promise<void>;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
const VOICE_MOVE_EARCON_SAMPLE_RATE = 24e3;
|
|
2
|
+
const VOICE_MOVE_EARCON_MS = 300;
|
|
3
|
+
const VOICE_MOVE_EARCON_PEAK = 0.1;
|
|
4
|
+
const NOTES = [
|
|
5
|
+
{ hz: 587.33, startMs: 0, durationMs: 170 },
|
|
6
|
+
{ hz: 880, startMs: 100, durationMs: 200 }
|
|
7
|
+
];
|
|
8
|
+
const ATTACK_MS = 10;
|
|
9
|
+
const OVERTONE_GAIN = 0.18;
|
|
10
|
+
let samplesCache = null;
|
|
11
|
+
let dataUriCache = null;
|
|
12
|
+
const voiceMoveEarconSamples = () => {
|
|
13
|
+
if (samplesCache) return samplesCache;
|
|
14
|
+
const rate = VOICE_MOVE_EARCON_SAMPLE_RATE;
|
|
15
|
+
const samples = new Float32Array(Math.round(VOICE_MOVE_EARCON_MS / 1e3 * rate));
|
|
16
|
+
for (const note of NOTES) {
|
|
17
|
+
const start = Math.round(note.startMs / 1e3 * rate);
|
|
18
|
+
const length = Math.round(note.durationMs / 1e3 * rate);
|
|
19
|
+
const attack = Math.round(ATTACK_MS / 1e3 * rate);
|
|
20
|
+
for (let i = 0; i < length && start + i < samples.length; i += 1) {
|
|
21
|
+
const t = i / rate;
|
|
22
|
+
const decay = (1 - i / length) ** 2;
|
|
23
|
+
const envelope = i < attack ? i / attack * decay : decay;
|
|
24
|
+
const phase = 2 * Math.PI * note.hz * t;
|
|
25
|
+
samples[start + i] += envelope * (Math.sin(phase) + OVERTONE_GAIN * Math.sin(2 * phase));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
let peak = 0;
|
|
29
|
+
for (const sample of samples) peak = Math.max(peak, Math.abs(sample));
|
|
30
|
+
const scale = peak > 0 ? VOICE_MOVE_EARCON_PEAK / peak : 0;
|
|
31
|
+
for (let i = 0; i < samples.length; i += 1) samples[i] *= scale;
|
|
32
|
+
samplesCache = samples;
|
|
33
|
+
return samples;
|
|
34
|
+
};
|
|
35
|
+
const voiceMoveEarconWav = () => {
|
|
36
|
+
const samples = voiceMoveEarconSamples();
|
|
37
|
+
const dataBytes = samples.length * 2;
|
|
38
|
+
const bytes = new Uint8Array(44 + dataBytes);
|
|
39
|
+
const view = new DataView(bytes.buffer);
|
|
40
|
+
const ascii = (offset, text) => {
|
|
41
|
+
for (let i = 0; i < text.length; i += 1) view.setUint8(offset + i, text.charCodeAt(i));
|
|
42
|
+
};
|
|
43
|
+
ascii(0, "RIFF");
|
|
44
|
+
view.setUint32(4, 36 + dataBytes, true);
|
|
45
|
+
ascii(8, "WAVE");
|
|
46
|
+
ascii(12, "fmt ");
|
|
47
|
+
view.setUint32(16, 16, true);
|
|
48
|
+
view.setUint16(20, 1, true);
|
|
49
|
+
view.setUint16(22, 1, true);
|
|
50
|
+
view.setUint32(24, VOICE_MOVE_EARCON_SAMPLE_RATE, true);
|
|
51
|
+
view.setUint32(28, VOICE_MOVE_EARCON_SAMPLE_RATE * 2, true);
|
|
52
|
+
view.setUint16(32, 2, true);
|
|
53
|
+
view.setUint16(34, 16, true);
|
|
54
|
+
ascii(36, "data");
|
|
55
|
+
view.setUint32(40, dataBytes, true);
|
|
56
|
+
for (let i = 0; i < samples.length; i += 1) {
|
|
57
|
+
const clamped = Math.max(-1, Math.min(1, samples[i] ?? 0));
|
|
58
|
+
view.setInt16(44 + i * 2, Math.round(clamped * 32767), true);
|
|
59
|
+
}
|
|
60
|
+
return bytes;
|
|
61
|
+
};
|
|
62
|
+
const BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
63
|
+
const base64 = (bytes) => {
|
|
64
|
+
let out = "";
|
|
65
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
66
|
+
const a = bytes[i] ?? 0;
|
|
67
|
+
const b = bytes[i + 1];
|
|
68
|
+
const c = bytes[i + 2];
|
|
69
|
+
const triple = a << 16 | (b ?? 0) << 8 | (c ?? 0);
|
|
70
|
+
out += BASE64[triple >> 18 & 63];
|
|
71
|
+
out += BASE64[triple >> 12 & 63];
|
|
72
|
+
out += b === void 0 ? "=" : BASE64[triple >> 6 & 63];
|
|
73
|
+
out += c === void 0 ? "=" : BASE64[triple & 63];
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
};
|
|
77
|
+
const voiceMoveEarconDataUri = () => {
|
|
78
|
+
dataUriCache ?? (dataUriCache = `data:audio/wav;base64,${base64(voiceMoveEarconWav())}`);
|
|
79
|
+
return dataUriCache;
|
|
80
|
+
};
|
|
81
|
+
const earconGlobals = globalThis;
|
|
82
|
+
const PLAYBACK_BOUND_MS = VOICE_MOVE_EARCON_MS + 400;
|
|
83
|
+
const playVoiceMoveEarcon = () => new Promise((resolve) => {
|
|
84
|
+
var _a;
|
|
85
|
+
let finished = false;
|
|
86
|
+
let release = () => void 0;
|
|
87
|
+
const finish = () => {
|
|
88
|
+
if (finished) return;
|
|
89
|
+
finished = true;
|
|
90
|
+
clearTimeout(bound);
|
|
91
|
+
try {
|
|
92
|
+
release();
|
|
93
|
+
} catch {
|
|
94
|
+
}
|
|
95
|
+
resolve();
|
|
96
|
+
};
|
|
97
|
+
const bound = setTimeout(finish, PLAYBACK_BOUND_MS);
|
|
98
|
+
try {
|
|
99
|
+
const AudioContextCtor = earconGlobals.AudioContext ?? earconGlobals.webkitAudioContext;
|
|
100
|
+
if (AudioContextCtor) {
|
|
101
|
+
const context = new AudioContextCtor();
|
|
102
|
+
release = () => void context.close().catch(() => void 0);
|
|
103
|
+
const samples = voiceMoveEarconSamples();
|
|
104
|
+
const buffer = context.createBuffer(1, samples.length, VOICE_MOVE_EARCON_SAMPLE_RATE);
|
|
105
|
+
buffer.getChannelData(0).set(samples);
|
|
106
|
+
const source = context.createBufferSource();
|
|
107
|
+
source.buffer = buffer;
|
|
108
|
+
source.connect(context.destination);
|
|
109
|
+
source.onended = finish;
|
|
110
|
+
if (context.state === "suspended") void ((_a = context.resume) == null ? void 0 : _a.call(context).catch(() => void 0));
|
|
111
|
+
source.start();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const AudioCtor = earconGlobals.Audio;
|
|
115
|
+
if (AudioCtor) {
|
|
116
|
+
const element = new AudioCtor(voiceMoveEarconDataUri());
|
|
117
|
+
element.volume = 1;
|
|
118
|
+
element.onended = finish;
|
|
119
|
+
release = () => {
|
|
120
|
+
element.onended = null;
|
|
121
|
+
};
|
|
122
|
+
void Promise.resolve(element.play()).catch(finish);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
finish();
|
|
126
|
+
} catch {
|
|
127
|
+
finish();
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
export {
|
|
131
|
+
VOICE_MOVE_EARCON_MS,
|
|
132
|
+
VOICE_MOVE_EARCON_PEAK,
|
|
133
|
+
VOICE_MOVE_EARCON_SAMPLE_RATE,
|
|
134
|
+
playVoiceMoveEarcon,
|
|
135
|
+
voiceMoveEarconDataUri,
|
|
136
|
+
voiceMoveEarconSamples,
|
|
137
|
+
voiceMoveEarconWav
|
|
138
|
+
};
|
|
139
|
+
//# sourceMappingURL=voice-earcon.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"voice-earcon.js","sources":["../../../../agent-runtime/src/voice-earcon.ts"],"sourcesContent":["/**\n * The earcon a call makes when it moves between a person's own devices: two\n * soft rising notes, played on the device that now holds the call and on the\n * device it left. One sound for every app, defined here once: the samples are\n * synthesized (no binary asset to bundle per host), web hosts play them\n * through WebAudio ({@link playVoiceMoveEarcon}), and native hosts play the\n * same sound as a WAV ({@link voiceMoveEarconDataUri}) through their own audio\n * session. Platform-free at import: nothing here touches a browser global\n * until a web host asks it to play.\n */\n\n/** Sample rate of the earcon (Hz); the realtime voice path's own rate. */\nexport const VOICE_MOVE_EARCON_SAMPLE_RATE = 24_000;\n\n/** How long the earcon lasts, tail included (ms). Short enough to never talk over a turn. */\nexport const VOICE_MOVE_EARCON_MS = 300;\n\n/**\n * The earcon's loudest sample (linear, full scale = 1): about -20 dBFS, well\n * under the agent's speech, so it marks the move without startling anyone.\n */\nexport const VOICE_MOVE_EARCON_PEAK = 0.1;\n\n/** The two notes: D5, then A5 over its tail (a fifth up, read as \"arrived\"). */\nconst NOTES = [\n { hz: 587.33, startMs: 0, durationMs: 170 },\n { hz: 880, startMs: 100, durationMs: 200 },\n] as const;\n/** A soft onset (no click) and a quiet octave for warmth. */\nconst ATTACK_MS = 10;\nconst OVERTONE_GAIN = 0.18;\n\nlet samplesCache: Float32Array | null = null;\nlet dataUriCache: string | null = null;\n\n/** The earcon as mono float samples at {@link VOICE_MOVE_EARCON_SAMPLE_RATE}. Do not mutate. */\nexport const voiceMoveEarconSamples = (): Float32Array => {\n if (samplesCache) return samplesCache;\n const rate = VOICE_MOVE_EARCON_SAMPLE_RATE;\n const samples = new Float32Array(Math.round((VOICE_MOVE_EARCON_MS / 1000) * rate));\n for (const note of NOTES) {\n const start = Math.round((note.startMs / 1000) * rate);\n const length = Math.round((note.durationMs / 1000) * rate);\n const attack = Math.round((ATTACK_MS / 1000) * rate);\n for (let i = 0; i < length && start + i < samples.length; i += 1) {\n const t = i / rate;\n // Linear attack, then a squared decay that lands on exactly zero.\n const decay = (1 - i / length) ** 2;\n const envelope = i < attack ? (i / attack) * decay : decay;\n const phase = 2 * Math.PI * note.hz * t;\n samples[start + i] += envelope * (Math.sin(phase) + OVERTONE_GAIN * Math.sin(2 * phase));\n }\n }\n let peak = 0;\n for (const sample of samples) peak = Math.max(peak, Math.abs(sample));\n const scale = peak > 0 ? VOICE_MOVE_EARCON_PEAK / peak : 0;\n for (let i = 0; i < samples.length; i += 1) samples[i] *= scale;\n samplesCache = samples;\n return samples;\n};\n\n/** The earcon as a 16-bit PCM mono WAV file. */\nexport const voiceMoveEarconWav = (): Uint8Array => {\n const samples = voiceMoveEarconSamples();\n const dataBytes = samples.length * 2;\n const bytes = new Uint8Array(44 + dataBytes);\n const view = new DataView(bytes.buffer);\n const ascii = (offset: number, text: string): void => {\n for (let i = 0; i < text.length; i += 1) view.setUint8(offset + i, text.charCodeAt(i));\n };\n ascii(0, 'RIFF');\n view.setUint32(4, 36 + dataBytes, true);\n ascii(8, 'WAVE');\n ascii(12, 'fmt ');\n view.setUint32(16, 16, true);\n view.setUint16(20, 1, true); // PCM\n view.setUint16(22, 1, true); // mono\n view.setUint32(24, VOICE_MOVE_EARCON_SAMPLE_RATE, true);\n view.setUint32(28, VOICE_MOVE_EARCON_SAMPLE_RATE * 2, true);\n view.setUint16(32, 2, true);\n view.setUint16(34, 16, true);\n ascii(36, 'data');\n view.setUint32(40, dataBytes, true);\n for (let i = 0; i < samples.length; i += 1) {\n const clamped = Math.max(-1, Math.min(1, samples[i] ?? 0));\n view.setInt16(44 + i * 2, Math.round(clamped * 0x7fff), true);\n }\n return bytes;\n};\n\nconst BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';\n\n/** Standard base64, with no dependency on `btoa` or `Buffer` (React Native has neither for sure). */\nconst base64 = (bytes: Uint8Array): string => {\n let out = '';\n for (let i = 0; i < bytes.length; i += 3) {\n const a = bytes[i] ?? 0;\n const b = bytes[i + 1];\n const c = bytes[i + 2];\n const triple = (a << 16) | ((b ?? 0) << 8) | (c ?? 0);\n out += BASE64[(triple >> 18) & 63];\n out += BASE64[(triple >> 12) & 63];\n out += b === undefined ? '=' : BASE64[(triple >> 6) & 63];\n out += c === undefined ? '=' : BASE64[triple & 63];\n }\n return out;\n};\n\n/**\n * The earcon WAV as a `data:audio/wav;base64,…` URI, for a native player that\n * takes a source URI (expo-audio writes it to a temporary file on iOS and\n * reads it directly on Android and the web).\n */\nexport const voiceMoveEarconDataUri = (): string => {\n dataUriCache ??= `data:audio/wav;base64,${base64(voiceMoveEarconWav())}`;\n return dataUriCache;\n};\n\ninterface EarconAudioNode {\n connect(destination: EarconAudioNode): unknown;\n}\ninterface EarconAudioBuffer {\n getChannelData(channel: number): Float32Array;\n}\ninterface EarconBufferSource extends EarconAudioNode {\n buffer: EarconAudioBuffer | null;\n onended: (() => void) | null;\n start(when?: number): void;\n}\ninterface EarconAudioContext {\n readonly state: string;\n readonly destination: EarconAudioNode;\n createBuffer(channels: number, length: number, sampleRate: number): EarconAudioBuffer;\n createBufferSource(): EarconBufferSource;\n resume?(): Promise<void>;\n close(): Promise<void>;\n}\ninterface EarconAudioElement {\n volume: number;\n onended: (() => void) | null;\n play(): Promise<void> | void;\n}\n\nconst earconGlobals = globalThis as {\n AudioContext?: new () => EarconAudioContext;\n webkitAudioContext?: new () => EarconAudioContext;\n Audio?: new (src?: string) => EarconAudioElement;\n};\n\n/** A cue that never ends (a blocked autoplay) still lets go this long after it started. */\nconst PLAYBACK_BOUND_MS = VOICE_MOVE_EARCON_MS + 400;\n\n/**\n * Play the earcon on a web host (Spaces, the Chrome side panel, the SDK the\n * desktop app renders): its own short-lived `AudioContext`, apart from the\n * call's media, or an `<audio>` element where WebAudio is missing. Resolves\n * once it has played, or within {@link PLAYBACK_BOUND_MS} when the page may\n * not play audio yet (autoplay policy). Never throws; a no-op where neither\n * exists (React Native, tests).\n */\nexport const playVoiceMoveEarcon = (): Promise<void> =>\n new Promise<void>((resolve) => {\n let finished = false;\n let release: () => void = () => undefined;\n const finish = (): void => {\n if (finished) return;\n finished = true;\n clearTimeout(bound);\n try {\n release();\n } catch {\n // Already closed.\n }\n resolve();\n };\n const bound = setTimeout(finish, PLAYBACK_BOUND_MS);\n try {\n const AudioContextCtor = earconGlobals.AudioContext ?? earconGlobals.webkitAudioContext;\n if (AudioContextCtor) {\n const context = new AudioContextCtor();\n release = () => void context.close().catch(() => undefined);\n const samples = voiceMoveEarconSamples();\n const buffer = context.createBuffer(1, samples.length, VOICE_MOVE_EARCON_SAMPLE_RATE);\n buffer.getChannelData(0).set(samples);\n const source = context.createBufferSource();\n source.buffer = buffer;\n source.connect(context.destination);\n source.onended = finish;\n if (context.state === 'suspended') void context.resume?.().catch(() => undefined);\n source.start();\n return;\n }\n const AudioCtor = earconGlobals.Audio;\n if (AudioCtor) {\n const element = new AudioCtor(voiceMoveEarconDataUri());\n element.volume = 1;\n element.onended = finish;\n release = () => {\n element.onended = null;\n };\n void Promise.resolve(element.play()).catch(finish);\n return;\n }\n finish();\n } catch {\n finish();\n }\n });\n"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"voice-media.cjs","sources":["../../../../agent-runtime/src/voice-media.ts"],"sourcesContent":["/** Capture processing shared by browser and native chat voice rooms. */\nexport const VOICE_MICROPHONE_CAPTURE = {\n echoCancellation: true,\n noiseSuppression: true,\n autoGainControl: true,\n} as const;\n\n/** `user-<user id>-<client>`: one device of the call's owner (agora's `voice_client_identity`). */\nconst VOICE_DEVICE_IDENTITY = /^(user-.+)-[^-]+$/;\n\n/**\n * Whether a remote participant of a chat voice room is another of the person's\n * own devices. The room holds the voice agent and the owner's devices, and\n * while a call moves (`voice/transfer-*`) the old and the new device are in it\n * together. A device plays the agent only: the other device's microphone is\n * the person's own voice, and on a browser's one `<audio>` sink it would also\n * take the agent's place. `false` while this device's identity is unknown.\n */\nexport function isOwnOtherVoiceDevice({\n identity,\n localIdentity,\n}: {\n identity: string;\n localIdentity: string | undefined;\n}): boolean {\n if (localIdentity === undefined || identity === localIdentity) return false;\n const person = VOICE_DEVICE_IDENTITY.exec(localIdentity)?.[1];\n return person !== undefined && VOICE_DEVICE_IDENTITY.exec(identity)?.[1] === person;\n}\n\nexport interface VoiceMediaHandlers {\n micMuted: boolean;\n signal?: AbortSignal;\n onDisconnected: (reason: string) => void;\n}\n\nexport interface VoiceMediaSession {\n setMicrophoneEnabled(enabled: boolean): Promise<void>;\n disconnect(): Promise<void>;\n}\n\n/**\n * The platform owns its room, audio sink and capture configuration. The shared\n * lifecycle owns cancellation, late capture, mute and loss of that room. No DOM\n * or LiveKit implementation is imported by this entrypoint.\n */\nexport interface VoiceMediaDriver {\n connect(): Promise<void>;\n setMicrophoneEnabled(options: {\n enabled: boolean;\n initial: boolean;\n }): Promise<unknown>;\n /** Must also release the platform's remote audio sinks. Safe to call again. */\n disconnect(): Promise<unknown>;\n onDisconnected(\n listener: (event: { reason: string; joinFailure: boolean }) => void\n ): void;\n}\n\nexport async function connectVoiceMedia({\n createDriver,\n handlers,\n}: {\n createDriver: () => VoiceMediaDriver;\n handlers: VoiceMediaHandlers;\n}): Promise<VoiceMediaSession> {\n const { signal } = handlers;\n // Hermes does not need a DOMException implementation to cancel a voice join.\n const endedError = (): Error => {\n const error = new Error('The voice session ended');\n error.name = 'AbortError';\n return error;\n };\n if (signal?.aborted) throw endedError();\n const driver = createDriver();\n let connected = false;\n let joined = false;\n let closed = false;\n let disconnecting: Promise<void> | null = null;\n let rejectEnded!: (error: Error) => void;\n const ended = new Promise<never>((_, reject) => {\n rejectEnded = reject;\n });\n const close = (): Promise<void> => {\n closed = true;\n signal?.removeEventListener('abort', onAbort);\n if (!disconnecting) {\n try {\n // Invoke synchronously so a browser's playback sinks stop at hangup.\n disconnecting = Promise.resolve(driver.disconnect()).then(\n () => undefined\n );\n } catch (error) {\n disconnecting = Promise.reject(error);\n }\n }\n return disconnecting;\n };\n const disconnectAgain = async (): Promise<void> => {\n try {\n await driver.disconnect();\n } catch {\n /* Preserve the original failure. */\n }\n };\n const onAbort = (): void => {\n rejectEnded(endedError());\n void close().catch(() => undefined);\n };\n signal?.addEventListener('abort', onAbort, { once: true });\n // A platform factory may synchronously cancel the attempt that created it.\n if (signal?.aborted) onAbort();\n driver.onDisconnected(({ reason, joinFailure }) => {\n if (closed) return;\n // Preserve connect()'s real cause when its room emits JOIN_FAILURE first.\n if (!connected && joinFailure) return;\n rejectEnded(new Error(reason));\n void close().catch(() => undefined);\n if (joined) handlers.onDisconnected(reason);\n });\n const checkOpen = (): void => {\n if (closed) throw endedError();\n };\n const joining = (async () => {\n try {\n checkOpen();\n await driver.connect();\n connected = true;\n checkOpen();\n await driver.setMicrophoneEnabled({\n enabled: !handlers.micMuted,\n initial: true,\n });\n checkOpen();\n } catch (error) {\n const alreadyClosed = closed;\n await close().catch(() => undefined);\n // An uncancellable platform capture can finish after its first close.\n if (alreadyClosed) await disconnectAgain();\n throw error;\n }\n })();\n await Promise.race([joining, ended]);\n checkOpen();\n joined = true;\n return {\n setMicrophoneEnabled: (enabled) => {\n const changing = (async () => {\n checkOpen();\n try {\n await driver.setMicrophoneEnabled({ enabled, initial: false });\n checkOpen();\n } finally {\n if (closed) await disconnectAgain();\n }\n })();\n return Promise.race([changing, ended]);\n },\n disconnect: () => {\n rejectEnded(endedError());\n return close();\n },\n };\n}\n"],"names":[],"mappings":";;AACO,MAAM,2BAA2B;AAAA,EACtC,kBAAkB;AAAA,EAClB,kBAAkB;AAAA,EAClB,iBAAiB;AACnB;AAGA,MAAM,wBAAwB;AAUvB,SAAS,sBAAsB;AAAA,EACpC;AAAA,EACA;AACF,GAGY;;AACV,MAAI,kBAAkB,UAAa,aAAa,cAAe,QAAO;AACtE,QAAM,UAAS,2BAAsB,KAAK,aAAa,MAAxC,mBAA4C;AAC3D,SAAO,WAAW,YAAa,2BAAsB,KAAK,QAAQ,MAAnC,mBAAuC,QAAO;AAC/E;
|
|
1
|
+
{"version":3,"file":"voice-media.cjs","sources":["../../../../agent-runtime/src/voice-media.ts"],"sourcesContent":["/** Capture processing shared by browser and native chat voice rooms. */\nexport const VOICE_MICROPHONE_CAPTURE = {\n echoCancellation: true,\n noiseSuppression: true,\n autoGainControl: true,\n} as const;\n\n/** `user-<user id>-<client>`: one device of the call's owner (agora's `voice_client_identity`). */\nconst VOICE_DEVICE_IDENTITY = /^(user-.+)-[^-]+$/;\n\n/**\n * Whether a remote participant of a chat voice room is another of the person's\n * own devices. The room holds the voice agent and the owner's devices, and\n * while a call moves (`voice/transfer-*`) the old and the new device are in it\n * together. A device plays the agent only: the other device's microphone is\n * the person's own voice, and on a browser's one `<audio>` sink it would also\n * take the agent's place. `false` while this device's identity is unknown.\n */\nexport function isOwnOtherVoiceDevice({\n identity,\n localIdentity,\n}: {\n identity: string;\n localIdentity: string | undefined;\n}): boolean {\n if (localIdentity === undefined || identity === localIdentity) return false;\n const person = VOICE_DEVICE_IDENTITY.exec(localIdentity)?.[1];\n return person !== undefined && VOICE_DEVICE_IDENTITY.exec(identity)?.[1] === person;\n}\n\n/**\n * What a platform's room reports about the call's media while it lasts, for\n * the surface's telemetry: the room reconnecting, the agent leaving or coming\n * back, connection quality, and periodic audio-quality samples of what the\n * person hears (`audio-quality` when an interval was degraded, one\n * `audio-summary` when the room closes). Ids-free; values are scalars.\n */\nexport type VoiceMediaEventType =\n | 'reconnecting'\n | 'signal-reconnecting'\n | 'reconnected'\n | 'connection-quality'\n | 'agent-left'\n | 'agent-joined'\n | 'agent-track-unsubscribed'\n | 'track-subscription-failed'\n | 'device-error'\n | 'audio-quality'\n | 'audio-summary';\n\nexport interface VoiceMediaEvent {\n type: VoiceMediaEventType;\n data?: Record<string, string | number | boolean | null>;\n}\n\nexport interface VoiceMediaHandlers {\n micMuted: boolean;\n signal?: AbortSignal;\n onDisconnected: (reason: string) => void;\n /** Media telemetry from the platform's room (optional; must not throw). */\n onEvent?: (event: VoiceMediaEvent) => void;\n}\n\nexport interface VoiceMediaSession {\n setMicrophoneEnabled(enabled: boolean): Promise<void>;\n disconnect(): Promise<void>;\n}\n\n/**\n * The platform owns its room, audio sink and capture configuration. The shared\n * lifecycle owns cancellation, late capture, mute and loss of that room. No DOM\n * or LiveKit implementation is imported by this entrypoint.\n */\nexport interface VoiceMediaDriver {\n connect(): Promise<void>;\n setMicrophoneEnabled(options: {\n enabled: boolean;\n initial: boolean;\n }): Promise<unknown>;\n /** Must also release the platform's remote audio sinks. Safe to call again. */\n disconnect(): Promise<unknown>;\n onDisconnected(\n listener: (event: { reason: string; joinFailure: boolean }) => void\n ): void;\n}\n\nexport async function connectVoiceMedia({\n createDriver,\n handlers,\n}: {\n createDriver: () => VoiceMediaDriver;\n handlers: VoiceMediaHandlers;\n}): Promise<VoiceMediaSession> {\n const { signal } = handlers;\n // Hermes does not need a DOMException implementation to cancel a voice join.\n const endedError = (): Error => {\n const error = new Error('The voice session ended');\n error.name = 'AbortError';\n return error;\n };\n if (signal?.aborted) throw endedError();\n const driver = createDriver();\n let connected = false;\n let joined = false;\n let closed = false;\n let disconnecting: Promise<void> | null = null;\n let rejectEnded!: (error: Error) => void;\n const ended = new Promise<never>((_, reject) => {\n rejectEnded = reject;\n });\n const close = (): Promise<void> => {\n closed = true;\n signal?.removeEventListener('abort', onAbort);\n if (!disconnecting) {\n try {\n // Invoke synchronously so a browser's playback sinks stop at hangup.\n disconnecting = Promise.resolve(driver.disconnect()).then(\n () => undefined\n );\n } catch (error) {\n disconnecting = Promise.reject(error);\n }\n }\n return disconnecting;\n };\n const disconnectAgain = async (): Promise<void> => {\n try {\n await driver.disconnect();\n } catch {\n /* Preserve the original failure. */\n }\n };\n const onAbort = (): void => {\n rejectEnded(endedError());\n void close().catch(() => undefined);\n };\n signal?.addEventListener('abort', onAbort, { once: true });\n // A platform factory may synchronously cancel the attempt that created it.\n if (signal?.aborted) onAbort();\n driver.onDisconnected(({ reason, joinFailure }) => {\n if (closed) return;\n // Preserve connect()'s real cause when its room emits JOIN_FAILURE first.\n if (!connected && joinFailure) return;\n rejectEnded(new Error(reason));\n void close().catch(() => undefined);\n if (joined) handlers.onDisconnected(reason);\n });\n const checkOpen = (): void => {\n if (closed) throw endedError();\n };\n const joining = (async () => {\n try {\n checkOpen();\n await driver.connect();\n connected = true;\n checkOpen();\n await driver.setMicrophoneEnabled({\n enabled: !handlers.micMuted,\n initial: true,\n });\n checkOpen();\n } catch (error) {\n const alreadyClosed = closed;\n await close().catch(() => undefined);\n // An uncancellable platform capture can finish after its first close.\n if (alreadyClosed) await disconnectAgain();\n throw error;\n }\n })();\n await Promise.race([joining, ended]);\n checkOpen();\n joined = true;\n return {\n setMicrophoneEnabled: (enabled) => {\n const changing = (async () => {\n checkOpen();\n try {\n await driver.setMicrophoneEnabled({ enabled, initial: false });\n checkOpen();\n } finally {\n if (closed) await disconnectAgain();\n }\n })();\n return Promise.race([changing, ended]);\n },\n disconnect: () => {\n rejectEnded(endedError());\n return close();\n },\n };\n}\n"],"names":[],"mappings":"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"}
|
|
@@ -16,10 +16,24 @@ export declare function isOwnOtherVoiceDevice({ identity, localIdentity, }: {
|
|
|
16
16
|
identity: string;
|
|
17
17
|
localIdentity: string | undefined;
|
|
18
18
|
}): boolean;
|
|
19
|
+
/**
|
|
20
|
+
* What a platform's room reports about the call's media while it lasts, for
|
|
21
|
+
* the surface's telemetry: the room reconnecting, the agent leaving or coming
|
|
22
|
+
* back, connection quality, and periodic audio-quality samples of what the
|
|
23
|
+
* person hears (`audio-quality` when an interval was degraded, one
|
|
24
|
+
* `audio-summary` when the room closes). Ids-free; values are scalars.
|
|
25
|
+
*/
|
|
26
|
+
export type VoiceMediaEventType = 'reconnecting' | 'signal-reconnecting' | 'reconnected' | 'connection-quality' | 'agent-left' | 'agent-joined' | 'agent-track-unsubscribed' | 'track-subscription-failed' | 'device-error' | 'audio-quality' | 'audio-summary';
|
|
27
|
+
export interface VoiceMediaEvent {
|
|
28
|
+
type: VoiceMediaEventType;
|
|
29
|
+
data?: Record<string, string | number | boolean | null>;
|
|
30
|
+
}
|
|
19
31
|
export interface VoiceMediaHandlers {
|
|
20
32
|
micMuted: boolean;
|
|
21
33
|
signal?: AbortSignal;
|
|
22
34
|
onDisconnected: (reason: string) => void;
|
|
35
|
+
/** Media telemetry from the platform's room (optional; must not throw). */
|
|
36
|
+
onEvent?: (event: VoiceMediaEvent) => void;
|
|
23
37
|
}
|
|
24
38
|
export interface VoiceMediaSession {
|
|
25
39
|
setMicrophoneEnabled(enabled: boolean): Promise<void>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"voice-media.js","sources":["../../../../agent-runtime/src/voice-media.ts"],"sourcesContent":["/** Capture processing shared by browser and native chat voice rooms. */\nexport const VOICE_MICROPHONE_CAPTURE = {\n echoCancellation: true,\n noiseSuppression: true,\n autoGainControl: true,\n} as const;\n\n/** `user-<user id>-<client>`: one device of the call's owner (agora's `voice_client_identity`). */\nconst VOICE_DEVICE_IDENTITY = /^(user-.+)-[^-]+$/;\n\n/**\n * Whether a remote participant of a chat voice room is another of the person's\n * own devices. The room holds the voice agent and the owner's devices, and\n * while a call moves (`voice/transfer-*`) the old and the new device are in it\n * together. A device plays the agent only: the other device's microphone is\n * the person's own voice, and on a browser's one `<audio>` sink it would also\n * take the agent's place. `false` while this device's identity is unknown.\n */\nexport function isOwnOtherVoiceDevice({\n identity,\n localIdentity,\n}: {\n identity: string;\n localIdentity: string | undefined;\n}): boolean {\n if (localIdentity === undefined || identity === localIdentity) return false;\n const person = VOICE_DEVICE_IDENTITY.exec(localIdentity)?.[1];\n return person !== undefined && VOICE_DEVICE_IDENTITY.exec(identity)?.[1] === person;\n}\n\nexport interface VoiceMediaHandlers {\n micMuted: boolean;\n signal?: AbortSignal;\n onDisconnected: (reason: string) => void;\n}\n\nexport interface VoiceMediaSession {\n setMicrophoneEnabled(enabled: boolean): Promise<void>;\n disconnect(): Promise<void>;\n}\n\n/**\n * The platform owns its room, audio sink and capture configuration. The shared\n * lifecycle owns cancellation, late capture, mute and loss of that room. No DOM\n * or LiveKit implementation is imported by this entrypoint.\n */\nexport interface VoiceMediaDriver {\n connect(): Promise<void>;\n setMicrophoneEnabled(options: {\n enabled: boolean;\n initial: boolean;\n }): Promise<unknown>;\n /** Must also release the platform's remote audio sinks. Safe to call again. */\n disconnect(): Promise<unknown>;\n onDisconnected(\n listener: (event: { reason: string; joinFailure: boolean }) => void\n ): void;\n}\n\nexport async function connectVoiceMedia({\n createDriver,\n handlers,\n}: {\n createDriver: () => VoiceMediaDriver;\n handlers: VoiceMediaHandlers;\n}): Promise<VoiceMediaSession> {\n const { signal } = handlers;\n // Hermes does not need a DOMException implementation to cancel a voice join.\n const endedError = (): Error => {\n const error = new Error('The voice session ended');\n error.name = 'AbortError';\n return error;\n };\n if (signal?.aborted) throw endedError();\n const driver = createDriver();\n let connected = false;\n let joined = false;\n let closed = false;\n let disconnecting: Promise<void> | null = null;\n let rejectEnded!: (error: Error) => void;\n const ended = new Promise<never>((_, reject) => {\n rejectEnded = reject;\n });\n const close = (): Promise<void> => {\n closed = true;\n signal?.removeEventListener('abort', onAbort);\n if (!disconnecting) {\n try {\n // Invoke synchronously so a browser's playback sinks stop at hangup.\n disconnecting = Promise.resolve(driver.disconnect()).then(\n () => undefined\n );\n } catch (error) {\n disconnecting = Promise.reject(error);\n }\n }\n return disconnecting;\n };\n const disconnectAgain = async (): Promise<void> => {\n try {\n await driver.disconnect();\n } catch {\n /* Preserve the original failure. */\n }\n };\n const onAbort = (): void => {\n rejectEnded(endedError());\n void close().catch(() => undefined);\n };\n signal?.addEventListener('abort', onAbort, { once: true });\n // A platform factory may synchronously cancel the attempt that created it.\n if (signal?.aborted) onAbort();\n driver.onDisconnected(({ reason, joinFailure }) => {\n if (closed) return;\n // Preserve connect()'s real cause when its room emits JOIN_FAILURE first.\n if (!connected && joinFailure) return;\n rejectEnded(new Error(reason));\n void close().catch(() => undefined);\n if (joined) handlers.onDisconnected(reason);\n });\n const checkOpen = (): void => {\n if (closed) throw endedError();\n };\n const joining = (async () => {\n try {\n checkOpen();\n await driver.connect();\n connected = true;\n checkOpen();\n await driver.setMicrophoneEnabled({\n enabled: !handlers.micMuted,\n initial: true,\n });\n checkOpen();\n } catch (error) {\n const alreadyClosed = closed;\n await close().catch(() => undefined);\n // An uncancellable platform capture can finish after its first close.\n if (alreadyClosed) await disconnectAgain();\n throw error;\n }\n })();\n await Promise.race([joining, ended]);\n checkOpen();\n joined = true;\n return {\n setMicrophoneEnabled: (enabled) => {\n const changing = (async () => {\n checkOpen();\n try {\n await driver.setMicrophoneEnabled({ enabled, initial: false });\n checkOpen();\n } finally {\n if (closed) await disconnectAgain();\n }\n })();\n return Promise.race([changing, ended]);\n },\n disconnect: () => {\n rejectEnded(endedError());\n return close();\n },\n };\n}\n"],"names":[],"mappings":"AACO,MAAM,2BAA2B;AAAA,EACtC,kBAAkB;AAAA,EAClB,kBAAkB;AAAA,EAClB,iBAAiB;AACnB;AAGA,MAAM,wBAAwB;AAUvB,SAAS,sBAAsB;AAAA,EACpC;AAAA,EACA;AACF,GAGY;AAvBL;AAwBL,MAAI,kBAAkB,UAAa,aAAa,cAAe,QAAO;AACtE,QAAM,UAAS,2BAAsB,KAAK,aAAa,MAAxC,mBAA4C;AAC3D,SAAO,WAAW,YAAa,2BAAsB,KAAK,QAAQ,MAAnC,mBAAuC,QAAO;AAC/E;
|
|
1
|
+
{"version":3,"file":"voice-media.js","sources":["../../../../agent-runtime/src/voice-media.ts"],"sourcesContent":["/** Capture processing shared by browser and native chat voice rooms. */\nexport const VOICE_MICROPHONE_CAPTURE = {\n echoCancellation: true,\n noiseSuppression: true,\n autoGainControl: true,\n} as const;\n\n/** `user-<user id>-<client>`: one device of the call's owner (agora's `voice_client_identity`). */\nconst VOICE_DEVICE_IDENTITY = /^(user-.+)-[^-]+$/;\n\n/**\n * Whether a remote participant of a chat voice room is another of the person's\n * own devices. The room holds the voice agent and the owner's devices, and\n * while a call moves (`voice/transfer-*`) the old and the new device are in it\n * together. A device plays the agent only: the other device's microphone is\n * the person's own voice, and on a browser's one `<audio>` sink it would also\n * take the agent's place. `false` while this device's identity is unknown.\n */\nexport function isOwnOtherVoiceDevice({\n identity,\n localIdentity,\n}: {\n identity: string;\n localIdentity: string | undefined;\n}): boolean {\n if (localIdentity === undefined || identity === localIdentity) return false;\n const person = VOICE_DEVICE_IDENTITY.exec(localIdentity)?.[1];\n return person !== undefined && VOICE_DEVICE_IDENTITY.exec(identity)?.[1] === person;\n}\n\n/**\n * What a platform's room reports about the call's media while it lasts, for\n * the surface's telemetry: the room reconnecting, the agent leaving or coming\n * back, connection quality, and periodic audio-quality samples of what the\n * person hears (`audio-quality` when an interval was degraded, one\n * `audio-summary` when the room closes). Ids-free; values are scalars.\n */\nexport type VoiceMediaEventType =\n | 'reconnecting'\n | 'signal-reconnecting'\n | 'reconnected'\n | 'connection-quality'\n | 'agent-left'\n | 'agent-joined'\n | 'agent-track-unsubscribed'\n | 'track-subscription-failed'\n | 'device-error'\n | 'audio-quality'\n | 'audio-summary';\n\nexport interface VoiceMediaEvent {\n type: VoiceMediaEventType;\n data?: Record<string, string | number | boolean | null>;\n}\n\nexport interface VoiceMediaHandlers {\n micMuted: boolean;\n signal?: AbortSignal;\n onDisconnected: (reason: string) => void;\n /** Media telemetry from the platform's room (optional; must not throw). */\n onEvent?: (event: VoiceMediaEvent) => void;\n}\n\nexport interface VoiceMediaSession {\n setMicrophoneEnabled(enabled: boolean): Promise<void>;\n disconnect(): Promise<void>;\n}\n\n/**\n * The platform owns its room, audio sink and capture configuration. The shared\n * lifecycle owns cancellation, late capture, mute and loss of that room. No DOM\n * or LiveKit implementation is imported by this entrypoint.\n */\nexport interface VoiceMediaDriver {\n connect(): Promise<void>;\n setMicrophoneEnabled(options: {\n enabled: boolean;\n initial: boolean;\n }): Promise<unknown>;\n /** Must also release the platform's remote audio sinks. Safe to call again. */\n disconnect(): Promise<unknown>;\n onDisconnected(\n listener: (event: { reason: string; joinFailure: boolean }) => void\n ): void;\n}\n\nexport async function connectVoiceMedia({\n createDriver,\n handlers,\n}: {\n createDriver: () => VoiceMediaDriver;\n handlers: VoiceMediaHandlers;\n}): Promise<VoiceMediaSession> {\n const { signal } = handlers;\n // Hermes does not need a DOMException implementation to cancel a voice join.\n const endedError = (): Error => {\n const error = new Error('The voice session ended');\n error.name = 'AbortError';\n return error;\n };\n if (signal?.aborted) throw endedError();\n const driver = createDriver();\n let connected = false;\n let joined = false;\n let closed = false;\n let disconnecting: Promise<void> | null = null;\n let rejectEnded!: (error: Error) => void;\n const ended = new Promise<never>((_, reject) => {\n rejectEnded = reject;\n });\n const close = (): Promise<void> => {\n closed = true;\n signal?.removeEventListener('abort', onAbort);\n if (!disconnecting) {\n try {\n // Invoke synchronously so a browser's playback sinks stop at hangup.\n disconnecting = Promise.resolve(driver.disconnect()).then(\n () => undefined\n );\n } catch (error) {\n disconnecting = Promise.reject(error);\n }\n }\n return disconnecting;\n };\n const disconnectAgain = async (): Promise<void> => {\n try {\n await driver.disconnect();\n } catch {\n /* Preserve the original failure. */\n }\n };\n const onAbort = (): void => {\n rejectEnded(endedError());\n void close().catch(() => undefined);\n };\n signal?.addEventListener('abort', onAbort, { once: true });\n // A platform factory may synchronously cancel the attempt that created it.\n if (signal?.aborted) onAbort();\n driver.onDisconnected(({ reason, joinFailure }) => {\n if (closed) return;\n // Preserve connect()'s real cause when its room emits JOIN_FAILURE first.\n if (!connected && joinFailure) return;\n rejectEnded(new Error(reason));\n void close().catch(() => undefined);\n if (joined) handlers.onDisconnected(reason);\n });\n const checkOpen = (): void => {\n if (closed) throw endedError();\n };\n const joining = (async () => {\n try {\n checkOpen();\n await driver.connect();\n connected = true;\n checkOpen();\n await driver.setMicrophoneEnabled({\n enabled: !handlers.micMuted,\n initial: true,\n });\n checkOpen();\n } catch (error) {\n const alreadyClosed = closed;\n await close().catch(() => undefined);\n // An uncancellable platform capture can finish after its first close.\n if (alreadyClosed) await disconnectAgain();\n throw error;\n }\n })();\n await Promise.race([joining, ended]);\n checkOpen();\n joined = true;\n return {\n setMicrophoneEnabled: (enabled) => {\n const changing = (async () => {\n checkOpen();\n try {\n await driver.setMicrophoneEnabled({ enabled, initial: false });\n checkOpen();\n } finally {\n if (closed) await disconnectAgain();\n }\n })();\n return Promise.race([changing, ended]);\n },\n disconnect: () => {\n rejectEnded(endedError());\n return close();\n },\n };\n}\n"],"names":[],"mappings":"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"}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
3
|
const core = require("@assistant-ui/core");
|
|
4
4
|
const VOICE_SOURCE_SPOKEN = "voice";
|
|
5
|
+
const VOICE_SOURCE_TYPED = "text";
|
|
5
6
|
const VOICE_SOURCE_DELEGATION = "voice_delegation";
|
|
6
7
|
const VOICE_DELEGATION_ID_PREFIX = "voicetask_";
|
|
7
8
|
const VOICE_LIVE_KEY = "voice_live";
|
|
@@ -50,6 +51,7 @@ exports.VOICE_LIVE_KEY = VOICE_LIVE_KEY;
|
|
|
50
51
|
exports.VOICE_LIVE_TYPED_HINT = VOICE_LIVE_TYPED_HINT;
|
|
51
52
|
exports.VOICE_SOURCE_DELEGATION = VOICE_SOURCE_DELEGATION;
|
|
52
53
|
exports.VOICE_SOURCE_SPOKEN = VOICE_SOURCE_SPOKEN;
|
|
54
|
+
exports.VOICE_SOURCE_TYPED = VOICE_SOURCE_TYPED;
|
|
53
55
|
exports.VOICE_TURN_IDS_KEY = VOICE_TURN_IDS_KEY;
|
|
54
56
|
exports.VOICE_UPDATE_TOOL_NAME = VOICE_UPDATE_TOOL_NAME;
|
|
55
57
|
exports.describeVoiceUpdateToolCall = describeVoiceUpdateToolCall;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"voice-presentation.cjs","sources":["../../../../agent-runtime/src/voice-presentation.ts"],"sourcesContent":["/**\n * How voice-mode transcript entries present, shared by every chat surface\n * (Olympus, the Chrome side panel, the mobile app) so a spoken turn, a\n * delegation the live voice model handed off, and an interim spoken update\n * read the same everywhere. The wire contract is the deep-agent host's\n * `additional_kwargs.metadata` (exposed as `message.metadata.custom`):\n *\n * - `source: \"voice\"` — a spoken turn; `voice_live: true` while it is still\n * being heard or spoken (the ephemeral entry the durable row replaces);\n * - `source: \"text\"` — a message typed into the live conversation;\n * - `source: \"voice_delegation\"` (id prefix `voicetask_`) — the unit of work\n * the live voice model delegated to the thread.\n */\nimport { getExternalStoreMessages, type ThreadMessage } from '@assistant-ui/core';\n\n/** `metadata.custom.source` of a spoken turn. */\nexport const VOICE_SOURCE_SPOKEN = 'voice';\n/** `metadata.custom.source` of a message typed into the live conversation. */\nexport const VOICE_SOURCE_TYPED = 'text';\n/** `metadata.custom.source` of a voice delegation's anchor message. */\nexport const VOICE_SOURCE_DELEGATION = 'voice_delegation';\n/** Id prefix of a voice delegation's anchor message (the working signal on\n * hosts that drop the command's metadata). */\nexport const VOICE_DELEGATION_ID_PREFIX = 'voicetask_';\n/** `metadata.custom` flag on a spoken turn still in flight. */\nexport const VOICE_LIVE_KEY = 'voice_live';\n/** `metadata.custom` list of the wire ids a grouped spoken bubble stands for\n * (see `groupSpokenUserTurns`), so id-keyed lookups such as checkpoint markers\n * can resolve any member to the bubble. */\nexport const VOICE_TURN_IDS_KEY = 'voice_turn_ids';\n\n/** The delegated run's interim-update tool (agora `send_voice_update`). */\nexport const VOICE_UPDATE_TOOL_NAME = 'send_voice_update';\n\n/** Trailing hint on the live-session strip, worded once for every surface. */\nexport const VOICE_LIVE_TYPED_HINT = 'typed messages go to the voice conversation';\n\nexport interface VoiceMessageMarker {\n /** The thread message id; a host may not have assigned one yet. */\n messageId: string | undefined;\n /** The thread message's `metadata.custom`, when any. */\n custom: Record<string, unknown> | undefined;\n}\n\n/** Whether a thread message is a voice delegation's anchor (the ask the live\n * voice model handed to the thread), by source or by id prefix. */\nexport function isVoiceDelegationMessage({ messageId, custom }: VoiceMessageMarker): boolean {\n return (\n custom?.source === VOICE_SOURCE_DELEGATION ||\n (typeof messageId === 'string' && messageId.startsWith(VOICE_DELEGATION_ID_PREFIX))\n );\n}\n\nexport interface VoiceProvenance {\n /** Chip copy: \"Listening\", \"Speaking\", \"Spoken\", or \"Delegated from voice\". */\n readonly label: string;\n /** True while the turn is still being heard or spoken. */\n readonly live: boolean;\n}\n\n// These values are read directly by useAuiState/useSyncExternalStore selectors.\n// Equal presentation must retain its identity, even when a transport replaces\n// the message metadata. Fresh objects here cause React's update-depth crash.\nconst SPOKEN_PROVENANCE: VoiceProvenance = Object.freeze({\n label: 'Spoken',\n live: false,\n});\nconst LISTENING_PROVENANCE: VoiceProvenance = Object.freeze({\n label: 'Listening',\n live: true,\n});\nconst SPEAKING_PROVENANCE: VoiceProvenance = Object.freeze({\n label: 'Speaking',\n live: true,\n});\nconst DELEGATED_PROVENANCE: VoiceProvenance = Object.freeze({\n label: 'Delegated from voice',\n live: false,\n});\n\n/**\n * The provenance chip for a message, or `null` for ordinary chat messages so\n * the chip is free to mount on every row. A spoken turn still in flight reads\n * as what is happening (\"Listening\" for the user, \"Speaking\" for the\n * assistant) rather than as a finished \"Spoken\" turn; a typed-in-voice turn\n * is a normal message and gets no chip. Results are immutable and stable for\n * equal presentation, so all surfaces can use this function as a selector.\n */\nexport function voiceProvenanceLabel({\n messageId,\n custom,\n role,\n}: VoiceMessageMarker & { role: 'user' | 'assistant' | string }): VoiceProvenance | null {\n const source = custom?.source;\n if (source === VOICE_SOURCE_SPOKEN) {\n if (custom?.[VOICE_LIVE_KEY] === true) {\n return role === 'user' ? LISTENING_PROVENANCE : SPEAKING_PROVENANCE;\n }\n return SPOKEN_PROVENANCE;\n }\n if (isVoiceDelegationMessage({ messageId, custom })) {\n return DELEGATED_PROVENANCE;\n }\n return null;\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n typeof value === 'object' && value !== null && !Array.isArray(value);\n\n/**\n * Whether a part of an assistant message holds the words of a spoken\n * assistant turn.\n *\n * The converter joins consecutive assistant entries into one message. The\n * host appends a live turn while it is heard, and a delegated run keeps\n * appending its own steps after it, so a turn spoken during a run becomes a\n * text part inside the run's message, before the run's later tool calls.\n * Grouped views put the text before the last tool call in the details panel\n * as the run's narration; they use this to keep the spoken words visible.\n *\n * The joined message's `metadata.custom` merges every entry in it, so this\n * reads the part's own entry, which the converter binds to the part.\n */\nexport function isSpokenTurnTextPart(part: { readonly type: string }): boolean {\n if (part.type !== 'text') return false;\n const entries: readonly unknown[] = getExternalStoreMessages<unknown>(\n part as ThreadMessage['content'][number]\n );\n return entries.some((entry) => {\n if (!isRecord(entry) || entry.role !== 'assistant' || !isRecord(entry.metadata)) return false;\n const custom = entry.metadata.custom;\n return isRecord(custom) && custom.source === VOICE_SOURCE_SPOKEN;\n });\n}\n\nexport interface VoiceUpdateToolCall {\n /** `mode: \"speak\"` — read aloud to the user; otherwise a silent progress note. */\n spoken: boolean;\n /** The update's words. */\n text: string;\n /** Whether the live session took it; `null` while the call is still running. */\n delivered: boolean | null;\n}\n\n/**\n * What a `send_voice_update` tool call did, from its arguments and the tool's\n * teaching-text result (agora returns prose, not JSON, so the run can read it).\n */\nexport function describeVoiceUpdateToolCall({\n args,\n result,\n}: {\n args: Record<string, unknown> | undefined;\n result: unknown;\n}): VoiceUpdateToolCall {\n const text = typeof args?.text === 'string' ? args.text.trim() : '';\n const spoken = args?.mode === 'speak';\n if (typeof result !== 'string' || result.trim() === '') {\n return { spoken, text, delivered: null };\n }\n const outcome = result.toLowerCase();\n const failed =\n outcome.includes('could not be delivered') ||\n outcome.includes('was not delivered') ||\n outcome.includes('rejected the update') ||\n outcome.includes('has ended') ||\n outcome.includes('not configured') ||\n outcome.includes('no voice session');\n return { spoken, text, delivered: !failed };\n}\n\n/** The card's status line for a voice update in a given tool-call state. */\nexport function voiceUpdateStatusLabel(\n call: VoiceUpdateToolCall,\n status: 'running' | 'complete' | 'incomplete' | 'requires-action' | undefined\n): string {\n if (status === 'running' || (call.delivered === null && status !== 'incomplete')) {\n return call.spoken ? 'Saying to you…' : 'Noting progress…';\n }\n if (call.delivered === false || status === 'incomplete') {\n return call.spoken ? 'Could not say this to you' : 'Progress note not delivered';\n }\n return call.spoken ? 'Said aloud' : 'Progress noted silently';\n}\n"],"names":["getExternalStoreMessages"],"mappings":";;;AAgBO,MAAM,sBAAsB;
|
|
1
|
+
{"version":3,"file":"voice-presentation.cjs","sources":["../../../../agent-runtime/src/voice-presentation.ts"],"sourcesContent":["/**\n * How voice-mode transcript entries present, shared by every chat surface\n * (Olympus, the Chrome side panel, the mobile app) so a spoken turn, a\n * delegation the live voice model handed off, and an interim spoken update\n * read the same everywhere. The wire contract is the deep-agent host's\n * `additional_kwargs.metadata` (exposed as `message.metadata.custom`):\n *\n * - `source: \"voice\"` — a spoken turn; `voice_live: true` while it is still\n * being heard or spoken (the ephemeral entry the durable row replaces);\n * - `source: \"text\"` — a message typed into the live conversation;\n * - `source: \"voice_delegation\"` (id prefix `voicetask_`) — the unit of work\n * the live voice model delegated to the thread.\n */\nimport { getExternalStoreMessages, type ThreadMessage } from '@assistant-ui/core';\n\n/** `metadata.custom.source` of a spoken turn. */\nexport const VOICE_SOURCE_SPOKEN = 'voice';\n/** `metadata.custom.source` of a message typed into the live conversation. */\nexport const VOICE_SOURCE_TYPED = 'text';\n/** `metadata.custom.source` of a voice delegation's anchor message. */\nexport const VOICE_SOURCE_DELEGATION = 'voice_delegation';\n/** Id prefix of a voice delegation's anchor message (the working signal on\n * hosts that drop the command's metadata). */\nexport const VOICE_DELEGATION_ID_PREFIX = 'voicetask_';\n/** `metadata.custom` flag on a spoken turn still in flight. */\nexport const VOICE_LIVE_KEY = 'voice_live';\n/** `metadata.custom` list of the wire ids a grouped spoken bubble stands for\n * (see `groupSpokenUserTurns`), so id-keyed lookups such as checkpoint markers\n * can resolve any member to the bubble. */\nexport const VOICE_TURN_IDS_KEY = 'voice_turn_ids';\n\n/** The delegated run's interim-update tool (agora `send_voice_update`). */\nexport const VOICE_UPDATE_TOOL_NAME = 'send_voice_update';\n\n/** Trailing hint on the live-session strip, worded once for every surface. */\nexport const VOICE_LIVE_TYPED_HINT = 'typed messages go to the voice conversation';\n\nexport interface VoiceMessageMarker {\n /** The thread message id; a host may not have assigned one yet. */\n messageId: string | undefined;\n /** The thread message's `metadata.custom`, when any. */\n custom: Record<string, unknown> | undefined;\n}\n\n/** Whether a thread message is a voice delegation's anchor (the ask the live\n * voice model handed to the thread), by source or by id prefix. */\nexport function isVoiceDelegationMessage({ messageId, custom }: VoiceMessageMarker): boolean {\n return (\n custom?.source === VOICE_SOURCE_DELEGATION ||\n (typeof messageId === 'string' && messageId.startsWith(VOICE_DELEGATION_ID_PREFIX))\n );\n}\n\nexport interface VoiceProvenance {\n /** Chip copy: \"Listening\", \"Speaking\", \"Spoken\", or \"Delegated from voice\". */\n readonly label: string;\n /** True while the turn is still being heard or spoken. */\n readonly live: boolean;\n}\n\n// These values are read directly by useAuiState/useSyncExternalStore selectors.\n// Equal presentation must retain its identity, even when a transport replaces\n// the message metadata. Fresh objects here cause React's update-depth crash.\nconst SPOKEN_PROVENANCE: VoiceProvenance = Object.freeze({\n label: 'Spoken',\n live: false,\n});\nconst LISTENING_PROVENANCE: VoiceProvenance = Object.freeze({\n label: 'Listening',\n live: true,\n});\nconst SPEAKING_PROVENANCE: VoiceProvenance = Object.freeze({\n label: 'Speaking',\n live: true,\n});\nconst DELEGATED_PROVENANCE: VoiceProvenance = Object.freeze({\n label: 'Delegated from voice',\n live: false,\n});\n\n/**\n * The provenance chip for a message, or `null` for ordinary chat messages so\n * the chip is free to mount on every row. A spoken turn still in flight reads\n * as what is happening (\"Listening\" for the user, \"Speaking\" for the\n * assistant) rather than as a finished \"Spoken\" turn; a typed-in-voice turn\n * is a normal message and gets no chip. Results are immutable and stable for\n * equal presentation, so all surfaces can use this function as a selector.\n */\nexport function voiceProvenanceLabel({\n messageId,\n custom,\n role,\n}: VoiceMessageMarker & { role: 'user' | 'assistant' | string }): VoiceProvenance | null {\n const source = custom?.source;\n if (source === VOICE_SOURCE_SPOKEN) {\n if (custom?.[VOICE_LIVE_KEY] === true) {\n return role === 'user' ? LISTENING_PROVENANCE : SPEAKING_PROVENANCE;\n }\n return SPOKEN_PROVENANCE;\n }\n if (isVoiceDelegationMessage({ messageId, custom })) {\n return DELEGATED_PROVENANCE;\n }\n return null;\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n typeof value === 'object' && value !== null && !Array.isArray(value);\n\n/**\n * Whether a part of an assistant message holds the words of a spoken\n * assistant turn.\n *\n * The converter joins consecutive assistant entries into one message. The\n * host appends a live turn while it is heard, and a delegated run keeps\n * appending its own steps after it, so a turn spoken during a run becomes a\n * text part inside the run's message, before the run's later tool calls.\n * Grouped views put the text before the last tool call in the details panel\n * as the run's narration; they use this to keep the spoken words visible.\n *\n * The joined message's `metadata.custom` merges every entry in it, so this\n * reads the part's own entry, which the converter binds to the part.\n */\nexport function isSpokenTurnTextPart(part: { readonly type: string }): boolean {\n if (part.type !== 'text') return false;\n const entries: readonly unknown[] = getExternalStoreMessages<unknown>(\n part as ThreadMessage['content'][number]\n );\n return entries.some((entry) => {\n if (!isRecord(entry) || entry.role !== 'assistant' || !isRecord(entry.metadata)) return false;\n const custom = entry.metadata.custom;\n return isRecord(custom) && custom.source === VOICE_SOURCE_SPOKEN;\n });\n}\n\nexport interface VoiceUpdateToolCall {\n /** `mode: \"speak\"` — read aloud to the user; otherwise a silent progress note. */\n spoken: boolean;\n /** The update's words. */\n text: string;\n /** Whether the live session took it; `null` while the call is still running. */\n delivered: boolean | null;\n}\n\n/**\n * What a `send_voice_update` tool call did, from its arguments and the tool's\n * teaching-text result (agora returns prose, not JSON, so the run can read it).\n */\nexport function describeVoiceUpdateToolCall({\n args,\n result,\n}: {\n args: Record<string, unknown> | undefined;\n result: unknown;\n}): VoiceUpdateToolCall {\n const text = typeof args?.text === 'string' ? args.text.trim() : '';\n const spoken = args?.mode === 'speak';\n if (typeof result !== 'string' || result.trim() === '') {\n return { spoken, text, delivered: null };\n }\n const outcome = result.toLowerCase();\n const failed =\n outcome.includes('could not be delivered') ||\n outcome.includes('was not delivered') ||\n outcome.includes('rejected the update') ||\n outcome.includes('has ended') ||\n outcome.includes('not configured') ||\n outcome.includes('no voice session');\n return { spoken, text, delivered: !failed };\n}\n\n/** The card's status line for a voice update in a given tool-call state. */\nexport function voiceUpdateStatusLabel(\n call: VoiceUpdateToolCall,\n status: 'running' | 'complete' | 'incomplete' | 'requires-action' | undefined\n): string {\n if (status === 'running' || (call.delivered === null && status !== 'incomplete')) {\n return call.spoken ? 'Saying to you…' : 'Noting progress…';\n }\n if (call.delivered === false || status === 'incomplete') {\n return call.spoken ? 'Could not say this to you' : 'Progress note not delivered';\n }\n return call.spoken ? 'Said aloud' : 'Progress noted silently';\n}\n"],"names":["getExternalStoreMessages"],"mappings":";;;AAgBO,MAAM,sBAAsB;AAE5B,MAAM,qBAAqB;AAE3B,MAAM,0BAA0B;AAGhC,MAAM,6BAA6B;AAEnC,MAAM,iBAAiB;AAIvB,MAAM,qBAAqB;AAG3B,MAAM,yBAAyB;AAG/B,MAAM,wBAAwB;AAW9B,SAAS,yBAAyB,EAAE,WAAW,UAAuC;AAC3F,UACE,iCAAQ,YAAW,2BAClB,OAAO,cAAc,YAAY,UAAU,WAAW,0BAA0B;AAErF;AAuDA,MAAM,WAAW,CAAC,UAChB,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAgB9D,SAAS,qBAAqB,MAA0C;AAC7E,MAAI,KAAK,SAAS,OAAQ,QAAO;AACjC,QAAM,UAA8BA,KAAAA;AAAAA,IAClC;AAAA,EAAA;AAEF,SAAO,QAAQ,KAAK,CAAC,UAAU;AAC7B,QAAI,CAAC,SAAS,KAAK,KAAK,MAAM,SAAS,eAAe,CAAC,SAAS,MAAM,QAAQ,EAAG,QAAO;AACxF,UAAM,SAAS,MAAM,SAAS;AAC9B,WAAO,SAAS,MAAM,KAAK,OAAO,WAAW;AAAA,EAC/C,CAAC;AACH;AAeO,SAAS,4BAA4B;AAAA,EAC1C;AAAA,EACA;AACF,GAGwB;AACtB,QAAM,OAAO,QAAO,6BAAM,UAAS,WAAW,KAAK,KAAK,SAAS;AACjE,QAAM,UAAS,6BAAM,UAAS;AAC9B,MAAI,OAAO,WAAW,YAAY,OAAO,KAAA,MAAW,IAAI;AACtD,WAAO,EAAE,QAAQ,MAAM,WAAW,KAAA;AAAA,EACpC;AACA,QAAM,UAAU,OAAO,YAAA;AACvB,QAAM,SACJ,QAAQ,SAAS,wBAAwB,KACzC,QAAQ,SAAS,mBAAmB,KACpC,QAAQ,SAAS,qBAAqB,KACtC,QAAQ,SAAS,WAAW,KAC5B,QAAQ,SAAS,gBAAgB,KACjC,QAAQ,SAAS,kBAAkB;AACrC,SAAO,EAAE,QAAQ,MAAM,WAAW,CAAC,OAAA;AACrC;AAGO,SAAS,uBACd,MACA,QACQ;AACR,MAAI,WAAW,aAAc,KAAK,cAAc,QAAQ,WAAW,cAAe;AAChF,WAAO,KAAK,SAAS,mBAAmB;AAAA,EAC1C;AACA,MAAI,KAAK,cAAc,SAAS,WAAW,cAAc;AACvD,WAAO,KAAK,SAAS,8BAA8B;AAAA,EACrD;AACA,SAAO,KAAK,SAAS,eAAe;AACtC;;;;;;;;;;;;;"}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getExternalStoreMessages } from "@assistant-ui/core";
|
|
2
2
|
const VOICE_SOURCE_SPOKEN = "voice";
|
|
3
|
+
const VOICE_SOURCE_TYPED = "text";
|
|
3
4
|
const VOICE_SOURCE_DELEGATION = "voice_delegation";
|
|
4
5
|
const VOICE_DELEGATION_ID_PREFIX = "voicetask_";
|
|
5
6
|
const VOICE_LIVE_KEY = "voice_live";
|
|
@@ -49,6 +50,7 @@ export {
|
|
|
49
50
|
VOICE_LIVE_TYPED_HINT,
|
|
50
51
|
VOICE_SOURCE_DELEGATION,
|
|
51
52
|
VOICE_SOURCE_SPOKEN,
|
|
53
|
+
VOICE_SOURCE_TYPED,
|
|
52
54
|
VOICE_TURN_IDS_KEY,
|
|
53
55
|
VOICE_UPDATE_TOOL_NAME,
|
|
54
56
|
describeVoiceUpdateToolCall,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"voice-presentation.js","sources":["../../../../agent-runtime/src/voice-presentation.ts"],"sourcesContent":["/**\n * How voice-mode transcript entries present, shared by every chat surface\n * (Olympus, the Chrome side panel, the mobile app) so a spoken turn, a\n * delegation the live voice model handed off, and an interim spoken update\n * read the same everywhere. The wire contract is the deep-agent host's\n * `additional_kwargs.metadata` (exposed as `message.metadata.custom`):\n *\n * - `source: \"voice\"` — a spoken turn; `voice_live: true` while it is still\n * being heard or spoken (the ephemeral entry the durable row replaces);\n * - `source: \"text\"` — a message typed into the live conversation;\n * - `source: \"voice_delegation\"` (id prefix `voicetask_`) — the unit of work\n * the live voice model delegated to the thread.\n */\nimport { getExternalStoreMessages, type ThreadMessage } from '@assistant-ui/core';\n\n/** `metadata.custom.source` of a spoken turn. */\nexport const VOICE_SOURCE_SPOKEN = 'voice';\n/** `metadata.custom.source` of a message typed into the live conversation. */\nexport const VOICE_SOURCE_TYPED = 'text';\n/** `metadata.custom.source` of a voice delegation's anchor message. */\nexport const VOICE_SOURCE_DELEGATION = 'voice_delegation';\n/** Id prefix of a voice delegation's anchor message (the working signal on\n * hosts that drop the command's metadata). */\nexport const VOICE_DELEGATION_ID_PREFIX = 'voicetask_';\n/** `metadata.custom` flag on a spoken turn still in flight. */\nexport const VOICE_LIVE_KEY = 'voice_live';\n/** `metadata.custom` list of the wire ids a grouped spoken bubble stands for\n * (see `groupSpokenUserTurns`), so id-keyed lookups such as checkpoint markers\n * can resolve any member to the bubble. */\nexport const VOICE_TURN_IDS_KEY = 'voice_turn_ids';\n\n/** The delegated run's interim-update tool (agora `send_voice_update`). */\nexport const VOICE_UPDATE_TOOL_NAME = 'send_voice_update';\n\n/** Trailing hint on the live-session strip, worded once for every surface. */\nexport const VOICE_LIVE_TYPED_HINT = 'typed messages go to the voice conversation';\n\nexport interface VoiceMessageMarker {\n /** The thread message id; a host may not have assigned one yet. */\n messageId: string | undefined;\n /** The thread message's `metadata.custom`, when any. */\n custom: Record<string, unknown> | undefined;\n}\n\n/** Whether a thread message is a voice delegation's anchor (the ask the live\n * voice model handed to the thread), by source or by id prefix. */\nexport function isVoiceDelegationMessage({ messageId, custom }: VoiceMessageMarker): boolean {\n return (\n custom?.source === VOICE_SOURCE_DELEGATION ||\n (typeof messageId === 'string' && messageId.startsWith(VOICE_DELEGATION_ID_PREFIX))\n );\n}\n\nexport interface VoiceProvenance {\n /** Chip copy: \"Listening\", \"Speaking\", \"Spoken\", or \"Delegated from voice\". */\n readonly label: string;\n /** True while the turn is still being heard or spoken. */\n readonly live: boolean;\n}\n\n// These values are read directly by useAuiState/useSyncExternalStore selectors.\n// Equal presentation must retain its identity, even when a transport replaces\n// the message metadata. Fresh objects here cause React's update-depth crash.\nconst SPOKEN_PROVENANCE: VoiceProvenance = Object.freeze({\n label: 'Spoken',\n live: false,\n});\nconst LISTENING_PROVENANCE: VoiceProvenance = Object.freeze({\n label: 'Listening',\n live: true,\n});\nconst SPEAKING_PROVENANCE: VoiceProvenance = Object.freeze({\n label: 'Speaking',\n live: true,\n});\nconst DELEGATED_PROVENANCE: VoiceProvenance = Object.freeze({\n label: 'Delegated from voice',\n live: false,\n});\n\n/**\n * The provenance chip for a message, or `null` for ordinary chat messages so\n * the chip is free to mount on every row. A spoken turn still in flight reads\n * as what is happening (\"Listening\" for the user, \"Speaking\" for the\n * assistant) rather than as a finished \"Spoken\" turn; a typed-in-voice turn\n * is a normal message and gets no chip. Results are immutable and stable for\n * equal presentation, so all surfaces can use this function as a selector.\n */\nexport function voiceProvenanceLabel({\n messageId,\n custom,\n role,\n}: VoiceMessageMarker & { role: 'user' | 'assistant' | string }): VoiceProvenance | null {\n const source = custom?.source;\n if (source === VOICE_SOURCE_SPOKEN) {\n if (custom?.[VOICE_LIVE_KEY] === true) {\n return role === 'user' ? LISTENING_PROVENANCE : SPEAKING_PROVENANCE;\n }\n return SPOKEN_PROVENANCE;\n }\n if (isVoiceDelegationMessage({ messageId, custom })) {\n return DELEGATED_PROVENANCE;\n }\n return null;\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n typeof value === 'object' && value !== null && !Array.isArray(value);\n\n/**\n * Whether a part of an assistant message holds the words of a spoken\n * assistant turn.\n *\n * The converter joins consecutive assistant entries into one message. The\n * host appends a live turn while it is heard, and a delegated run keeps\n * appending its own steps after it, so a turn spoken during a run becomes a\n * text part inside the run's message, before the run's later tool calls.\n * Grouped views put the text before the last tool call in the details panel\n * as the run's narration; they use this to keep the spoken words visible.\n *\n * The joined message's `metadata.custom` merges every entry in it, so this\n * reads the part's own entry, which the converter binds to the part.\n */\nexport function isSpokenTurnTextPart(part: { readonly type: string }): boolean {\n if (part.type !== 'text') return false;\n const entries: readonly unknown[] = getExternalStoreMessages<unknown>(\n part as ThreadMessage['content'][number]\n );\n return entries.some((entry) => {\n if (!isRecord(entry) || entry.role !== 'assistant' || !isRecord(entry.metadata)) return false;\n const custom = entry.metadata.custom;\n return isRecord(custom) && custom.source === VOICE_SOURCE_SPOKEN;\n });\n}\n\nexport interface VoiceUpdateToolCall {\n /** `mode: \"speak\"` — read aloud to the user; otherwise a silent progress note. */\n spoken: boolean;\n /** The update's words. */\n text: string;\n /** Whether the live session took it; `null` while the call is still running. */\n delivered: boolean | null;\n}\n\n/**\n * What a `send_voice_update` tool call did, from its arguments and the tool's\n * teaching-text result (agora returns prose, not JSON, so the run can read it).\n */\nexport function describeVoiceUpdateToolCall({\n args,\n result,\n}: {\n args: Record<string, unknown> | undefined;\n result: unknown;\n}): VoiceUpdateToolCall {\n const text = typeof args?.text === 'string' ? args.text.trim() : '';\n const spoken = args?.mode === 'speak';\n if (typeof result !== 'string' || result.trim() === '') {\n return { spoken, text, delivered: null };\n }\n const outcome = result.toLowerCase();\n const failed =\n outcome.includes('could not be delivered') ||\n outcome.includes('was not delivered') ||\n outcome.includes('rejected the update') ||\n outcome.includes('has ended') ||\n outcome.includes('not configured') ||\n outcome.includes('no voice session');\n return { spoken, text, delivered: !failed };\n}\n\n/** The card's status line for a voice update in a given tool-call state. */\nexport function voiceUpdateStatusLabel(\n call: VoiceUpdateToolCall,\n status: 'running' | 'complete' | 'incomplete' | 'requires-action' | undefined\n): string {\n if (status === 'running' || (call.delivered === null && status !== 'incomplete')) {\n return call.spoken ? 'Saying to you…' : 'Noting progress…';\n }\n if (call.delivered === false || status === 'incomplete') {\n return call.spoken ? 'Could not say this to you' : 'Progress note not delivered';\n }\n return call.spoken ? 'Said aloud' : 'Progress noted silently';\n}\n"],"names":[],"mappings":";AAgBO,MAAM,sBAAsB;
|
|
1
|
+
{"version":3,"file":"voice-presentation.js","sources":["../../../../agent-runtime/src/voice-presentation.ts"],"sourcesContent":["/**\n * How voice-mode transcript entries present, shared by every chat surface\n * (Olympus, the Chrome side panel, the mobile app) so a spoken turn, a\n * delegation the live voice model handed off, and an interim spoken update\n * read the same everywhere. The wire contract is the deep-agent host's\n * `additional_kwargs.metadata` (exposed as `message.metadata.custom`):\n *\n * - `source: \"voice\"` — a spoken turn; `voice_live: true` while it is still\n * being heard or spoken (the ephemeral entry the durable row replaces);\n * - `source: \"text\"` — a message typed into the live conversation;\n * - `source: \"voice_delegation\"` (id prefix `voicetask_`) — the unit of work\n * the live voice model delegated to the thread.\n */\nimport { getExternalStoreMessages, type ThreadMessage } from '@assistant-ui/core';\n\n/** `metadata.custom.source` of a spoken turn. */\nexport const VOICE_SOURCE_SPOKEN = 'voice';\n/** `metadata.custom.source` of a message typed into the live conversation. */\nexport const VOICE_SOURCE_TYPED = 'text';\n/** `metadata.custom.source` of a voice delegation's anchor message. */\nexport const VOICE_SOURCE_DELEGATION = 'voice_delegation';\n/** Id prefix of a voice delegation's anchor message (the working signal on\n * hosts that drop the command's metadata). */\nexport const VOICE_DELEGATION_ID_PREFIX = 'voicetask_';\n/** `metadata.custom` flag on a spoken turn still in flight. */\nexport const VOICE_LIVE_KEY = 'voice_live';\n/** `metadata.custom` list of the wire ids a grouped spoken bubble stands for\n * (see `groupSpokenUserTurns`), so id-keyed lookups such as checkpoint markers\n * can resolve any member to the bubble. */\nexport const VOICE_TURN_IDS_KEY = 'voice_turn_ids';\n\n/** The delegated run's interim-update tool (agora `send_voice_update`). */\nexport const VOICE_UPDATE_TOOL_NAME = 'send_voice_update';\n\n/** Trailing hint on the live-session strip, worded once for every surface. */\nexport const VOICE_LIVE_TYPED_HINT = 'typed messages go to the voice conversation';\n\nexport interface VoiceMessageMarker {\n /** The thread message id; a host may not have assigned one yet. */\n messageId: string | undefined;\n /** The thread message's `metadata.custom`, when any. */\n custom: Record<string, unknown> | undefined;\n}\n\n/** Whether a thread message is a voice delegation's anchor (the ask the live\n * voice model handed to the thread), by source or by id prefix. */\nexport function isVoiceDelegationMessage({ messageId, custom }: VoiceMessageMarker): boolean {\n return (\n custom?.source === VOICE_SOURCE_DELEGATION ||\n (typeof messageId === 'string' && messageId.startsWith(VOICE_DELEGATION_ID_PREFIX))\n );\n}\n\nexport interface VoiceProvenance {\n /** Chip copy: \"Listening\", \"Speaking\", \"Spoken\", or \"Delegated from voice\". */\n readonly label: string;\n /** True while the turn is still being heard or spoken. */\n readonly live: boolean;\n}\n\n// These values are read directly by useAuiState/useSyncExternalStore selectors.\n// Equal presentation must retain its identity, even when a transport replaces\n// the message metadata. Fresh objects here cause React's update-depth crash.\nconst SPOKEN_PROVENANCE: VoiceProvenance = Object.freeze({\n label: 'Spoken',\n live: false,\n});\nconst LISTENING_PROVENANCE: VoiceProvenance = Object.freeze({\n label: 'Listening',\n live: true,\n});\nconst SPEAKING_PROVENANCE: VoiceProvenance = Object.freeze({\n label: 'Speaking',\n live: true,\n});\nconst DELEGATED_PROVENANCE: VoiceProvenance = Object.freeze({\n label: 'Delegated from voice',\n live: false,\n});\n\n/**\n * The provenance chip for a message, or `null` for ordinary chat messages so\n * the chip is free to mount on every row. A spoken turn still in flight reads\n * as what is happening (\"Listening\" for the user, \"Speaking\" for the\n * assistant) rather than as a finished \"Spoken\" turn; a typed-in-voice turn\n * is a normal message and gets no chip. Results are immutable and stable for\n * equal presentation, so all surfaces can use this function as a selector.\n */\nexport function voiceProvenanceLabel({\n messageId,\n custom,\n role,\n}: VoiceMessageMarker & { role: 'user' | 'assistant' | string }): VoiceProvenance | null {\n const source = custom?.source;\n if (source === VOICE_SOURCE_SPOKEN) {\n if (custom?.[VOICE_LIVE_KEY] === true) {\n return role === 'user' ? LISTENING_PROVENANCE : SPEAKING_PROVENANCE;\n }\n return SPOKEN_PROVENANCE;\n }\n if (isVoiceDelegationMessage({ messageId, custom })) {\n return DELEGATED_PROVENANCE;\n }\n return null;\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n typeof value === 'object' && value !== null && !Array.isArray(value);\n\n/**\n * Whether a part of an assistant message holds the words of a spoken\n * assistant turn.\n *\n * The converter joins consecutive assistant entries into one message. The\n * host appends a live turn while it is heard, and a delegated run keeps\n * appending its own steps after it, so a turn spoken during a run becomes a\n * text part inside the run's message, before the run's later tool calls.\n * Grouped views put the text before the last tool call in the details panel\n * as the run's narration; they use this to keep the spoken words visible.\n *\n * The joined message's `metadata.custom` merges every entry in it, so this\n * reads the part's own entry, which the converter binds to the part.\n */\nexport function isSpokenTurnTextPart(part: { readonly type: string }): boolean {\n if (part.type !== 'text') return false;\n const entries: readonly unknown[] = getExternalStoreMessages<unknown>(\n part as ThreadMessage['content'][number]\n );\n return entries.some((entry) => {\n if (!isRecord(entry) || entry.role !== 'assistant' || !isRecord(entry.metadata)) return false;\n const custom = entry.metadata.custom;\n return isRecord(custom) && custom.source === VOICE_SOURCE_SPOKEN;\n });\n}\n\nexport interface VoiceUpdateToolCall {\n /** `mode: \"speak\"` — read aloud to the user; otherwise a silent progress note. */\n spoken: boolean;\n /** The update's words. */\n text: string;\n /** Whether the live session took it; `null` while the call is still running. */\n delivered: boolean | null;\n}\n\n/**\n * What a `send_voice_update` tool call did, from its arguments and the tool's\n * teaching-text result (agora returns prose, not JSON, so the run can read it).\n */\nexport function describeVoiceUpdateToolCall({\n args,\n result,\n}: {\n args: Record<string, unknown> | undefined;\n result: unknown;\n}): VoiceUpdateToolCall {\n const text = typeof args?.text === 'string' ? args.text.trim() : '';\n const spoken = args?.mode === 'speak';\n if (typeof result !== 'string' || result.trim() === '') {\n return { spoken, text, delivered: null };\n }\n const outcome = result.toLowerCase();\n const failed =\n outcome.includes('could not be delivered') ||\n outcome.includes('was not delivered') ||\n outcome.includes('rejected the update') ||\n outcome.includes('has ended') ||\n outcome.includes('not configured') ||\n outcome.includes('no voice session');\n return { spoken, text, delivered: !failed };\n}\n\n/** The card's status line for a voice update in a given tool-call state. */\nexport function voiceUpdateStatusLabel(\n call: VoiceUpdateToolCall,\n status: 'running' | 'complete' | 'incomplete' | 'requires-action' | undefined\n): string {\n if (status === 'running' || (call.delivered === null && status !== 'incomplete')) {\n return call.spoken ? 'Saying to you…' : 'Noting progress…';\n }\n if (call.delivered === false || status === 'incomplete') {\n return call.spoken ? 'Could not say this to you' : 'Progress note not delivered';\n }\n return call.spoken ? 'Said aloud' : 'Progress noted silently';\n}\n"],"names":[],"mappings":";AAgBO,MAAM,sBAAsB;AAE5B,MAAM,qBAAqB;AAE3B,MAAM,0BAA0B;AAGhC,MAAM,6BAA6B;AAEnC,MAAM,iBAAiB;AAIvB,MAAM,qBAAqB;AAG3B,MAAM,yBAAyB;AAG/B,MAAM,wBAAwB;AAW9B,SAAS,yBAAyB,EAAE,WAAW,UAAuC;AAC3F,UACE,iCAAQ,YAAW,2BAClB,OAAO,cAAc,YAAY,UAAU,WAAW,0BAA0B;AAErF;AAuDA,MAAM,WAAW,CAAC,UAChB,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAgB9D,SAAS,qBAAqB,MAA0C;AAC7E,MAAI,KAAK,SAAS,OAAQ,QAAO;AACjC,QAAM,UAA8B;AAAA,IAClC;AAAA,EAAA;AAEF,SAAO,QAAQ,KAAK,CAAC,UAAU;AAC7B,QAAI,CAAC,SAAS,KAAK,KAAK,MAAM,SAAS,eAAe,CAAC,SAAS,MAAM,QAAQ,EAAG,QAAO;AACxF,UAAM,SAAS,MAAM,SAAS;AAC9B,WAAO,SAAS,MAAM,KAAK,OAAO,WAAW;AAAA,EAC/C,CAAC;AACH;AAeO,SAAS,4BAA4B;AAAA,EAC1C;AAAA,EACA;AACF,GAGwB;AACtB,QAAM,OAAO,QAAO,6BAAM,UAAS,WAAW,KAAK,KAAK,SAAS;AACjE,QAAM,UAAS,6BAAM,UAAS;AAC9B,MAAI,OAAO,WAAW,YAAY,OAAO,KAAA,MAAW,IAAI;AACtD,WAAO,EAAE,QAAQ,MAAM,WAAW,KAAA;AAAA,EACpC;AACA,QAAM,UAAU,OAAO,YAAA;AACvB,QAAM,SACJ,QAAQ,SAAS,wBAAwB,KACzC,QAAQ,SAAS,mBAAmB,KACpC,QAAQ,SAAS,qBAAqB,KACtC,QAAQ,SAAS,WAAW,KAC5B,QAAQ,SAAS,gBAAgB,KACjC,QAAQ,SAAS,kBAAkB;AACrC,SAAO,EAAE,QAAQ,MAAM,WAAW,CAAC,OAAA;AACrC;AAGO,SAAS,uBACd,MACA,QACQ;AACR,MAAI,WAAW,aAAc,KAAK,cAAc,QAAQ,WAAW,cAAe;AAChF,WAAO,KAAK,SAAS,mBAAmB;AAAA,EAC1C;AACA,MAAI,KAAK,cAAc,SAAS,WAAW,cAAc;AACvD,WAAO,KAAK,SAAS,8BAA8B;AAAA,EACrD;AACA,SAAO,KAAK,SAAS,eAAe;AACtC;"}
|