fix(voice): keep LiveKit TTS tracks alive

This commit is contained in:
Yahya Alhinai
2026-06-28 07:20:54 +00:00
parent 46d277d203
commit c4c65f8802
91 changed files with 16863 additions and 21 deletions
@@ -0,0 +1,91 @@
import { NextResponse } from 'next/server';
import { AccessToken, type AccessTokenOptions, type VideoGrant } from 'livekit-server-sdk';
import { RoomConfiguration } from '@livekit/protocol';
type ConnectionDetails = {
serverUrl: string;
roomName: string;
participantName: string;
participantToken: string;
};
// NOTE: you are expected to define the following environment variables in `.env.local`:
const API_KEY = process.env.LIVEKIT_API_KEY;
const API_SECRET = process.env.LIVEKIT_API_SECRET;
const LIVEKIT_URL = process.env.LIVEKIT_URL;
// don't cache the results
export const revalidate = 0;
export async function POST(req: Request) {
try {
if (LIVEKIT_URL === undefined) {
throw new Error('LIVEKIT_URL is not defined');
}
if (API_KEY === undefined) {
throw new Error('LIVEKIT_API_KEY is not defined');
}
if (API_SECRET === undefined) {
throw new Error('LIVEKIT_API_SECRET is not defined');
}
// Parse agent configuration from request body
const body = await req.json();
const agentName: string = body?.room_config?.agents?.[0]?.agent_name;
// Generate participant token
const participantName = 'user';
const participantIdentity = `voice_assistant_user_${Math.floor(Math.random() * 10_000)}`;
const roomName = `voice_assistant_room_${Math.floor(Math.random() * 10_000)}`;
const participantToken = await createParticipantToken(
{ identity: participantIdentity, name: participantName },
roomName,
agentName
);
// Return connection details
const data: ConnectionDetails = {
serverUrl: LIVEKIT_URL,
roomName,
participantToken: participantToken,
participantName,
};
const headers = new Headers({
'Cache-Control': 'no-store',
});
return NextResponse.json(data, { headers });
} catch (error) {
if (error instanceof Error) {
console.error(error);
return new NextResponse(error.message, { status: 500 });
}
}
}
function createParticipantToken(
userInfo: AccessTokenOptions,
roomName: string,
agentName?: string
): Promise<string> {
const at = new AccessToken(API_KEY, API_SECRET, {
...userInfo,
ttl: '15m',
});
const grant: VideoGrant = {
room: roomName,
roomJoin: true,
canPublish: true,
canPublishData: true,
canSubscribe: true,
};
at.addGrant(grant);
if (agentName) {
at.roomConfig = new RoomConfiguration({
agents: [{ agentName }],
});
}
return at.toJwt();
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1,111 @@
import { Public_Sans } from 'next/font/google';
import localFont from 'next/font/local';
import { headers } from 'next/headers';
import { ThemeProvider } from '@/components/app/theme-provider';
import { ThemeToggle } from '@/components/app/theme-toggle';
import { cn } from '@/lib/shadcn/utils';
import { getAppConfig, getStyles } from '@/lib/utils';
import '@/styles/globals.css';
const publicSans = Public_Sans({
variable: '--font-public-sans',
subsets: ['latin'],
});
const commitMono = localFont({
display: 'swap',
variable: '--font-commit-mono',
src: [
{
path: '../fonts/CommitMono-400-Regular.otf',
weight: '400',
style: 'normal',
},
{
path: '../fonts/CommitMono-700-Regular.otf',
weight: '700',
style: 'normal',
},
{
path: '../fonts/CommitMono-400-Italic.otf',
weight: '400',
style: 'italic',
},
{
path: '../fonts/CommitMono-700-Italic.otf',
weight: '700',
style: 'italic',
},
],
});
interface RootLayoutProps {
children: React.ReactNode;
}
export default async function RootLayout({ children }: RootLayoutProps) {
const hdrs = await headers();
const appConfig = await getAppConfig(hdrs);
const styles = getStyles(appConfig);
const { pageTitle, pageDescription, companyName, logo, logoDark } = appConfig;
return (
<html
lang="en"
suppressHydrationWarning
className={cn(
publicSans.variable,
commitMono.variable,
'scroll-smooth font-sans antialiased'
)}
>
<head>
{styles && <style>{styles}</style>}
<title>{pageTitle}</title>
<meta name="description" content={pageDescription} />
</head>
<body className="overflow-x-hidden">
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<header className="fixed top-0 left-0 z-50 hidden w-full flex-row justify-between p-6 md:flex">
<a
target="_blank"
rel="noopener noreferrer"
href="https://livekit.io"
className="scale-100 transition-transform duration-300 hover:scale-110"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={logo} alt={`${companyName} Logo`} className="block size-6 dark:hidden" />
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={logoDark ?? logo}
alt={`${companyName} Logo`}
className="hidden size-6 dark:block"
/>
</a>
<span className="text-foreground font-mono text-xs font-bold tracking-wider uppercase">
Built with{' '}
<a
target="_blank"
rel="noopener noreferrer"
href="https://docs.livekit.io/agents"
className="underline underline-offset-4"
>
LiveKit Agents
</a>
</span>
</header>
{children}
<div className="group fixed bottom-0 left-1/2 z-50 mb-2 -translate-x-1/2">
<ThemeToggle className="translate-y-20 transition-transform delay-150 duration-300 group-hover:translate-y-0" />
</div>
</ThemeProvider>
</body>
</html>
);
}
@@ -0,0 +1,255 @@
import { headers } from 'next/headers';
import { ImageResponse } from 'next/og';
import getImageSize from 'buffer-image-size';
import mime from 'mime';
import { existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { APP_CONFIG_DEFAULTS } from '@/app-config';
import { getAppConfig } from '@/lib/utils';
type Dimensions = {
width: number;
height: number;
};
type ImageData = {
base64: string;
dimensions: Dimensions;
};
// Image metadata
export const alt = 'About Acme';
export const size = {
width: 1200,
height: 628,
};
function isRemoteFile(uri: string) {
return uri.startsWith('http');
}
function doesLocalFileExist(uri: string) {
return existsSync(join(process.cwd(), uri));
}
// LOCAL FILES MUST BE IN PUBLIC FOLDER
async function loadFileData(filePath: string): Promise<ArrayBuffer> {
if (isRemoteFile(filePath)) {
const response = await fetch(filePath);
if (!response.ok) {
throw new Error(`Failed to fetch ${filePath} - ${response.status} ${response.statusText}`);
}
return await response.arrayBuffer();
}
// Try file system first (works in local development)
if (doesLocalFileExist(filePath)) {
const buffer = await readFile(join(process.cwd(), filePath));
return buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
}
// Fallback to fetching from public URL (works in production)
const publicFilePath = filePath.replace('public/', '');
const fontUrl = `https://${process.env.VERCEL_URL}/${publicFilePath}`;
const response = await fetch(fontUrl);
if (!response.ok) {
throw new Error(`Failed to fetch ${fontUrl} - ${response.status} ${response.statusText}`);
}
return await response.arrayBuffer();
}
async function getImageData(uri: string, fallbackUri?: string): Promise<ImageData> {
try {
const fileData = await loadFileData(uri);
const buffer = Buffer.from(fileData);
const mimeType = mime.getType(uri);
return {
base64: `data:${mimeType};base64,${buffer.toString('base64')}`,
dimensions: getImageSize(buffer),
};
} catch (e) {
if (fallbackUri) {
return getImageData(fallbackUri, fallbackUri);
}
throw e;
}
}
function scaleImageSize(size: { width: number; height: number }, desiredHeight: number) {
const scale = desiredHeight / size.height;
return {
width: size.width * scale,
height: desiredHeight,
};
}
function cleanPageTitle(appName: string) {
if (appName === APP_CONFIG_DEFAULTS.pageTitle) {
return 'Voice agent';
}
return appName;
}
export const contentType = 'image/png';
// Image generation
export default async function Image() {
const hdrs = await headers();
const appConfig = await getAppConfig(hdrs);
const pageTitle = cleanPageTitle(appConfig.pageTitle);
const logoUri = appConfig.logoDark || appConfig.logo;
const isLogoUriLocal = logoUri.includes('lk-logo');
const wordmarkUri = logoUri === APP_CONFIG_DEFAULTS.logoDark ? 'public/lk-wordmark.svg' : logoUri;
// Load fonts - use file system in dev, fetch in production
let commitMonoData: ArrayBuffer | undefined;
let everettLightData: ArrayBuffer | undefined;
try {
commitMonoData = await loadFileData('public/commit-mono-400-regular.woff');
everettLightData = await loadFileData('public/everett-light.woff');
} catch (e) {
console.error('Failed to load fonts:', e);
// Continue without custom fonts - will fall back to system fonts
}
// bg
const { base64: bgSrcBase64 } = await getImageData('public/opengraph-image-bg.png');
// wordmark
const { base64: wordmarkSrcBase64, dimensions: wordmarkDimensions } = isLogoUriLocal
? await getImageData(wordmarkUri)
: await getImageData(logoUri);
const wordmarkSize = scaleImageSize(wordmarkDimensions, isLogoUriLocal ? 32 : 64);
// logo
const { base64: logoSrcBase64, dimensions: logoDimensions } = await getImageData(
logoUri,
'public/lk-logo-dark.svg'
);
const logoSize = scaleImageSize(logoDimensions, 24);
return new ImageResponse(
(
// ImageResponse JSX element
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: size.width,
height: size.height,
backgroundImage: `url(${bgSrcBase64})`,
backgroundSize: '100% 100%',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}}
>
{/* wordmark */}
<div
style={{
position: 'absolute',
top: 30,
left: 30,
display: 'flex',
alignItems: 'center',
gap: 10,
}}
>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img src={wordmarkSrcBase64} width={wordmarkSize.width} height={wordmarkSize.height} />
</div>
{/* logo */}
<div
style={{
position: 'absolute',
top: 200,
left: 460,
display: 'flex',
alignItems: 'center',
gap: 10,
}}
>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img src={logoSrcBase64} width={logoSize.width} height={logoSize.height} />
</div>
{/* title */}
<div
style={{
position: 'absolute',
bottom: 100,
left: 30,
width: '380px',
display: 'flex',
flexDirection: 'column',
gap: 16,
}}
>
<div
style={{
backgroundColor: '#1F1F1F',
padding: '2px 8px',
borderRadius: 4,
width: 72,
fontSize: 12,
fontFamily: 'CommitMono',
fontWeight: 600,
color: '#999999',
letterSpacing: 0.8,
}}
>
SANDBOX
</div>
<div
style={{
fontSize: 48,
fontWeight: 300,
fontFamily: 'Everett',
color: 'white',
lineHeight: 1,
}}
>
{pageTitle}
</div>
</div>
</div>
),
// ImageResponse options
{
// For convenience, we can re-use the exported opengraph-image
// size config to also set the ImageResponse's width and height.
...size,
fonts: [
...(commitMonoData
? [
{
name: 'CommitMono',
data: commitMonoData,
style: 'normal' as const,
weight: 400 as const,
},
]
: []),
...(everettLightData
? [
{
name: 'Everett',
data: everettLightData,
style: 'normal' as const,
weight: 300 as const,
},
]
: []),
],
}
);
}
@@ -0,0 +1,10 @@
import { headers } from 'next/headers';
import { App } from '@/components/app/app';
import { getAppConfig } from '@/lib/utils';
export default async function Page() {
const hdrs = await headers();
const appConfig = await getAppConfig(hdrs);
return <App appConfig={appConfig} />;
}