Add a packaged-client runtime boundary so the shared UI can talk to local, desktop, remote, and VS Code runtimes through the right transport instead of assuming one same-origin web server. Centralize OpenChamber-owned API access behind RuntimeAPIs, runtimeFetch, and runtime URL helpers, while keeping official OpenCode traffic on the SDK path. Support runtime switching, remote host selection, desktop client credentials, and headless connection links for pairing packaged clients with remote OpenChamber servers. Harden the new auth model by moving long-lived client tokens out of browser URLs, introducing short-lived scoped URL tokens for browser-owned transports, restricting URL-token access to explicit readable/realtime routes, and making client-token management session-scoped or self-scoped as appropriate. Update browser-owned assets and preview proxy flows to work with the split runtime model, including authenticated project icons, preview token propagation, CSP-safe preview bridge injection, and preview proxy auth that survives short-lived URL-token expiry. Tighten Electron security boundaries for packaged clients by gating privileged preload state to trusted origins and requiring explicit confirmation before connect deep-links import or switch remote runtimes. Also refresh agent guidance and project skills so future runtime/API, auth, preview, UI, CLI, settings, locale, and drag-to-reorder work follows the new architecture.
366 lines
11 KiB
TypeScript
366 lines
11 KiB
TypeScript
/**
|
|
* useServerTTS Hook
|
|
*
|
|
* React hook for server-side text-to-speech playback.
|
|
* Fetches audio from the server and plays it, bypassing mobile Safari restrictions.
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const { speak, isPlaying, stop, isAvailable } = useServerTTS();
|
|
*
|
|
* // Speak text
|
|
* await speak('Hello, this is a test');
|
|
*
|
|
* // Stop playback
|
|
* stop();
|
|
* ```
|
|
*/
|
|
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { useConfigStore } from '@/stores/useConfigStore';
|
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
|
|
|
interface ServerTTSStatusCache {
|
|
available: boolean;
|
|
checkedAt: number;
|
|
}
|
|
|
|
interface UseServerTTSOptions {
|
|
enabled?: boolean;
|
|
availabilityMode?: 'auto' | 'openai' | 'openai-compatible';
|
|
}
|
|
|
|
const SERVER_TTS_STATUS_TTL_MS = 30000;
|
|
let serverTTSStatusCache: ServerTTSStatusCache | null = null;
|
|
let serverTTSStatusRequest: Promise<boolean> | null = null;
|
|
|
|
async function getServerTTSStatus(): Promise<boolean> {
|
|
const now = Date.now();
|
|
if (serverTTSStatusCache && now - serverTTSStatusCache.checkedAt < SERVER_TTS_STATUS_TTL_MS) {
|
|
return serverTTSStatusCache.available;
|
|
}
|
|
|
|
if (serverTTSStatusRequest) {
|
|
return serverTTSStatusRequest;
|
|
}
|
|
|
|
serverTTSStatusRequest = (async () => {
|
|
try {
|
|
const response = await runtimeFetch('/api/tts/status');
|
|
if (!response.ok) {
|
|
serverTTSStatusCache = { available: false, checkedAt: Date.now() };
|
|
return false;
|
|
}
|
|
|
|
const data = await response.json();
|
|
const available = Boolean(data.available);
|
|
serverTTSStatusCache = { available, checkedAt: Date.now() };
|
|
return available;
|
|
} catch {
|
|
serverTTSStatusCache = { available: false, checkedAt: Date.now() };
|
|
return false;
|
|
} finally {
|
|
serverTTSStatusRequest = null;
|
|
}
|
|
})();
|
|
|
|
return serverTTSStatusRequest;
|
|
}
|
|
|
|
export interface UseServerTTSReturn {
|
|
/** Whether TTS is currently playing */
|
|
isPlaying: boolean;
|
|
/** Whether the server TTS service is available */
|
|
isAvailable: boolean;
|
|
/** Current error if any */
|
|
error: string | null;
|
|
/** Speak the given text */
|
|
speak: (text: string, options?: SpeakOptions) => Promise<void>;
|
|
/** Stop current playback */
|
|
stop: () => void;
|
|
/** Check if service is available */
|
|
checkAvailability: () => Promise<boolean>;
|
|
/** Unlock audio for mobile Safari - call this on user gesture before speaking */
|
|
unlockAudio: () => Promise<void>;
|
|
}
|
|
|
|
export interface SpeakOptions {
|
|
/** Voice to use (defaults to coral) */
|
|
voice?: string;
|
|
/** Model to use (defaults to gpt-4o-mini-tts) */
|
|
model?: string;
|
|
/** Speech speed (0.25 to 4.0, defaults to 1.0) */
|
|
speed?: number;
|
|
/** Speech pitch shift (0.5 to 2.0, mapped to cents; 1.0 = no shift) */
|
|
pitch?: number;
|
|
/** Playback volume (0 to 1, defaults to 1.0) */
|
|
volume?: number;
|
|
/** Optional instructions for the voice */
|
|
instructions?: string;
|
|
/** Summarize long text before speaking (defaults to true) */
|
|
summarize?: boolean;
|
|
/** Provider ID for summarization model */
|
|
providerId?: string;
|
|
/** Model ID for summarization */
|
|
modelId?: string;
|
|
/** Character threshold for summarization (defaults to 200) */
|
|
threshold?: number;
|
|
/** Custom base URL for OpenAI-compatible server */
|
|
baseURL?: string;
|
|
/** Callback when playback starts */
|
|
onStart?: () => void;
|
|
/** Callback when playback ends */
|
|
onEnd?: () => void;
|
|
/** Callback on error */
|
|
onError?: (error: string) => void;
|
|
}
|
|
|
|
// Shared AudioContext for Web Audio API playback (better iOS support)
|
|
let sharedAudioContext: AudioContext | null = null;
|
|
|
|
function getAudioContext(): AudioContext {
|
|
if (!sharedAudioContext) {
|
|
sharedAudioContext = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
|
|
}
|
|
return sharedAudioContext;
|
|
}
|
|
|
|
export function useServerTTS(options: UseServerTTSOptions = {}): UseServerTTSReturn {
|
|
const enabled = options.enabled ?? true;
|
|
const availabilityMode = options.availabilityMode ?? 'auto';
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [isAvailable, setIsAvailable] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const audioSourceRef = useRef<AudioBufferSourceNode | null>(null);
|
|
const abortControllerRef = useRef<AbortController | null>(null);
|
|
|
|
// Get current model and API settings from config store.
|
|
const currentProviderId = useConfigStore((state) => state.currentProviderId);
|
|
const currentModelId = useConfigStore((state) => state.currentModelId);
|
|
const openaiApiKey = useConfigStore((state) => state.openaiApiKey);
|
|
const openaiCompatibleUrl = useConfigStore((state) => state.openaiCompatibleUrl);
|
|
const openaiCompatibleApiKey = useConfigStore((state) => state.openaiCompatibleApiKey);
|
|
|
|
// Check if server TTS is available
|
|
const checkAvailability = useCallback(async (): Promise<boolean> => {
|
|
if (!enabled) {
|
|
setIsAvailable(false);
|
|
return false;
|
|
}
|
|
|
|
const hasClientKey = Boolean(openaiApiKey && openaiApiKey.trim().length > 0);
|
|
const hasCustomUrl = Boolean(openaiCompatibleUrl && openaiCompatibleUrl.trim().length > 0);
|
|
if (availabilityMode === 'openai-compatible') {
|
|
setIsAvailable(hasCustomUrl);
|
|
return hasCustomUrl;
|
|
}
|
|
|
|
if (hasClientKey) {
|
|
setIsAvailable(true);
|
|
return true;
|
|
}
|
|
|
|
if (availabilityMode === 'auto' && hasCustomUrl) {
|
|
setIsAvailable(true);
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
const hasServerKey = await getServerTTSStatus();
|
|
setIsAvailable(hasServerKey);
|
|
return hasServerKey;
|
|
} catch {
|
|
setIsAvailable(false);
|
|
return false;
|
|
}
|
|
}, [availabilityMode, enabled, openaiApiKey, openaiCompatibleUrl]);
|
|
|
|
// Check availability on mount and when API key changes
|
|
useEffect(() => {
|
|
void checkAvailability();
|
|
}, [checkAvailability]);
|
|
|
|
// Stop current playback
|
|
const stop = useCallback(() => {
|
|
// Stop Web Audio API source
|
|
if (audioSourceRef.current) {
|
|
try {
|
|
audioSourceRef.current.stop();
|
|
} catch {
|
|
// Already stopped
|
|
}
|
|
audioSourceRef.current = null;
|
|
}
|
|
|
|
if (abortControllerRef.current) {
|
|
abortControllerRef.current.abort();
|
|
abortControllerRef.current = null;
|
|
}
|
|
|
|
setIsPlaying(false);
|
|
}, []);
|
|
|
|
// Pre-unlock audio for mobile Safari
|
|
// This must be called within a user gesture context
|
|
const unlockAudio = useCallback(async (): Promise<void> => {
|
|
try {
|
|
// Get or create AudioContext
|
|
const ctx = getAudioContext();
|
|
|
|
// Resume if suspended (required for iOS Safari)
|
|
if (ctx.state === 'suspended') {
|
|
await ctx.resume();
|
|
console.log('[useServerTTS] AudioContext resumed');
|
|
}
|
|
|
|
// Play a tiny silent buffer to fully unlock
|
|
const buffer = ctx.createBuffer(1, 1, 22050);
|
|
const source = ctx.createBufferSource();
|
|
source.buffer = buffer;
|
|
source.connect(ctx.destination);
|
|
source.start(0);
|
|
|
|
console.log('[useServerTTS] Audio unlocked for mobile playback');
|
|
} catch (err) {
|
|
console.error('[useServerTTS] Failed to unlock audio:', err);
|
|
}
|
|
}, []);
|
|
|
|
// Speak text using server TTS
|
|
const speak = useCallback(async (text: string, options?: SpeakOptions): Promise<void> => {
|
|
// Stop any existing playback
|
|
stop();
|
|
|
|
if (!text.trim()) {
|
|
setError('No text to speak');
|
|
options?.onError?.('No text to speak');
|
|
return;
|
|
}
|
|
|
|
setError(null);
|
|
|
|
try {
|
|
// Unlock audio context first (required for mobile Safari)
|
|
// Must be done before any async operations to stay within user gesture context
|
|
const ctx = getAudioContext();
|
|
if (ctx.state === 'suspended') {
|
|
await ctx.resume();
|
|
console.log('[useServerTTS] AudioContext resumed');
|
|
}
|
|
|
|
// Play a silent buffer to fully unlock audio on iOS
|
|
const silentBuffer = ctx.createBuffer(1, 1, 22050);
|
|
const silentSource = ctx.createBufferSource();
|
|
silentSource.buffer = silentBuffer;
|
|
silentSource.connect(ctx.destination);
|
|
silentSource.start(0);
|
|
|
|
// Create abort controller for this request
|
|
abortControllerRef.current = new AbortController();
|
|
|
|
const voice = options?.voice || 'nova';
|
|
console.log('[useServerTTS] Speaking with voice:', voice, 'options:', options);
|
|
|
|
// Fetch audio from server
|
|
const response = await runtimeFetch('/api/tts/speak', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
text: text.trim(),
|
|
voice,
|
|
model: options?.model || undefined,
|
|
speed: options?.speed || 0.9,
|
|
instructions: options?.instructions,
|
|
summarize: false,
|
|
// Use provided provider/model, or fall back to current chat model
|
|
providerId: options?.providerId || currentProviderId || undefined,
|
|
modelId: options?.modelId || currentModelId || undefined,
|
|
// Send API key from settings if available
|
|
apiKey: options?.baseURL ? (openaiCompatibleApiKey || undefined) : (openaiApiKey || undefined),
|
|
// Send custom base URL for OpenAI-compatible servers
|
|
baseURL: options?.baseURL || undefined,
|
|
}),
|
|
signal: abortControllerRef.current.signal,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }));
|
|
throw new Error(errorData.error || `HTTP ${response.status}`);
|
|
}
|
|
|
|
// Get audio data from response
|
|
const audioBlob = await response.blob();
|
|
const arrayBuffer = await audioBlob.arrayBuffer();
|
|
|
|
// Decode audio data using the same context we unlocked earlier
|
|
console.log('[useServerTTS] Decoding audio data...');
|
|
const audioBuffer = await ctx.decodeAudioData(arrayBuffer);
|
|
|
|
// Create source node
|
|
const source = ctx.createBufferSource();
|
|
source.buffer = audioBuffer;
|
|
|
|
// Apply pitch shift via detune (cents): 1200 cents = 1 octave
|
|
const pitch = options?.pitch ?? 1.0;
|
|
if (pitch !== 1.0) {
|
|
source.detune.value = (pitch - 1.0) * 1200;
|
|
}
|
|
|
|
// Apply volume via GainNode
|
|
const volume = options?.volume ?? 1.0;
|
|
const gainNode = ctx.createGain();
|
|
gainNode.gain.value = volume;
|
|
|
|
source.connect(gainNode);
|
|
gainNode.connect(ctx.destination);
|
|
audioSourceRef.current = source;
|
|
|
|
// Set up event handlers
|
|
source.onended = () => {
|
|
console.log('[useServerTTS] Audio playback ended');
|
|
setIsPlaying(false);
|
|
audioSourceRef.current = null;
|
|
options?.onEnd?.();
|
|
};
|
|
|
|
// Start playback
|
|
console.log('[useServerTTS] Starting audio playback via Web Audio API...');
|
|
setIsPlaying(true);
|
|
options?.onStart?.();
|
|
source.start(0);
|
|
|
|
} catch (err) {
|
|
if ((err as Error).name === 'AbortError') {
|
|
// Request was aborted, don't show error
|
|
return;
|
|
}
|
|
|
|
const errorMsg = err instanceof Error ? err.message : 'Failed to speak';
|
|
console.error('[useServerTTS] Error:', errorMsg);
|
|
setError(errorMsg);
|
|
options?.onError?.(errorMsg);
|
|
setIsPlaying(false);
|
|
}
|
|
}, [stop, currentProviderId, currentModelId, openaiApiKey, openaiCompatibleApiKey]);
|
|
|
|
// Cleanup on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
stop();
|
|
};
|
|
}, [stop]);
|
|
|
|
return {
|
|
isPlaying,
|
|
isAvailable,
|
|
error,
|
|
speak,
|
|
stop,
|
|
checkAvailability,
|
|
unlockAudio,
|
|
};
|
|
}
|