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,125 @@
import { cache } from 'react';
import { TokenSource } from 'livekit-client';
import { APP_CONFIG_DEFAULTS } from '@/app-config';
import type { AppConfig } from '@/app-config';
export const CONFIG_ENDPOINT = process.env.NEXT_PUBLIC_APP_CONFIG_ENDPOINT;
export const SANDBOX_ID = process.env.SANDBOX_ID;
export interface SandboxConfig {
[key: string]:
| { type: 'string'; value: string }
| { type: 'number'; value: number }
| { type: 'boolean'; value: boolean }
| null;
}
/**
* Get the app configuration
* @param headers - The headers of the request
* @returns The app configuration
*
* @note React will invalidate the cache for all memoized functions for each server request.
* https://react.dev/reference/react/cache#caveats
*/
export const getAppConfig = cache(async (headers: Headers): Promise<AppConfig> => {
if (CONFIG_ENDPOINT) {
const sandboxId = SANDBOX_ID ?? headers.get('x-sandbox-id') ?? '';
try {
if (!sandboxId) {
throw new Error('Sandbox ID is required');
}
const response = await fetch(CONFIG_ENDPOINT, {
cache: 'no-store',
headers: { 'X-Sandbox-ID': sandboxId },
});
if (response.ok) {
const remoteConfig: SandboxConfig = await response.json();
const config: AppConfig = { ...APP_CONFIG_DEFAULTS, sandboxId };
for (const [key, entry] of Object.entries(remoteConfig)) {
if (entry === null) continue;
// Only include app config entries that are declared in defaults and, if set,
// share the same primitive type as the default value.
if (
(key in APP_CONFIG_DEFAULTS &&
APP_CONFIG_DEFAULTS[key as keyof AppConfig] === undefined) ||
(typeof config[key as keyof AppConfig] === entry.type &&
typeof config[key as keyof AppConfig] === typeof entry.value)
) {
// @ts-expect-error I'm not sure quite how to appease TypeScript, but we've thoroughly checked types above
config[key as keyof AppConfig] = entry.value as AppConfig[keyof AppConfig];
}
}
return config;
} else {
console.error(
`ERROR: querying config endpoint failed with status ${response.status}: ${response.statusText}`
);
}
} catch (error) {
console.error('ERROR: getAppConfig() - lib/utils.ts', error);
}
}
return APP_CONFIG_DEFAULTS;
});
/**
* Get styles for the app
* @param appConfig - The app configuration
* @returns A string of styles
*/
export function getStyles(appConfig: AppConfig) {
const { accent, accentDark } = appConfig;
return [
accent
? `:root { --primary: ${accent}; --primary-hover: color-mix(in srgb, ${accent} 80%, #000); }`
: '',
accentDark
? `.dark { --primary: ${accentDark}; --primary-hover: color-mix(in srgb, ${accentDark} 80%, #000); }`
: '',
]
.filter(Boolean)
.join('\n');
}
/**
* Get a token source for a sandboxed LiveKit session
* @param appConfig - The app configuration
* @returns A token source for a sandboxed LiveKit session
*/
export function getSandboxTokenSource(appConfig: AppConfig) {
return TokenSource.custom(async () => {
const url = new URL(process.env.NEXT_PUBLIC_CONN_DETAILS_ENDPOINT!, window.location.origin);
const sandboxId = appConfig.sandboxId ?? '';
const roomConfig = appConfig.agentName
? {
agents: [{ agent_name: appConfig.agentName }],
}
: undefined;
try {
const res = await fetch(url.toString(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Sandbox-Id': sandboxId,
},
body: JSON.stringify({
room_config: roomConfig,
}),
});
return await res.json();
} catch (error) {
console.error('Error fetching connection details:', error);
throw new Error('Error fetching connection details!');
}
});
}