e30e75b5d4
Changesets / Create Version PR (push) Has been cancelled
Deploy Shadcn Registry / Deploy Production (push) Has been cancelled
Template Metrics / LOC + Bundle Size (push) Has been cancelled
Code Quality / Oxlint + Oxfmt (push) Has been cancelled
Code Quality / Template Sync (push) Has been cancelled
Code Quality / Build Changed Packages (push) Has been cancelled
Code Quality / Test Changed Packages (push) Has been cancelled
Deploy Expo Example / Deploy Production (push) Has been cancelled
Deploy Ink Example / Deploy Production (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.12) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.12) (push) Has been cancelled
139 lines
4.4 KiB
TypeScript
139 lines
4.4 KiB
TypeScript
import type { RealtimeVoiceAdapter } from "@assistant-ui/react";
|
|
import { createVoiceSession } from "@assistant-ui/react";
|
|
import {
|
|
Room,
|
|
RoomEvent,
|
|
Track,
|
|
type RemoteTrack,
|
|
type RoomOptions,
|
|
} from "livekit-client";
|
|
|
|
export type LiveKitVoiceAdapterOptions = {
|
|
url: string;
|
|
token: string | (() => Promise<string>);
|
|
roomOptions?: RoomOptions;
|
|
};
|
|
|
|
export class LiveKitVoiceAdapter implements RealtimeVoiceAdapter {
|
|
private _url: string;
|
|
private _token: string | (() => Promise<string>);
|
|
private _roomOptions: RoomOptions | undefined;
|
|
|
|
constructor(options: LiveKitVoiceAdapterOptions) {
|
|
this._url = options.url;
|
|
this._token = options.token;
|
|
this._roomOptions = options.roomOptions;
|
|
}
|
|
|
|
connect(options: {
|
|
abortSignal?: AbortSignal;
|
|
}): RealtimeVoiceAdapter.Session {
|
|
const room = new Room(this._roomOptions);
|
|
|
|
return createVoiceSession(options, async (session) => {
|
|
let volumeInterval: ReturnType<typeof setInterval> | null = null;
|
|
const attachedAudioElements = new Set<HTMLMediaElement>();
|
|
|
|
const attachRemoteAudio = (track: RemoteTrack) => {
|
|
if (track.kind !== Track.Kind.Audio) return;
|
|
const element = track.attach();
|
|
element.style.display = "none";
|
|
document.body.appendChild(element);
|
|
attachedAudioElements.add(element);
|
|
};
|
|
|
|
const detachRemoteAudio = (track: RemoteTrack) => {
|
|
if (track.kind !== Track.Kind.Audio) return;
|
|
for (const element of track.detach()) {
|
|
element.remove();
|
|
attachedAudioElements.delete(element);
|
|
}
|
|
};
|
|
|
|
const cleanupAudioElements = () => {
|
|
for (const element of attachedAudioElements) element.remove();
|
|
attachedAudioElements.clear();
|
|
};
|
|
|
|
room.on(RoomEvent.TrackSubscribed, attachRemoteAudio);
|
|
room.on(RoomEvent.TrackUnsubscribed, detachRemoteAudio);
|
|
|
|
room.on(RoomEvent.Connected, () => {
|
|
session.setStatus({ type: "running" });
|
|
if (volumeInterval) clearInterval(volumeInterval);
|
|
volumeInterval = setInterval(() => {
|
|
if (session.isDisposed()) return;
|
|
const localLevel = room.localParticipant.audioLevel ?? 0;
|
|
let remoteLevel = 0;
|
|
for (const p of room.remoteParticipants.values()) {
|
|
remoteLevel = Math.max(remoteLevel, p.audioLevel ?? 0);
|
|
}
|
|
session.emitVolume(Math.max(localLevel, remoteLevel));
|
|
}, 100);
|
|
});
|
|
|
|
room.on(RoomEvent.Disconnected, () => {
|
|
if (volumeInterval) clearInterval(volumeInterval);
|
|
session.end("finished");
|
|
});
|
|
room.on(RoomEvent.MediaDevicesError, (error) => {
|
|
if (volumeInterval) clearInterval(volumeInterval);
|
|
session.end("error", error);
|
|
});
|
|
|
|
room.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
|
|
if (session.isDisposed()) return;
|
|
const remoteIsSpeaking = speakers.some(
|
|
(s) => s !== room.localParticipant,
|
|
);
|
|
session.emitMode(remoteIsSpeaking ? "speaking" : "listening");
|
|
});
|
|
|
|
room.on(
|
|
RoomEvent.TranscriptionReceived,
|
|
(segments, participant, _publication) => {
|
|
if (session.isDisposed()) return;
|
|
const role =
|
|
participant === room.localParticipant ? "user" : "assistant";
|
|
for (const segment of segments) {
|
|
session.emitTranscript({
|
|
role,
|
|
text: segment.text,
|
|
isFinal: segment.final,
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
const token =
|
|
typeof this._token === "function" ? await this._token() : this._token;
|
|
if (session.isDisposed()) {
|
|
cleanupAudioElements();
|
|
return { disconnect: () => {}, mute: () => {}, unmute: () => {} };
|
|
}
|
|
|
|
await room.connect(this._url, token);
|
|
if (session.isDisposed()) {
|
|
cleanupAudioElements();
|
|
return { disconnect: () => {}, mute: () => {}, unmute: () => {} };
|
|
}
|
|
|
|
await room.localParticipant.setMicrophoneEnabled(true);
|
|
|
|
return {
|
|
disconnect: () => {
|
|
if (volumeInterval) clearInterval(volumeInterval);
|
|
cleanupAudioElements();
|
|
room.disconnect();
|
|
},
|
|
mute: () => {
|
|
room.localParticipant.setMicrophoneEnabled(false).catch(() => {});
|
|
},
|
|
unmute: () => {
|
|
room.localParticipant.setMicrophoneEnabled(true).catch(() => {});
|
|
},
|
|
};
|
|
});
|
|
}
|
|
}
|