fix: stabilize livekit gemini voice playback
This commit is contained in:
+53
-17
@@ -5,6 +5,7 @@ import {
|
|||||||
LocalAudioTrack,
|
LocalAudioTrack,
|
||||||
TrackPublishOptions,
|
TrackPublishOptions,
|
||||||
TrackSource,
|
TrackSource,
|
||||||
|
type LocalParticipant,
|
||||||
type Room,
|
type Room,
|
||||||
} from '@livekit/rtc-node';
|
} from '@livekit/rtc-node';
|
||||||
import { GoogleGenAI, Modality, type LiveServerMessage, type Session } from '@google/genai';
|
import { GoogleGenAI, Modality, type LiveServerMessage, type Session } from '@google/genai';
|
||||||
@@ -14,8 +15,11 @@ import { env } from '../env.js';
|
|||||||
const SAMPLE_RATE = 24_000;
|
const SAMPLE_RATE = 24_000;
|
||||||
const CHANNELS = 1;
|
const CHANNELS = 1;
|
||||||
const FRAME_SAMPLES = SAMPLE_RATE / 10;
|
const FRAME_SAMPLES = SAMPLE_RATE / 10;
|
||||||
|
const VOICE_QUEUE_MS = 30_000;
|
||||||
|
const VOICE_TRACK_PREFIX = 'podman-hermes-voice';
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const ai = new GoogleGenAI({ apiKey: env.GEMINI_API_KEY });
|
const ai = new GoogleGenAI({ apiKey: env.GEMINI_API_KEY });
|
||||||
|
let voiceQueue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
function ttsPrompt(message: string): string {
|
function ttsPrompt(message: string): string {
|
||||||
return [
|
return [
|
||||||
@@ -35,6 +39,17 @@ async function publishVoiceCue(room: Room, message: string): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function unpublishVoiceTracks(localParticipant: LocalParticipant): Promise<void> {
|
||||||
|
const publications = Array.from(localParticipant.trackPublications.values()).filter(
|
||||||
|
(publication) => publication.name?.startsWith(VOICE_TRACK_PREFIX) && publication.sid,
|
||||||
|
);
|
||||||
|
for (const publication of publications) {
|
||||||
|
await localParticipant.unpublishTrack(publication.sid!, true).catch((err) => {
|
||||||
|
console.warn(`[voice] stale track cleanup failed: ${(err as Error).message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function audioFrameFromBase64(data: string, mimeType?: string): AudioFrame | null {
|
function audioFrameFromBase64(data: string, mimeType?: string): AudioFrame | null {
|
||||||
if (mimeType && !mimeType.includes('audio')) return null;
|
if (mimeType && !mimeType.includes('audio')) return null;
|
||||||
const buf = Buffer.from(data, 'base64');
|
const buf = Buffer.from(data, 'base64');
|
||||||
@@ -127,6 +142,42 @@ async function speakWithLive(source: AudioSource, message: string): Promise<void
|
|||||||
session.close();
|
session.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitForVoicePlayout(source: AudioSource): Promise<void> {
|
||||||
|
if (source.queuedDuration <= 0) return;
|
||||||
|
await Promise.race([
|
||||||
|
source.waitForPlayout(),
|
||||||
|
new Promise((resolve) => setTimeout(resolve, VOICE_QUEUE_MS + 2_000)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function speakAudio(room: Room, message: string): Promise<void> {
|
||||||
|
const localParticipant = room.localParticipant;
|
||||||
|
if (!localParticipant) return;
|
||||||
|
await unpublishVoiceTracks(localParticipant);
|
||||||
|
const source = new AudioSource(SAMPLE_RATE, CHANNELS, VOICE_QUEUE_MS);
|
||||||
|
const track = LocalAudioTrack.createAudioTrack(`${VOICE_TRACK_PREFIX}-${Date.now()}`, source);
|
||||||
|
const options = new TrackPublishOptions();
|
||||||
|
options.source = TrackSource.SOURCE_UNKNOWN;
|
||||||
|
let publicationSid: string | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const publication = await localParticipant.publishTrack(track, options);
|
||||||
|
publicationSid = publication.sid;
|
||||||
|
if (env.GEMINI_LIVE_MODEL.includes('tts')) await speakWithTts(source, message);
|
||||||
|
else await speakWithLive(source, message);
|
||||||
|
await waitForVoicePlayout(source);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[voice] Gemini voice publish failed: ${(err as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
if (publicationSid) {
|
||||||
|
await localParticipant.unpublishTrack(publicationSid, true).catch((err) => {
|
||||||
|
console.warn(`[voice] track unpublish failed: ${(err as Error).message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await source.close().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Speak a message into the LiveKit room using Gemini audio. A data-channel
|
* Speak a message into the LiveKit room using Gemini audio. A data-channel
|
||||||
* VOICE_CUE is sent first so clients still get the cue if audio generation or
|
* VOICE_CUE is sent first so clients still get the cue if audio generation or
|
||||||
@@ -134,21 +185,6 @@ async function speakWithLive(source: AudioSource, message: string): Promise<void
|
|||||||
*/
|
*/
|
||||||
export async function speak(room: Room, message: string): Promise<void> {
|
export async function speak(room: Room, message: string): Promise<void> {
|
||||||
await publishVoiceCue(room, message);
|
await publishVoiceCue(room, message);
|
||||||
if (!room.localParticipant) return;
|
voiceQueue = voiceQueue.catch(() => {}).then(() => speakAudio(room, message));
|
||||||
|
await voiceQueue;
|
||||||
const source = new AudioSource(SAMPLE_RATE, CHANNELS);
|
|
||||||
const track = LocalAudioTrack.createAudioTrack('podman-hermes-voice', source);
|
|
||||||
const options = new TrackPublishOptions();
|
|
||||||
options.source = TrackSource.SOURCE_MICROPHONE;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const publication = await room.localParticipant.publishTrack(track, options);
|
|
||||||
if (env.GEMINI_LIVE_MODEL.includes('tts')) await speakWithTts(source, message);
|
|
||||||
else await speakWithLive(source, message);
|
|
||||||
if (publication.sid) await room.localParticipant.unpublishTrack(publication.sid, true);
|
|
||||||
await source.close();
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`[voice] Gemini voice publish failed: ${(err as Error).message}`);
|
|
||||||
await source.close().catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -524,7 +524,10 @@ export function PodView({
|
|||||||
</Card>
|
</Card>
|
||||||
</aside>
|
</aside>
|
||||||
</main>
|
</main>
|
||||||
<div ref={audioRef} className="hidden" />
|
<div
|
||||||
|
ref={audioRef}
|
||||||
|
className="pointer-events-none fixed size-px overflow-hidden opacity-0"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
<ActivitySidebar
|
<ActivitySidebar
|
||||||
|
|||||||
Reference in New Issue
Block a user