Based on the git diff showing extensive package-lock.json changes and modifications to the agent system prompt and configuration, here's the commit message:

```
feat: add AI SDK dependencies and refine agent voice interaction config

- Add @ai-sdk packages for enhanced AI capabilities
- Install Deepgram SDK, livekit-client, and werift for voice/WebRTC
- Expand agent system prompt with multi-tool workflows and Claude Code interaction patterns
- Adjust VAD timing (1.2s silence threshold) and turn detection for better voice UX
- Switch TTS to inworld/inworld-tts-1 with Olivia voice
- Configure interruption timing (0.2s detection, no word requirement)
- Add comprehensive guidelines for conversational responses vs structured output
- Document plan mode triggers and Claude response type handling

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
```
This commit is contained in:
Mohamed Boudra
2025-10-19 15:41:42 +02:00
parent dabcf63ebc
commit 40b199e329
44 changed files with 12183 additions and 1715 deletions
+391
View File
@@ -0,0 +1,391 @@
.app {
display: flex;
flex-direction: column;
min-height: 100vh;
width: 100%;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 2rem;
background-color: #1a1a1a;
border-bottom: 1px solid #333;
}
.header h1 {
font-size: 1.5rem;
font-weight: 600;
}
.status-indicator {
padding: 0.5rem 1rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
}
.status-indicator.disconnected {
background-color: #444;
color: #999;
}
.status-indicator.connecting {
background-color: #ffa500;
color: #000;
animation: pulse 1.5s ease-in-out infinite;
}
.status-indicator.connected {
background-color: #22c55e;
color: #000;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
.main {
display: flex;
flex: 1;
padding: 2rem;
gap: 2rem;
}
.chat-interface {
flex: 1;
background-color: #1a1a1a;
border-radius: 8px;
padding: 2rem;
display: flex;
flex-direction: column;
gap: 1rem;
max-height: calc(100vh - 120px);
}
.chat-interface h2 {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
padding-bottom: 0.75rem;
border-bottom: 1px solid #333;
}
.ping-button {
padding: 0.75rem 2rem;
font-size: 1rem;
font-weight: 500;
background-color: #22c55e;
color: #000;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.2s;
}
.ping-button:hover:not(:disabled) {
background-color: #16a34a;
}
.ping-button:disabled {
background-color: #444;
color: #999;
cursor: not-allowed;
}
.connection-info {
color: #999;
font-size: 0.875rem;
text-align: center;
max-width: 400px;
}
.activity-log {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.log-entries {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.log-entry {
padding: 0.75rem;
border-radius: 4px;
font-size: 0.875rem;
line-height: 1.4;
display: flex;
gap: 0.75rem;
}
.log-time {
font-family: monospace;
color: #666;
flex-shrink: 0;
}
.log-message {
flex: 1;
}
.log-entry.system {
background-color: #2a2a2a;
color: #999;
font-style: italic;
}
.log-entry.info {
background-color: #1e3a8a;
color: #93c5fd;
}
.log-entry.success {
background-color: #14532d;
color: #86efac;
}
.log-entry.error {
background-color: #7f1d1d;
color: #fca5a5;
}
.log-entry.user {
background-color: #1e293b;
color: #e2e8f0;
}
.log-entry.assistant {
background-color: #0f766e;
color: #ccfbf1;
}
.log-entry.tool {
background-color: #581c87;
color: #e9d5ff;
font-family: monospace;
font-size: 0.75rem;
}
.log-entry.streaming {
opacity: 0.8;
}
.streaming-indicator {
animation: dots 1.5s steps(3, end) infinite;
color: #666;
}
@keyframes dots {
0%, 20% { content: '.'; }
40% { content: '..'; }
60%, 100% { content: '...'; }
}
.log-metadata {
margin-top: 0.5rem;
}
.log-metadata summary {
cursor: pointer;
color: #999;
font-size: 0.75rem;
}
.log-metadata pre {
margin-top: 0.5rem;
padding: 0.5rem;
background-color: #000;
border-radius: 4px;
overflow-x: auto;
font-size: 0.75rem;
}
.message-form {
display: flex;
gap: 0.5rem;
padding-top: 1rem;
border-top: 1px solid #333;
}
.message-input {
flex: 1;
padding: 0.75rem 1rem;
background-color: #2a2a2a;
border: 1px solid #444;
border-radius: 6px;
color: #fff;
font-size: 0.875rem;
outline: none;
}
.message-input:focus {
border-color: #22c55e;
}
.message-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.send-button {
padding: 0.75rem 1.5rem;
background-color: #22c55e;
color: #000;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.send-button:hover:not(:disabled) {
background-color: #16a34a;
}
.send-button:disabled {
background-color: #444;
color: #999;
cursor: not-allowed;
}
.test-controls {
padding-top: 0.5rem;
border-top: 1px solid #333;
display: flex;
justify-content: center;
}
/* Voice Controls */
.voice-controls {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
background-color: #2a2a2a;
border-radius: 6px;
margin-bottom: 1rem;
}
.voice-button {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1rem 2rem;
background-color: #3b82f6;
color: #fff;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.voice-button:hover:not(:disabled) {
background-color: #2563eb;
transform: scale(1.02);
}
.voice-button:disabled {
background-color: #444;
color: #999;
cursor: not-allowed;
transform: none;
}
.voice-button.recording {
background-color: #ef4444;
animation: pulse-red 1.5s ease-in-out infinite;
}
.voice-button.recording:hover:not(:disabled) {
background-color: #dc2626;
}
.voice-button.processing {
background-color: #f59e0b;
}
@keyframes pulse-red {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.recording-indicator {
display: inline-block;
width: 12px;
height: 12px;
background-color: #fff;
border-radius: 50%;
animation: pulse 1s ease-in-out infinite;
}
.processing-indicator {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid #fff;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.microphone-icon {
font-size: 1.25rem;
}
.voice-error {
padding: 0.75rem;
background-color: #7f1d1d;
color: #fca5a5;
border-radius: 4px;
font-size: 0.875rem;
}
.permission-denied {
padding: 0.75rem;
background-color: #7c2d12;
color: #fed7aa;
border-radius: 4px;
font-size: 0.875rem;
}
.playing-indicator {
padding: 0.5rem;
background-color: #0f766e;
color: #ccfbf1;
border-radius: 4px;
font-size: 0.875rem;
text-align: center;
}
@media (max-width: 1024px) {
.main {
flex-direction: column;
}
}
+260
View File
@@ -0,0 +1,260 @@
import { useState, useEffect, useRef } from 'react';
import { useWebSocket } from './hooks/useWebSocket';
import { VoiceControls } from './components/VoiceControls';
import { createAudioPlayer } from './lib/audio-playback';
import './App.css';
interface LogEntry {
id: string;
timestamp: number;
type: 'system' | 'info' | 'success' | 'error' | 'user' | 'assistant' | 'tool';
message: string;
metadata?: Record<string, unknown>;
}
function App() {
const [logs, setLogs] = useState<LogEntry[]>([
{ id: '1', timestamp: Date.now(), type: 'system', message: 'System initialized' },
]);
const [userInput, setUserInput] = useState('');
const [currentAssistantMessage, setCurrentAssistantMessage] = useState('');
const [isProcessingAudio, setIsProcessingAudio] = useState(false);
const [isPlayingAudio, setIsPlayingAudio] = useState(false);
const logEndRef = useRef<HTMLDivElement>(null);
const audioPlayerRef = useRef(createAudioPlayer());
// WebSocket URL - use ws://localhost:3000/ws in dev, or construct from current host in prod
const wsUrl = `ws://${window.location.host}/ws`;
const ws = useWebSocket(wsUrl);
const addLog = (
type: LogEntry['type'],
message: string,
metadata?: Record<string, unknown>
) => {
console.trace("addLog", type, message, metadata);
setLogs((prev) => [
...prev,
{ id: Date.now().toString(), timestamp: Date.now(), type, message, metadata },
]);
};
useEffect(() => {
// Listen for status messages
const unsubStatus = ws.on('status', (payload: unknown) => {
const data = payload as { status: string; message?: string };
addLog('info', data.message || `Status: ${data.status}`);
});
// Listen for pong messages
const unsubPong = ws.on('pong', () => {
addLog('success', 'Received pong from server');
});
// Listen for activity log messages
const unsubActivity = ws.on('activity_log', (payload: unknown) => {
const data = payload as {
type: string;
content: string;
metadata?: Record<string, unknown>;
};
// Map activity log types to UI log types
let logType: LogEntry['type'] = 'info';
if (data.type === 'transcript') logType = 'user';
else if (data.type === 'assistant') logType = 'assistant';
else if (data.type === 'tool_call' || data.type === 'tool_result') logType = 'tool';
else if (data.type === 'error') logType = 'error';
addLog(logType, data.content, data.metadata);
});
// Listen for streaming assistant chunks
const unsubChunk = ws.on('assistant_chunk', (payload: unknown) => {
const data = payload as { chunk: string };
setCurrentAssistantMessage((prev) => prev + data.chunk);
});
// Listen for transcription results
const unsubTranscription = ws.on('transcription_result', (payload: unknown) => {
const data = payload as { text: string };
addLog('info', `Transcription: ${data.text}`);
setIsProcessingAudio(false);
});
// Listen for audio output (TTS)
const unsubAudioOutput = ws.on('audio_output', async (payload: unknown) => {
const data = payload as { audio: string; format: string; id: string };
try {
addLog('info', 'Playing assistant audio response...');
setIsPlayingAudio(true);
// Decode base64 audio
const binaryString = atob(data.audio);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
// Create blob with correct mime type
const mimeType = data.format === 'mp3' ? 'audio/mpeg' : `audio/${data.format}`;
const audioBlob = new Blob([bytes], { type: mimeType });
// Play audio
await audioPlayerRef.current.play(audioBlob);
setIsPlayingAudio(false);
addLog('success', 'Audio playback complete');
} catch (error: any) {
console.error('[App] Audio playback error:', error);
addLog('error', `Audio playback failed: ${error.message}`);
setIsPlayingAudio(false);
}
});
return () => {
unsubStatus();
unsubPong();
unsubActivity();
unsubChunk();
unsubTranscription();
unsubAudioOutput();
};
}, [ws]);
useEffect(() => {
if (ws.isConnected) {
addLog('success', 'WebSocket connected');
} else {
addLog('system', 'WebSocket disconnected');
}
}, [ws.isConnected]);
// Auto-scroll to bottom when new logs are added
useEffect(() => {
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [logs, currentAssistantMessage]);
const handlePing = () => {
ws.sendPing();
addLog('info', 'Sent ping to server');
};
const handleSendMessage = (e: React.FormEvent) => {
e.preventDefault();
if (!userInput.trim() || !ws.isConnected) return;
// Send message to server
ws.sendUserMessage(userInput);
// Clear input and reset streaming state
setUserInput('');
setCurrentAssistantMessage('');
};
const handleAudioRecorded = async (audioBlob: Blob, format: string) => {
if (!ws.isConnected) {
addLog('error', 'Cannot send audio - not connected to server');
return;
}
try {
setIsProcessingAudio(true);
addLog('info', 'Sending audio to server...');
// Convert blob to base64
const arrayBuffer = await audioBlob.arrayBuffer();
const base64Audio = btoa(
new Uint8Array(arrayBuffer).reduce((data, byte) => data + String.fromCharCode(byte), '')
);
// Send audio chunk via WebSocket
ws.send({
type: 'audio_chunk',
payload: {
audio: base64Audio,
format: format,
isLast: true,
},
});
console.log(`[App] Sent audio: ${audioBlob.size} bytes, format: ${format}`);
} catch (error: any) {
console.error('[App] Error sending audio:', error);
addLog('error', `Failed to send audio: ${error.message}`);
setIsProcessingAudio(false);
}
};
return (
<div className="app">
<header className="header">
<h1>Voice Assistant</h1>
<div className={`status-indicator ${ws.isConnected ? 'connected' : 'disconnected'}`}>
{ws.isConnected ? 'connected' : 'disconnected'}
</div>
</header>
<main className="main">
<div className="chat-interface">
<h2>Chat with Assistant</h2>
<VoiceControls
onAudioRecorded={handleAudioRecorded}
isProcessing={isProcessingAudio}
isPlaying={isPlayingAudio}
/>
<div className="activity-log">
<div className="log-entries">
{logs.map((log) => (
<div key={log.id} className={`log-entry ${log.type}`}>
<span className="log-time">{new Date(log.timestamp).toLocaleTimeString()}</span>
<span className="log-message">{log.message}</span>
{log.metadata && (
<details className="log-metadata">
<summary>Details</summary>
<pre>{JSON.stringify(log.metadata, null, 2)}</pre>
</details>
)}
</div>
))}
{currentAssistantMessage && (
<div className="log-entry assistant streaming">
<span className="log-time">{new Date().toLocaleTimeString()}</span>
<span className="log-message">{currentAssistantMessage}</span>
<span className="streaming-indicator">...</span>
</div>
)}
<div ref={logEndRef} />
</div>
</div>
<form onSubmit={handleSendMessage} className="message-form">
<input
type="text"
value={userInput}
onChange={(e) => setUserInput(e.target.value)}
placeholder="Type a message to the assistant..."
disabled={!ws.isConnected}
className="message-input"
/>
<button type="submit" disabled={!ws.isConnected || !userInput.trim()} className="send-button">
Send
</button>
</form>
<div className="test-controls">
<button onClick={handlePing} disabled={!ws.isConnected} className="ping-button">
Send Ping (Test)
</button>
</div>
</div>
</main>
</div>
);
}
export default App;
@@ -0,0 +1,113 @@
import { useState, useEffect, useRef } from 'react';
import { createAudioRecorder, type AudioRecorder } from '../lib/audio-capture';
interface VoiceControlsProps {
onAudioRecorded: (audio: Blob, format: string) => void;
isProcessing: boolean;
isPlaying?: boolean;
}
export function VoiceControls({ onAudioRecorded, isProcessing, isPlaying }: VoiceControlsProps) {
const [isRecording, setIsRecording] = useState(false);
const [error, setError] = useState<string | null>(null);
const [permissionState, setPermissionState] = useState<'prompt' | 'granted' | 'denied'>('prompt');
const recorderRef = useRef<AudioRecorder | null>(null);
useEffect(() => {
// Initialize recorder
recorderRef.current = createAudioRecorder();
// Check microphone permission
if (navigator.permissions) {
navigator.permissions
.query({ name: 'microphone' as PermissionName })
.then((result) => {
setPermissionState(result.state);
result.onchange = () => {
setPermissionState(result.state);
};
})
.catch(() => {
setPermissionState('prompt');
});
}
}, []);
async function handleToggleRecording() {
const recorder = recorderRef.current;
if (!recorder) return;
try {
setError(null);
if (isRecording) {
// Stop recording
console.log('[VoiceControls] Stopping recording...');
const audioBlob = await recorder.stop();
setIsRecording(false);
// Get format from blob type
const format = audioBlob.type || 'audio/webm';
console.log(`[VoiceControls] Recording complete: ${audioBlob.size} bytes, format: ${format}`);
// Send to parent
onAudioRecorded(audioBlob, format);
} else {
// Start recording
console.log('[VoiceControls] Starting recording...');
await recorder.start();
setIsRecording(true);
setPermissionState('granted');
}
} catch (err: any) {
console.error('[VoiceControls] Recording error:', err);
setError(err.message || 'Failed to access microphone');
setIsRecording(false);
if (err.message.includes('Permission denied')) {
setPermissionState('denied');
}
}
}
const canRecord = !isProcessing && !isPlaying;
const buttonDisabled = !canRecord || permissionState === 'denied';
return (
<div className="voice-controls">
<button
onClick={handleToggleRecording}
disabled={buttonDisabled}
className={`voice-button ${isRecording ? 'recording' : ''} ${isProcessing ? 'processing' : ''}`}
title={isRecording ? 'Stop recording' : 'Start recording'}
>
{isRecording ? (
<>
<span className="recording-indicator" />
<span>Recording...</span>
</>
) : isProcessing ? (
<>
<span className="processing-indicator" />
<span>Processing...</span>
</>
) : (
<>
<span className="microphone-icon">🎤</span>
<span>Record</span>
</>
)}
</button>
{error && <div className="voice-error">{error}</div>}
{permissionState === 'denied' && (
<div className="permission-denied">
Microphone access denied. Please enable it in your browser settings.
</div>
)}
{isPlaying && <div className="playing-indicator">🔊 Playing response...</div>}
</div>
);
}
@@ -0,0 +1,136 @@
import { useEffect, useRef, useState, useCallback } from 'react';
interface WebSocketMessage {
type: string;
payload: unknown;
}
type MessageHandler = (payload: unknown) => void;
export interface UseWebSocketReturn {
isConnected: boolean;
send: (message: WebSocketMessage) => void;
on: (type: string, handler: MessageHandler) => () => void;
sendPing: () => void;
sendUserMessage: (message: string) => void;
}
export function useWebSocket(url: string): UseWebSocketReturn {
const [isConnected, setIsConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
const handlersRef = useRef<Map<string, Set<MessageHandler>>>(new Map());
const reconnectTimeoutRef = useRef<number>();
const connect = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
return;
}
try {
const ws = new WebSocket(url);
ws.onopen = () => {
console.log('[WS] Connected to server');
setIsConnected(true);
};
ws.onclose = () => {
console.log('[WS] Disconnected from server');
setIsConnected(false);
// Attempt to reconnect after 3 seconds
reconnectTimeoutRef.current = window.setTimeout(() => {
console.log('[WS] Attempting to reconnect...');
connect();
}, 3000);
};
ws.onerror = (error) => {
console.error('[WS] Error:', error);
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data) as WebSocketMessage;
console.log(`[WS] Received message type: ${message.type}`, message.payload);
// Call all registered handlers for this message type
const handlers = handlersRef.current.get(message.type);
if (handlers) {
handlers.forEach((handler) => {
try {
handler(message.payload);
} catch (err) {
console.error(`[WS] Error in handler for ${message.type}:`, err);
}
});
}
} catch (err) {
console.error('[WS] Failed to parse message:', err);
}
};
wsRef.current = ws;
} catch (err) {
console.error('[WS] Failed to create WebSocket:', err);
}
}, [url]);
useEffect(() => {
connect();
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (wsRef.current) {
wsRef.current.close();
}
};
}, [connect]);
const send = useCallback((message: WebSocketMessage) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(message));
} else {
console.warn('[WS] Cannot send message - not connected');
}
}, []);
const on = useCallback((type: string, handler: MessageHandler) => {
if (!handlersRef.current.has(type)) {
handlersRef.current.set(type, new Set());
}
handlersRef.current.get(type)!.add(handler);
// Return cleanup function
return () => {
const handlers = handlersRef.current.get(type);
if (handlers) {
handlers.delete(handler);
if (handlers.size === 0) {
handlersRef.current.delete(type);
}
}
};
}, []);
const sendPing = useCallback(() => {
send({ type: 'ping', payload: {} });
}, [send]);
const sendUserMessage = useCallback(
(message: string) => {
send({ type: 'user_message', payload: { message } });
},
[send]
);
return {
isConnected,
send,
on,
sendPing,
sendUserMessage,
};
}
+31
View File
@@ -0,0 +1,31 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
#root {
width: 100%;
min-height: 100vh;
}
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Voice Assistant</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,184 @@
export interface AudioCaptureConfig {
mimeType?: string;
audioBitsPerSecond?: number;
}
export interface AudioRecorder {
start(): Promise<void>;
stop(): Promise<Blob>;
isRecording(): boolean;
getSupportedMimeType(): string | null;
}
export function createAudioRecorder(config?: AudioCaptureConfig): AudioRecorder {
let mediaRecorder: MediaRecorder | null = null;
let audioChunks: Blob[] = [];
let stream: MediaStream | null = null;
let recording = false;
// Detect supported mime type
function getSupportedMimeType(): string | null {
const types = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/ogg;codecs=opus',
'audio/ogg',
'audio/mp4',
'audio/wav',
];
for (const type of types) {
if (MediaRecorder.isTypeSupported(type)) {
return type;
}
}
return null;
}
async function start(): Promise<void> {
if (recording) {
throw new Error('Already recording');
}
try {
// Request microphone permission with WebRTC audio processing constraints
stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true, // Remove echo from speakers
noiseSuppression: true, // Remove background noise
autoGainControl: true, // Normalize volume
sampleRate: 16000, // Optimal for speech (Whisper works well with this)
channelCount: 1, // Mono audio (sufficient for voice)
},
video: false,
});
// Check which constraints were actually applied
const track = stream.getAudioTracks()[0];
const settings = track.getSettings();
console.log('[AudioRecorder] Audio settings:', settings);
// Try WebM/Opus first (best compression and quality for speech)
const mimeType = config?.mimeType ||
(MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: getSupportedMimeType());
if (!mimeType) {
throw new Error('No supported audio mime type found');
}
// Create MediaRecorder
const options: MediaRecorderOptions = {
mimeType,
};
if (config?.audioBitsPerSecond) {
options.audioBitsPerSecond = config.audioBitsPerSecond;
}
mediaRecorder = new MediaRecorder(stream, options);
audioChunks = [];
// Collect data chunks
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunks.push(event.data);
console.log(`[AudioRecorder] Chunk received: ${event.data.size} bytes`);
}
};
// Start recording
mediaRecorder.start(100); // Collect chunks every 100ms
recording = true;
console.log('[AudioRecorder] Recording started with constraints:', {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
sampleRate: 16000,
channelCount: 1,
mimeType,
});
} catch (error: any) {
console.error('[AudioRecorder] Failed to start recording:', error);
if (stream) {
stream.getTracks().forEach((track) => track.stop());
stream = null;
}
throw new Error(`Failed to start audio recording: ${error.message}`);
}
}
async function stop(): Promise<Blob> {
if (!recording || !mediaRecorder) {
throw new Error('Not recording');
}
return new Promise((resolve, reject) => {
if (!mediaRecorder) {
reject(new Error('MediaRecorder not initialized'));
return;
}
mediaRecorder.onstop = () => {
// Create blob from chunks
const mimeType = mediaRecorder?.mimeType || 'audio/webm';
const audioBlob = new Blob(audioChunks, { type: mimeType });
console.log('[AudioRecorder] Recording stopped:', {
size: audioBlob.size,
type: mimeType,
chunks: audioChunks.length,
});
// Clean up
if (stream) {
stream.getTracks().forEach((track) => {
track.stop();
console.log('[AudioRecorder] Stopped track:', track.label);
});
stream = null;
}
recording = false;
mediaRecorder = null;
audioChunks = [];
resolve(audioBlob);
};
mediaRecorder.onerror = (event: Event) => {
console.error('[AudioRecorder] MediaRecorder error:', event);
reject(new Error('MediaRecorder error during stop'));
};
// Stop recording
mediaRecorder.stop();
});
}
function isRecordingFunc(): boolean {
return recording;
}
return {
start,
stop,
isRecording: isRecordingFunc,
getSupportedMimeType,
};
}
export function checkMicrophonePermission(): Promise<PermissionState> {
// Check if browser supports permissions API
if (!navigator.permissions) {
return Promise.resolve('prompt' as PermissionState);
}
return navigator.permissions
.query({ name: 'microphone' as PermissionName })
.then((result) => result.state)
.catch(() => 'prompt' as PermissionState);
}
@@ -0,0 +1,116 @@
export interface AudioPlayer {
play(audioData: Blob): Promise<void>;
stop(): void;
isPlaying(): boolean;
clearQueue(): void;
}
export function createAudioPlayer(): AudioPlayer {
let currentAudio: HTMLAudioElement | null = null;
let playing = false;
let queue: Blob[] = [];
let isProcessingQueue = false;
async function play(audioData: Blob): Promise<void> {
// Add to queue
queue.push(audioData);
// Start processing queue if not already processing
if (!isProcessingQueue) {
processQueue();
}
}
async function processQueue(): Promise<void> {
if (isProcessingQueue || queue.length === 0) {
return;
}
isProcessingQueue = true;
while (queue.length > 0) {
const audioData = queue.shift()!;
await playAudio(audioData);
}
isProcessingQueue = false;
}
async function playAudio(audioData: Blob): Promise<void> {
return new Promise((resolve, reject) => {
try {
// Create blob URL
const audioUrl = URL.createObjectURL(audioData);
// Create audio element
const audio = new Audio(audioUrl);
currentAudio = audio;
playing = true;
console.log(`[AudioPlayer] Playing audio (${audioData.size} bytes, type: ${audioData.type})`);
audio.onended = () => {
console.log('[AudioPlayer] Playback finished');
playing = false;
currentAudio = null;
// Clean up blob URL
URL.revokeObjectURL(audioUrl);
resolve();
};
audio.onerror = (error) => {
console.error('[AudioPlayer] Playback error:', error);
playing = false;
currentAudio = null;
// Clean up blob URL
URL.revokeObjectURL(audioUrl);
reject(new Error('Audio playback failed'));
};
// Start playback
audio.play().catch((error) => {
console.error('[AudioPlayer] Failed to start playback:', error);
playing = false;
currentAudio = null;
URL.revokeObjectURL(audioUrl);
reject(error);
});
} catch (error) {
console.error('[AudioPlayer] Error creating audio element:', error);
playing = false;
currentAudio = null;
reject(error);
}
});
}
function stop(): void {
if (currentAudio) {
currentAudio.pause();
currentAudio.currentTime = 0;
currentAudio = null;
}
playing = false;
queue = [];
isProcessingQueue = false;
}
function isPlayingFunc(): boolean {
return playing;
}
function clearQueue(): void {
queue = [];
}
return {
play,
stop,
isPlaying: isPlayingFunc,
clearQueue,
};
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)