-
-
Notifications
You must be signed in to change notification settings - Fork 22
Expand file tree
/
Copy pathlivekit-voice.js
More file actions
113 lines (103 loc) · 3.51 KB
/
Copy pathlivekit-voice.js
File metadata and controls
113 lines (103 loc) · 3.51 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
// LiveKit realtime voice — bidirectional audio via a LiveKit room.
// The agent server joins the same room, handles VAD/STT/TTS, and sends
// data-channel messages with transcript payloads.
import { Room, RoomEvent, Track } from 'livekit-client';
export class LiveKitVoice {
/**
* @param {object} opts
* @param {string} opts.serverUrl LiveKit server WebSocket URL (wss://…)
* @param {string} opts.token Room access JWT
* @param {import('../agent-protocol.js').AgentProtocol} opts.protocol
*/
constructor({ serverUrl, token, protocol }) {
this._serverUrl = serverUrl;
this._token = token;
this._protocol = protocol;
this._room = null;
this._audioEls = []; // { track, el }[]
this._userSpeaking = false;
}
async connect() {
const room = new Room();
this._room = room;
// Agent TTS audio — attach <audio> element when track arrives.
// In the slim widget shell we still need the element to exist so the
// browser plays the track, but it must be hidden (no DOM leak) and
// detached from any user-visible layout. The audio is what the
// embedder asked for; the <audio> tag itself is invisible chrome.
room.on(RoomEvent.TrackSubscribed, (track, _pub, _participant) => {
if (track.kind !== Track.Kind.Audio) return;
const el = track.attach();
if (window.__WIDGET_SHELL) {
el.style.display = 'none';
el.setAttribute('aria-hidden', 'true');
}
document.body.appendChild(el);
this._audioEls.push({ track, el });
});
room.on(RoomEvent.TrackUnsubscribed, (track) => {
const idx = this._audioEls.findIndex((e) => e.track === track);
if (idx === -1) return;
const { el } = this._audioEls.splice(idx, 1)[0];
try { track.detach(el); } catch {}
el.remove();
});
// Data messages from the agent server
room.on(RoomEvent.DataReceived, (data, _participant) => {
let msg;
try {
msg = JSON.parse(new TextDecoder().decode(data));
} catch {
return;
}
// Agent speaking — drive avatar lipsync + chat bubble
if (msg.type === 'transcript' && msg.text) {
this._protocol.emit({
type: 'speak',
payload: { text: msg.text, sentiment: 0 },
});
}
// User speech boundaries (VAD from agent server)
if (msg.type === 'speech_started') {
this._userSpeaking = true;
this._protocol.emit({ type: 'listen-start', payload: {} });
}
if (msg.type === 'speech_ended') {
this._userSpeaking = false;
this._protocol.emit({
type: 'listen-end',
payload: { text: msg.transcript || '' },
});
}
});
// Fallback: use LiveKit active-speaker VAD when agent doesn't send speech events
room.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
const localId = room.localParticipant?.identity;
const active = speakers.some((p) => p.identity === localId);
if (active && !this._userSpeaking) {
this._userSpeaking = true;
this._protocol.emit({ type: 'listen-start', payload: {} });
} else if (!active && this._userSpeaking) {
this._userSpeaking = false;
this._protocol.emit({ type: 'listen-end', payload: { text: '' } });
}
});
await room.connect(this._serverUrl, this._token);
await room.localParticipant.setMicrophoneEnabled(true);
}
async disconnect() {
for (const { track, el } of this._audioEls) {
try { track.detach(el); } catch {}
try { el.remove(); } catch {}
}
this._audioEls = [];
if (this._room) {
try { await this._room.disconnect(); } catch {}
this._room = null;
}
this._userSpeaking = false;
}
get connected() {
return this._room?.state === 'connected';
}
}