377 lines
10 KiB
TypeScript
377 lines
10 KiB
TypeScript
import { useState, useRef, useEffect, useCallback } from 'react';
|
|
import { useSocket, type RobotState } from './hooks/useSocket';
|
|
import { useMicrophone } from './hooks/useMicrophone';
|
|
|
|
const STATE_COLORS: Record<RobotState, string> = {
|
|
disconnected: '#666',
|
|
idle: '#4caf50',
|
|
listening: '#2196f3',
|
|
thinking: '#ff9800',
|
|
speaking: '#ab47bc',
|
|
};
|
|
|
|
const STATE_LABELS: Record<RobotState, string> = {
|
|
disconnected: 'Déconnecté',
|
|
idle: 'Prêt',
|
|
listening: 'Écoute...',
|
|
thinking: 'Réflexion...',
|
|
speaking: 'Parle...',
|
|
};
|
|
|
|
const LS_KEY_TOKEN = 'tipote_device_token';
|
|
const LS_KEY_URL = 'tipote_server_url';
|
|
|
|
function App() {
|
|
const [serverUrl, setServerUrl] = useState(() => localStorage.getItem(LS_KEY_URL) || 'http://localhost:3000');
|
|
const [deviceToken, setDeviceToken] = useState(() => localStorage.getItem(LS_KEY_TOKEN) || '');
|
|
const [conversationActive, setConversationActive] = useState(false);
|
|
const [showLogs, setShowLogs] = useState(false);
|
|
const { state, connected, logs, connect, disconnect, emit, clearLogs } = useSocket();
|
|
const logsEndRef = useRef<HTMLDivElement>(null);
|
|
const prevStateRef = useRef<RobotState>('disconnected');
|
|
|
|
const onAudioChunk = useCallback(
|
|
(chunk: ArrayBuffer, sampleRate: number) => {
|
|
emit('audio_chunk', { data: chunk, sampleRate });
|
|
},
|
|
[emit],
|
|
);
|
|
|
|
const onSpeechEnd = useCallback(() => {
|
|
emit('speech_end');
|
|
}, [emit]);
|
|
|
|
const { recording, start: startMic, stop: stopMic, silentStop } = useMicrophone({ onAudioChunk, onSpeechEnd });
|
|
|
|
// Stop mic when Ti-Pote starts thinking/speaking
|
|
useEffect(() => {
|
|
if (recording && (state === 'thinking' || state === 'speaking')) {
|
|
silentStop();
|
|
}
|
|
}, [state, recording, silentStop]);
|
|
|
|
// Auto-restart listening when Ti-Pote finishes speaking
|
|
useEffect(() => {
|
|
const prevState = prevStateRef.current;
|
|
prevStateRef.current = state;
|
|
|
|
if (conversationActive && state === 'idle' && (prevState === 'speaking' || prevState === 'thinking')) {
|
|
const timer = setTimeout(() => {
|
|
emit('wake_word_detected');
|
|
startMic();
|
|
}, 500);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [state, conversationActive, emit, startMic]);
|
|
|
|
useEffect(() => {
|
|
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
}, [logs]);
|
|
|
|
// Persist settings to localStorage
|
|
const handleServerUrlChange = (value: string) => {
|
|
setServerUrl(value);
|
|
localStorage.setItem(LS_KEY_URL, value);
|
|
};
|
|
|
|
const handleTokenChange = (value: string) => {
|
|
setDeviceToken(value);
|
|
localStorage.setItem(LS_KEY_TOKEN, value);
|
|
};
|
|
|
|
const handleConnect = () => {
|
|
if (connected) {
|
|
disconnect();
|
|
setConversationActive(false);
|
|
} else {
|
|
if (!deviceToken.trim()) return alert('Device token requis');
|
|
connect(serverUrl, deviceToken.trim());
|
|
}
|
|
};
|
|
|
|
const handleConversation = () => {
|
|
if (conversationActive) {
|
|
setConversationActive(false);
|
|
emit('user_interrupt');
|
|
if (recording) stopMic();
|
|
} else {
|
|
setConversationActive(true);
|
|
emit('wake_word_detected');
|
|
startMic();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div style={styles.container}>
|
|
{/* Header */}
|
|
<div style={styles.header}>
|
|
<h1 style={styles.title}>Ti-Pote Simulator</h1>
|
|
<div
|
|
style={{
|
|
...styles.statusBadge,
|
|
background: STATE_COLORS[state],
|
|
boxShadow: state !== 'disconnected' ? `0 0 10px ${STATE_COLORS[state]}60` : 'none',
|
|
}}
|
|
>
|
|
{STATE_LABELS[state]}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Connection */}
|
|
{!connected ? (
|
|
<div style={styles.panel}>
|
|
<h2 style={styles.panelTitle}>Connexion</h2>
|
|
<div style={styles.field}>
|
|
<label style={styles.label}>Server URL</label>
|
|
<input
|
|
style={styles.input}
|
|
value={serverUrl}
|
|
onChange={(e) => handleServerUrlChange(e.target.value)}
|
|
placeholder="http://localhost:3000"
|
|
/>
|
|
</div>
|
|
<div style={styles.field}>
|
|
<label style={styles.label}>Device Token</label>
|
|
<input
|
|
style={styles.input}
|
|
value={deviceToken}
|
|
onChange={(e) => handleTokenChange(e.target.value)}
|
|
placeholder="Coller le JWT du device ici"
|
|
/>
|
|
</div>
|
|
<button
|
|
style={{ ...styles.btn, background: '#4caf50', width: '100%', padding: '12px 20px', fontSize: 16 }}
|
|
onClick={handleConnect}
|
|
disabled={!deviceToken.trim()}
|
|
>
|
|
Connecter
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Main action */}
|
|
<div style={styles.panel}>
|
|
<button
|
|
style={{
|
|
...styles.btn,
|
|
width: '100%',
|
|
padding: '20px',
|
|
fontSize: 18,
|
|
background: conversationActive ? '#f44336' : '#2196f3',
|
|
borderRadius: 12,
|
|
transition: 'all 0.2s',
|
|
}}
|
|
onClick={handleConversation}
|
|
>
|
|
{conversationActive ? 'Arrêter la conversation' : 'Parler à Ti-Pote'}
|
|
</button>
|
|
|
|
{conversationActive && recording && (
|
|
<div style={styles.listeningIndicator}>
|
|
<span style={styles.pulse} />
|
|
Micro actif — parlez, Ti-Pote écoute
|
|
</div>
|
|
)}
|
|
|
|
{conversationActive && state === 'thinking' && (
|
|
<div style={styles.thinkingIndicator}>Ti-Pote réfléchit...</div>
|
|
)}
|
|
|
|
{conversationActive && state === 'speaking' && (
|
|
<div style={styles.speakingIndicator}>Ti-Pote parle...</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Disconnect */}
|
|
<button
|
|
style={{ ...styles.btn, background: 'transparent', color: '#888', width: '100%', marginBottom: 16 }}
|
|
onClick={handleConnect}
|
|
>
|
|
Déconnecter
|
|
</button>
|
|
</>
|
|
)}
|
|
|
|
{/* Logs (collapsible) */}
|
|
<div style={styles.panel}>
|
|
<div style={styles.logHeader}>
|
|
<button
|
|
style={{ ...styles.btn, background: 'transparent', color: '#888', padding: '4px 0', fontSize: 12 }}
|
|
onClick={() => setShowLogs(!showLogs)}
|
|
>
|
|
{showLogs ? '▼' : '▶'} Logs ({logs.length})
|
|
</button>
|
|
{showLogs && (
|
|
<button
|
|
style={{ ...styles.btn, background: '#333', padding: '4px 12px', fontSize: 11 }}
|
|
onClick={clearLogs}
|
|
>
|
|
Clear
|
|
</button>
|
|
)}
|
|
</div>
|
|
{showLogs && (
|
|
<div style={styles.logContainer}>
|
|
{logs.map((log, i) => (
|
|
<div key={i} style={styles.logEntry}>
|
|
<span style={styles.logTime}>{log.timestamp.toLocaleTimeString()}</span>
|
|
<span
|
|
style={{
|
|
...styles.logDirection,
|
|
color: log.direction === 'in' ? '#4caf50' : log.direction === 'out' ? '#2196f3' : '#ff9800',
|
|
}}
|
|
>
|
|
{log.direction === 'in' ? '◀' : log.direction === 'out' ? '▶' : '●'}
|
|
</span>
|
|
<span style={styles.logEvent}>{log.event}</span>
|
|
{log.data && <span style={styles.logData}>{log.data}</span>}
|
|
</div>
|
|
))}
|
|
<div ref={logsEndRef} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const styles: Record<string, React.CSSProperties> = {
|
|
container: {
|
|
maxWidth: 480,
|
|
margin: '0 auto',
|
|
padding: 20,
|
|
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
|
color: '#e0e0e0',
|
|
background: '#1a1a2e',
|
|
minHeight: '100vh',
|
|
},
|
|
header: {
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
marginBottom: 20,
|
|
},
|
|
title: {
|
|
fontSize: 20,
|
|
fontWeight: 700,
|
|
color: '#fff',
|
|
margin: 0,
|
|
},
|
|
statusBadge: {
|
|
padding: '6px 14px',
|
|
borderRadius: 20,
|
|
fontSize: 12,
|
|
fontWeight: 600,
|
|
color: '#fff',
|
|
transition: 'all 0.3s',
|
|
},
|
|
panel: {
|
|
background: '#16213e',
|
|
borderRadius: 12,
|
|
padding: 16,
|
|
marginBottom: 12,
|
|
},
|
|
panelTitle: {
|
|
fontSize: 13,
|
|
fontWeight: 600,
|
|
textTransform: 'uppercase' as const,
|
|
letterSpacing: 1,
|
|
color: '#666',
|
|
margin: '0 0 12px 0',
|
|
},
|
|
field: {
|
|
marginBottom: 12,
|
|
},
|
|
label: {
|
|
display: 'block',
|
|
fontSize: 12,
|
|
color: '#aaa',
|
|
marginBottom: 4,
|
|
},
|
|
input: {
|
|
width: '100%',
|
|
padding: '10px 12px',
|
|
border: '1px solid #333',
|
|
borderRadius: 8,
|
|
background: '#0f3460',
|
|
color: '#fff',
|
|
fontSize: 14,
|
|
boxSizing: 'border-box' as const,
|
|
outline: 'none',
|
|
},
|
|
btn: {
|
|
padding: '8px 20px',
|
|
border: 'none',
|
|
borderRadius: 8,
|
|
color: '#fff',
|
|
fontSize: 14,
|
|
fontWeight: 600,
|
|
cursor: 'pointer',
|
|
},
|
|
listeningIndicator: {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 10,
|
|
marginTop: 12,
|
|
padding: '10px 14px',
|
|
background: '#1a237e',
|
|
borderRadius: 8,
|
|
fontSize: 13,
|
|
color: '#90caf9',
|
|
},
|
|
pulse: {
|
|
display: 'inline-block',
|
|
width: 10,
|
|
height: 10,
|
|
borderRadius: '50%',
|
|
background: '#2196f3',
|
|
animation: 'pulse 1.5s infinite',
|
|
flexShrink: 0,
|
|
},
|
|
thinkingIndicator: {
|
|
marginTop: 12,
|
|
padding: '10px 14px',
|
|
background: '#4a2800',
|
|
borderRadius: 8,
|
|
fontSize: 13,
|
|
color: '#ffb74d',
|
|
textAlign: 'center' as const,
|
|
},
|
|
speakingIndicator: {
|
|
marginTop: 12,
|
|
padding: '10px 14px',
|
|
background: '#2a1040',
|
|
borderRadius: 8,
|
|
fontSize: 13,
|
|
color: '#ce93d8',
|
|
textAlign: 'center' as const,
|
|
},
|
|
logHeader: {
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
},
|
|
logContainer: {
|
|
maxHeight: 200,
|
|
overflowY: 'auto' as const,
|
|
fontFamily: 'monospace',
|
|
fontSize: 11,
|
|
background: '#0a0a1a',
|
|
borderRadius: 6,
|
|
padding: 8,
|
|
marginTop: 8,
|
|
},
|
|
logEntry: {
|
|
display: 'flex',
|
|
gap: 6,
|
|
padding: '2px 0',
|
|
borderBottom: '1px solid #1a1a2e',
|
|
},
|
|
logTime: { color: '#555', flexShrink: 0 },
|
|
logDirection: { flexShrink: 0 },
|
|
logEvent: { color: '#ccc', fontWeight: 600, flexShrink: 0 },
|
|
logData: { color: '#888', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' as const },
|
|
};
|
|
|
|
export default App;
|