feat(livekit): upgrade Gemini starter

This commit is contained in:
Yahya Alhinai
2026-06-28 07:38:09 +00:00
parent c4c65f8802
commit 6083f45f05
7 changed files with 1114 additions and 987 deletions
@@ -2,3 +2,6 @@ LIVEKIT_API_KEY=<your API Key>
LIVEKIT_API_SECRET=<your API Secret> LIVEKIT_API_SECRET=<your API Secret>
LIVEKIT_URL=<your LiveKit server URL> LIVEKIT_URL=<your LiveKit server URL>
GOOGLE_API_KEY=<your Google/Gemini API key> GOOGLE_API_KEY=<your Google/Gemini API key>
GEMINI_REALTIME_MODEL=gemini-3.1-flash-live-preview
GEMINI_IMAGE_MODEL=gemini-3.1-flash-image
GEMINI_LYRIA_MODEL=models/lyria-realtime-exp
@@ -16,9 +16,9 @@ logger = logging.getLogger(__name__)
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# HACK HERE: swap model IDs to experiment # HACK HERE: swap model IDs to experiment
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
REALTIME_MODEL = "gemini-2.5-flash-native-audio-preview-12-2025" REALTIME_MODEL = os.getenv("GEMINI_REALTIME_MODEL", "gemini-3.1-flash-live-preview")
IMAGE_MODEL = "gemini-2.5-flash-image" # Nano Banana IMAGE_MODEL = os.getenv("GEMINI_IMAGE_MODEL", "gemini-3.1-flash-image") # Nano Banana 2
LYRIA_MODEL = "models/lyria-realtime-exp" LYRIA_MODEL = os.getenv("GEMINI_LYRIA_MODEL", "models/lyria-realtime-exp")
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# HACK HERE: change the agent's persona # HACK HERE: change the agent's persona
@@ -27,7 +27,7 @@ PERSONA_INSTRUCTIONS = """You are a creative multimodal AI assistant at a Google
You can see through the user's camera, hear them speak, generate images, and play real-time music. You can see through the user's camera, hear them speak, generate images, and play real-time music.
Your capabilities: Your capabilities:
- generate_image: Create images with Nano Banana (Gemini 2.5 Flash Image). Use this when asked to generate, create, render, or visualize anything. - generate_image: Create images with Nano Banana 2 (Gemini 3.1 Flash Image). Use this when asked to generate, create, render, or visualize anything.
- start_music: Play real-time generative music with Lyria RealTime. Use this for soundtracks, ambience, or any audio atmosphere. - start_music: Play real-time generative music with Lyria RealTime. Use this for soundtracks, ambience, or any audio atmosphere.
- stop_music: Stop the current music. - stop_music: Stop the current music.
@@ -281,9 +281,10 @@ async def entrypoint(ctx: agents.JobContext):
await ctx.connect() await ctx.connect()
if REALTIME_MODEL != "gemini-3.1-flash-live-preview":
try: try:
await session.generate_reply( await session.generate_reply(
instructions="Greet the user. Let them know you can generate images with Nano Banana and play real-time music with Lyria. Mention they can enable their camera for visual context." instructions="Greet the user. Let them know you can generate images with Nano Banana 2 and play real-time music with Lyria. Mention they can enable their camera for visual context."
) )
except Exception as exc: except Exception as exc:
logger.warning("Initial greeting failed: %s", exc) logger.warning("Initial greeting failed: %s", exc)
@@ -4,8 +4,8 @@ version = "0.1.0"
description = "Gemini hackathon starter — voice, vision, image generation, and real-time music with LiveKit" description = "Gemini hackathon starter — voice, vision, image generation, and real-time music with LiveKit"
requires-python = ">=3.10,<3.14" requires-python = ">=3.10,<3.14"
dependencies = [ dependencies = [
"livekit-agents[google,images]~=1.4", "livekit-agents[google,images]>=1.6.4,<1.7",
"google-genai>=1.16.0", "google-genai>=2.10.0,<3",
"python-dotenv>=1.0.0", "python-dotenv>=1.0.0",
] ]
File diff suppressed because it is too large Load Diff
@@ -25,7 +25,7 @@ export const APP_CONFIG_DEFAULTS: AppConfig = {
companyName: 'Gemini Hackathon', companyName: 'Gemini Hackathon',
pageTitle: 'Gemini Hacker Starter', pageTitle: 'Gemini Hacker Starter',
pageDescription: pageDescription:
'Voice, vision, image generation, and real-time music with Gemini 2.5 native audio, Nano Banana, and Lyria', 'Voice, vision, image generation, and real-time music with Gemini 3.1 Live, Nano Banana 2, and Lyria',
supportsChatInput: true, supportsChatInput: true,
supportsVideoInput: true, supportsVideoInput: true,
@@ -34,7 +34,7 @@ export const WelcomeView = ({
<WelcomeImage /> <WelcomeImage />
<p className="text-foreground max-w-prose pt-1 leading-6 font-medium"> <p className="text-foreground max-w-prose pt-1 leading-6 font-medium">
Voice, vision, images, and music powered by Gemini 3.1, NanoBanana 2, and Lyria Voice, vision, images, and music powered by Gemini 3.1 Live, NanoBanana 2, and Lyria
</p> </p>
<Button <Button
+27 -2
View File
@@ -116,6 +116,7 @@ export function PodView({
const [testingVoice, setTestingVoice] = useState(false); const [testingVoice, setTestingVoice] = useState(false);
const [note, setNote] = useState<string | null>(null); const [note, setNote] = useState<string | null>(null);
const [audioBlocked, setAudioBlocked] = useState(false); const [audioBlocked, setAudioBlocked] = useState(false);
const [remoteAudioTracks, setRemoteAudioTracks] = useState(0);
const [micOn, setMicOn] = useState(false); const [micOn, setMicOn] = useState(false);
const [leftStreamOpen, setLeftStreamOpen] = useState(() => const [leftStreamOpen, setLeftStreamOpen] = useState(() =>
readStoredBool('podman.myStreamOpen', true), readStoredBool('podman.myStreamOpen', true),
@@ -146,6 +147,7 @@ export function PodView({
element.autoplay = true; element.autoplay = true;
audioElementsRef.current.set(key, element); audioElementsRef.current.set(key, element);
audioRef.current.appendChild(element); audioRef.current.appendChild(element);
setRemoteAudioTracks(audioElementsRef.current.size);
}; };
const removeAudio = (track: RemoteTrack, pub?: RemoteTrackPublication) => { const removeAudio = (track: RemoteTrack, pub?: RemoteTrackPublication) => {
const key = pub?.trackSid || track.sid || track.mediaStreamTrack.id; const key = pub?.trackSid || track.sid || track.mediaStreamTrack.id;
@@ -153,6 +155,7 @@ export function PodView({
if (attached) { if (attached) {
attached.remove(); attached.remove();
audioElementsRef.current.delete(key); audioElementsRef.current.delete(key);
setRemoteAudioTracks(audioElementsRef.current.size);
} }
track.detach().forEach((el) => el.remove()); track.detach().forEach((el) => el.remove());
}; };
@@ -165,14 +168,23 @@ export function PodView({
}); });
}; };
const onAudio = (track: RemoteTrack, pub: RemoteTrackPublication) => attachAudio(track, pub); const onAudio = (track: RemoteTrack, pub: RemoteTrackPublication) => attachAudio(track, pub);
const onAudioGone = (track: RemoteTrack, pub: RemoteTrackPublication) => removeAudio(track, pub); const onAudioGone = (track: RemoteTrack, pub: RemoteTrackPublication) =>
removeAudio(track, pub);
const onDisconnected = () => onLeaveRef.current(); const onDisconnected = () => onLeaveRef.current();
// Browsers block autoplay of incoming audio until a user gesture unlocks it. // Browsers block autoplay of incoming audio until a user gesture unlocks it.
// Surface a button whenever the room can't play sound so PodMan's voice cues // Surface a button whenever the room can't play sound so PodMan's voice cues
// are actually heard. // are actually heard.
const onPlaybackChanged = () => setAudioBlocked(!room.canPlaybackAudio); const onPlaybackChanged = () => setAudioBlocked(!room.canPlaybackAudio);
const unlockAudioFromGesture = () => {
void room.startAudio().finally(() => setAudioBlocked(!room.canPlaybackAudio));
};
onPlaybackChanged(); onPlaybackChanged();
setMicOn(room.localParticipant.isMicrophoneEnabled); setMicOn(room.localParticipant.isMicrophoneEnabled);
window.addEventListener('pointerdown', unlockAudioFromGesture, {
once: true,
capture: true,
});
window.addEventListener('keydown', unlockAudioFromGesture, { once: true, capture: true });
room room
.on(RoomEvent.ParticipantConnected, refresh) .on(RoomEvent.ParticipantConnected, refresh)
@@ -193,8 +205,11 @@ export function PodView({
.off(RoomEvent.TrackUnsubscribed, onAudioGone) .off(RoomEvent.TrackUnsubscribed, onAudioGone)
.off(RoomEvent.AudioPlaybackStatusChanged, onPlaybackChanged) .off(RoomEvent.AudioPlaybackStatusChanged, onPlaybackChanged)
.off(RoomEvent.Disconnected, onDisconnected); .off(RoomEvent.Disconnected, onDisconnected);
window.removeEventListener('pointerdown', unlockAudioFromGesture, { capture: true });
window.removeEventListener('keydown', unlockAudioFromGesture, { capture: true });
audioElementsRef.current.forEach((el) => el.remove()); audioElementsRef.current.forEach((el) => el.remove());
audioElementsRef.current.clear(); audioElementsRef.current.clear();
setRemoteAudioTracks(0);
}; };
}, [room, me]); }, [room, me]);
@@ -268,6 +283,8 @@ export function PodView({
if (!room) return; if (!room) return;
setNote(null); setNote(null);
try { try {
await room.startAudio().catch(() => {});
setAudioBlocked(!room.canPlaybackAudio);
if (sharing) { if (sharing) {
if (screenTrackRef.current) if (screenTrackRef.current)
await room.localParticipant.unpublishTrack(screenTrackRef.current); await room.localParticipant.unpublishTrack(screenTrackRef.current);
@@ -585,7 +602,15 @@ export function PodView({
<StatusLine <StatusLine
label="Audio" label="Audio"
value={ value={
beat.on ? (beat.mine ? 'publishing' : `${beat.by} playing`) : 'ready' audioBlocked
? 'blocked'
: remoteAudioTracks > 0
? `${remoteAudioTracks} remote track${remoteAudioTracks === 1 ? '' : 's'}`
: beat.on
? beat.mine
? 'publishing'
: `${beat.by} playing`
: 'ready'
} }
/> />
<StatusLine label="Agent" value={podmanPresent ? 'watching' : 'waiting'} /> <StatusLine label="Agent" value={podmanPresent ? 'watching' : 'waiting'} />