fix(voice): keep LiveKit TTS tracks alive
This commit is contained in:
@@ -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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user