mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-11 17:29:03 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c25875d8be | ||
|
|
7c10d633ca | ||
|
|
115996c7d9 |
No files matched your search
@@ -1,7 +1,7 @@
|
||||
# 🚀 EveFlow — Compagnon de Bureau Rétro-Futuriste 3D
|
||||
|
||||
[](https://github.com/R0m1k3/EveFlow)
|
||||
[](https://github.com/R0m1k3/EveFlow/releases/tag/v1.0.3)
|
||||
[](https://github.com/R0m1k3/EveFlow/releases/tag/v1.0.4)
|
||||
[](LICENSE)
|
||||
|
||||
**EveFlow** est un compagnon de bureau Windows immersif haut de gamme, combinant une esthétique cyberpunk rétro-futuriste soignée et des technologies d'intelligence artificielle avancées. Il intègre un assistant virtuel en 3D nommé **Eve**, animé en temps réel avec des expressions émotionnelles dynamiques et synchronisé avec des services de synthèse vocale (TTS/STT) locaux et des architectures multi-agents.
|
||||
@@ -12,16 +12,7 @@
|
||||
|
||||
Découvrez le cockpit de contrôle immersif et le mode widget compact d'EveFlow :
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><strong>Cockpit Principal (Mode Discussion & Télémétrie)</strong></td>
|
||||
<td align="center"><strong>Widget Flottant (Mode Compact Transparent)</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="public/screenshots/cockpit_view.png" width="500" alt="EveFlow Cockpit View"/></td>
|
||||
<td><img src="public/screenshots/compact_widget.png" width="300" alt="EveFlow Compact Widget"/></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -83,7 +74,7 @@ Pour générer l'exécutable d'installation Windows (`.exe`) autonome :
|
||||
```bash
|
||||
npm run dist
|
||||
```
|
||||
*L'exécutable d'installation NSIS (`EveFlow Setup 1.0.3.exe`) et la build décompressée seront générés dans le dossier `./out/`.*
|
||||
*L'exécutable d'installation NSIS (`EveFlow Setup 1.0.4.exe`) et la build décompressée seront générés dans le dossier `./out/`.*
|
||||
|
||||
---
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "eveflow",
|
||||
"version": "1.0.3",
|
||||
"version": "1.0.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "eveflow",
|
||||
"version": "1.0.3",
|
||||
"version": "1.0.4",
|
||||
"dependencies": {
|
||||
"@react-three/drei": "^9.105.0",
|
||||
"@react-three/fiber": "^8.16.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eveflow",
|
||||
"version": "1.0.3",
|
||||
"version": "1.0.4",
|
||||
"description": "Retro-futuristic Windows companion with 3D Eve bot, local TTS/STT, and multi-agent connectivity",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
||||
+249
-5
@@ -892,7 +892,18 @@ export const App: React.FC = () => {
|
||||
const [selectedVoice, setSelectedVoice] = useState<string>('');
|
||||
const [ttsRate, setTtsRate] = useState<number>(1.15);
|
||||
const [sttLang, setSttLang] = useState<string>('fr-FR');
|
||||
const [ttsProvider, setTtsProvider] = useState<'system' | 'google-free'>('google-free');
|
||||
const [ttsProvider, setTtsProvider] = useState<'system' | 'google-free' | 'openai-tts'>('google-free');
|
||||
|
||||
// Configuration personnalisée de l'API Speech-To-Text (ASR) et Text-To-Speech (TTS)
|
||||
const [sttProvider, setSttProvider] = useState<'browser' | 'qwen3-asr'>('browser');
|
||||
const [sttApiUrl, setSttApiUrl] = useState<string>('http://127.0.0.1:8000/v1');
|
||||
const [sttApiKey, setSttApiKey] = useState<string>('');
|
||||
const [sttModel, setSttModel] = useState<string>('qwen3-asr');
|
||||
|
||||
const [ttsApiUrl, setTtsApiUrl] = useState<string>('http://127.0.0.1:8000/v1');
|
||||
const [ttsApiKey, setTtsApiKey] = useState<string>('');
|
||||
const [ttsModel, setTtsModel] = useState<string>('tts-1');
|
||||
|
||||
const activeAvatar = AVATAR_PROFILES[avatarId];
|
||||
const hermesUnreadRuns = hermesRunHistory.filter(run => !run.read).length;
|
||||
const hermesSyncLabel = hermesSyncState === 'offline'
|
||||
@@ -1136,6 +1147,8 @@ export const App: React.FC = () => {
|
||||
// --- INITIALISATION ---
|
||||
useEffect(() => {
|
||||
const service = new AudioService();
|
||||
service.updateSTTConfig(sttProvider, sttApiUrl, sttApiKey, sttModel);
|
||||
service.updateTTSConfig(ttsApiUrl, ttsApiKey, ttsModel);
|
||||
audioServiceRef.current = service;
|
||||
|
||||
// Charger la configuration depuis le store persistant (IPC fichier > localStorage)
|
||||
@@ -1159,11 +1172,33 @@ export const App: React.FC = () => {
|
||||
});
|
||||
|
||||
persistRead('eveflow_tts_provider').then(savedProvider => {
|
||||
if (savedProvider === 'system' || savedProvider === 'google-free') {
|
||||
setTtsProvider(savedProvider as 'system' | 'google-free');
|
||||
if (savedProvider === 'system' || savedProvider === 'google-free' || savedProvider === 'openai-tts') {
|
||||
setTtsProvider(savedProvider as 'system' | 'google-free' | 'openai-tts');
|
||||
}
|
||||
});
|
||||
|
||||
persistRead('eveflow_stt_provider').then(val => {
|
||||
if (val === 'browser' || val === 'qwen3-asr') setSttProvider(val as 'browser' | 'qwen3-asr');
|
||||
});
|
||||
persistRead('eveflow_stt_api_url').then(val => {
|
||||
if (val) setSttApiUrl(val);
|
||||
});
|
||||
persistRead('eveflow_stt_api_key').then(val => {
|
||||
if (val) setSttApiKey(val);
|
||||
});
|
||||
persistRead('eveflow_stt_model').then(val => {
|
||||
if (val) setSttModel(val);
|
||||
});
|
||||
persistRead('eveflow_tts_api_url').then(val => {
|
||||
if (val) setTtsApiUrl(val);
|
||||
});
|
||||
persistRead('eveflow_tts_api_key').then(val => {
|
||||
if (val) setTtsApiKey(val);
|
||||
});
|
||||
persistRead('eveflow_tts_model').then(val => {
|
||||
if (val) setTtsModel(val);
|
||||
});
|
||||
|
||||
persistRead('eveflow_avatar_id').then(savedAvatar => {
|
||||
if (savedAvatar && savedAvatar in AVATAR_PROFILES) {
|
||||
setAvatarId(savedAvatar as EveAvatar);
|
||||
@@ -1221,6 +1256,49 @@ export const App: React.FC = () => {
|
||||
return undefined;
|
||||
}, []);
|
||||
|
||||
// Synchroniser la configuration de AudioService avec l'état React
|
||||
useEffect(() => {
|
||||
if (audioServiceRef.current) {
|
||||
audioServiceRef.current.updateSTTConfig(sttProvider, sttApiUrl, sttApiKey, sttModel);
|
||||
}
|
||||
}, [sttProvider, sttApiUrl, sttApiKey, sttModel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (audioServiceRef.current) {
|
||||
audioServiceRef.current.updateTTSConfig(ttsApiUrl, ttsApiKey, ttsModel);
|
||||
}
|
||||
}, [ttsApiUrl, ttsApiKey, ttsModel]);
|
||||
|
||||
const handleSttProviderChange = (val: 'browser' | 'qwen3-asr') => {
|
||||
setSttProvider(val);
|
||||
persistWrite('eveflow_stt_provider', val);
|
||||
};
|
||||
const handleSttApiUrlChange = (val: string) => {
|
||||
setSttApiUrl(val);
|
||||
persistWrite('eveflow_stt_api_url', val);
|
||||
};
|
||||
const handleSttApiKeyChange = (val: string) => {
|
||||
setSttApiKey(val);
|
||||
persistWrite('eveflow_stt_api_key', val);
|
||||
};
|
||||
const handleSttModelChange = (val: string) => {
|
||||
setSttModel(val);
|
||||
persistWrite('eveflow_stt_model', val);
|
||||
};
|
||||
|
||||
const handleTtsApiUrlChange = (val: string) => {
|
||||
setTtsApiUrl(val);
|
||||
persistWrite('eveflow_tts_api_url', val);
|
||||
};
|
||||
const handleTtsApiKeyChange = (val: string) => {
|
||||
setTtsApiKey(val);
|
||||
persistWrite('eveflow_tts_api_key', val);
|
||||
};
|
||||
const handleTtsModelChange = (val: string) => {
|
||||
setTtsModel(val);
|
||||
persistWrite('eveflow_tts_model', val);
|
||||
};
|
||||
|
||||
// Défilement automatique
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
@@ -2069,7 +2147,7 @@ export const App: React.FC = () => {
|
||||
|
||||
</div>
|
||||
|
||||
{/* B. TTS / STT */}
|
||||
{/* B. TTS (Synthese Vocale / Voice Reader) */}
|
||||
<div className="settings-card">
|
||||
<h3 className="label-title" style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<Volume2 style={{ width: '14px', height: '14px' }} /> Voix de {activeAvatar.name} (Text-to-Speech)
|
||||
@@ -2078,7 +2156,7 @@ export const App: React.FC = () => {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<div>
|
||||
<label className="label-title">Type de Synthèse Vocale</label>
|
||||
<div className="grid-2" style={{ gap: '10px', marginTop: '4px' }}>
|
||||
<div className="grid-3" style={{ gap: '10px', marginTop: '4px' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setTtsProvider('google-free');
|
||||
@@ -2113,6 +2191,23 @@ export const App: React.FC = () => {
|
||||
>
|
||||
🔌 Voix Système (Local)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setTtsProvider('openai-tts');
|
||||
persistWrite('eveflow_tts_provider', 'openai-tts');
|
||||
}}
|
||||
className="neon-btn"
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
fontSize: '11px',
|
||||
background: ttsProvider === 'openai-tts' ? '' : 'transparent',
|
||||
border: ttsProvider === 'openai-tts' ? 'none' : '1px solid #cbd5e1',
|
||||
color: ttsProvider === 'openai-tts' ? 'white' : '#64748b',
|
||||
boxShadow: ttsProvider === 'openai-tts' ? '' : 'none'
|
||||
}}
|
||||
>
|
||||
🌐 API Personnalisée
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2143,6 +2238,63 @@ export const App: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ttsProvider === 'openai-tts' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<div>
|
||||
<label className="label-title">URL de l'API TTS</label>
|
||||
<input
|
||||
type="text"
|
||||
value={ttsApiUrl}
|
||||
onChange={(e) => handleTtsApiUrlChange(e.target.value)}
|
||||
className="glow-input"
|
||||
style={{ padding: '8px 12px', fontSize: '12px' }}
|
||||
placeholder="Ex: http://127.0.0.1:8000/v1"
|
||||
/>
|
||||
<span style={{ display: 'block', marginTop: '5px', fontSize: '10px', fontFamily: 'var(--font-mono)', color: 'var(--text-secondary)', lineHeight: 1.5 }}>
|
||||
ℹ️ L'endpoint <code>/v1/audio/speech</code> sera utilisé pour la synthèse.
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid-2">
|
||||
<div>
|
||||
<label className="label-title">Modèle TTS</label>
|
||||
<input
|
||||
type="text"
|
||||
value={ttsModel}
|
||||
onChange={(e) => handleTtsModelChange(e.target.value)}
|
||||
className="glow-input"
|
||||
style={{ padding: '8px 12px', fontSize: '12px' }}
|
||||
placeholder="tts-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label-title">Clé API (Optionnel)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={ttsApiKey}
|
||||
onChange={(e) => handleTtsApiKeyChange(e.target.value)}
|
||||
className="glow-input"
|
||||
style={{ padding: '8px 12px', fontSize: '12px' }}
|
||||
placeholder="Bearer token (API_KEY)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label-title">Nom de la Voix (ex: alloy, onyx, nova)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={selectedVoice}
|
||||
onChange={(e) => {
|
||||
setSelectedVoice(e.target.value);
|
||||
persistWrite('eveflow_voice', e.target.value);
|
||||
}}
|
||||
className="glow-input"
|
||||
style={{ padding: '8px 12px', fontSize: '12px' }}
|
||||
placeholder="alloy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid-2">
|
||||
<div>
|
||||
<label className="label-title">Vitesse: {ttsRate.toFixed(2)}</label>
|
||||
@@ -2185,6 +2337,98 @@ export const App: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* C. STT (Reconnaissance Vocale) */}
|
||||
<div className="settings-card">
|
||||
<h3 className="label-title" style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<Mic style={{ width: '14px', height: '14px' }} /> Reconnaissance Vocale (Speech-to-Text)
|
||||
</h3>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<div>
|
||||
<label className="label-title">Moteur de Reconnaissance Vocale</label>
|
||||
<div className="grid-2" style={{ gap: '10px', marginTop: '4px' }}>
|
||||
<button
|
||||
onClick={() => handleSttProviderChange('browser')}
|
||||
className="neon-btn"
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
fontSize: '11px',
|
||||
background: sttProvider === 'browser' ? '' : 'transparent',
|
||||
border: sttProvider === 'browser' ? 'none' : '1px solid #cbd5e1',
|
||||
color: sttProvider === 'browser' ? 'white' : '#64748b',
|
||||
boxShadow: sttProvider === 'browser' ? '' : 'none'
|
||||
}}
|
||||
>
|
||||
🟢 Moteur Navigateur (Local)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSttProviderChange('qwen3-asr')}
|
||||
className="neon-btn"
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
fontSize: '11px',
|
||||
background: sttProvider === 'qwen3-asr' ? '' : 'transparent',
|
||||
border: sttProvider === 'qwen3-asr' ? 'none' : '1px solid #cbd5e1',
|
||||
color: sttProvider === 'qwen3-asr' ? 'white' : '#64748b',
|
||||
boxShadow: sttProvider === 'qwen3-asr' ? '' : 'none'
|
||||
}}
|
||||
>
|
||||
🌐 Qwen3 ASR (API)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sttProvider === 'browser' && (
|
||||
<div style={{ padding: '8px 12px', backgroundColor: 'var(--accent-light)', border: '1px solid rgba(0,212,245,0.2)', borderRadius: '10px', fontSize: '11px', fontFamily: 'var(--font-mono)', color: 'var(--text-neon)', lineHeight: 1.5 }}>
|
||||
ℹ️ **Mode Navigateur Actif** : Utilise l'API de reconnaissance vocale standard intégrée à votre système (locale, rapide et gratuite).
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sttProvider === 'qwen3-asr' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<div>
|
||||
<label className="label-title">URL de l'API STT</label>
|
||||
<input
|
||||
type="text"
|
||||
value={sttApiUrl}
|
||||
onChange={(e) => handleSttApiUrlChange(e.target.value)}
|
||||
className="glow-input"
|
||||
style={{ padding: '8px 12px', fontSize: '12px' }}
|
||||
placeholder="Ex: http://127.0.0.1:8000/v1"
|
||||
/>
|
||||
<span style={{ display: 'block', marginTop: '5px', fontSize: '10px', fontFamily: 'var(--font-mono)', color: 'var(--text-secondary)', lineHeight: 1.5 }}>
|
||||
ℹ️ L'endpoint <code>/v1/audio/transcriptions</code> sera utilisé pour la transcription.
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid-2">
|
||||
<div>
|
||||
<label className="label-title">Modèle ASR</label>
|
||||
<input
|
||||
type="text"
|
||||
value={sttModel}
|
||||
onChange={(e) => handleSttModelChange(e.target.value)}
|
||||
className="glow-input"
|
||||
style={{ padding: '8px 12px', fontSize: '12px' }}
|
||||
placeholder="qwen3-asr"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label-title">Clé API (Optionnel)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={sttApiKey}
|
||||
onChange={(e) => handleSttApiKeyChange(e.target.value)}
|
||||
className="glow-input"
|
||||
style={{ padding: '8px 12px', fontSize: '12px' }}
|
||||
placeholder="Bearer token"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
|
||||
+286
-34
@@ -55,10 +55,22 @@ export class AudioService {
|
||||
private recognition: any = null;
|
||||
private isListeningActive = false;
|
||||
private currentAudioElement: HTMLAudioElement | null = null;
|
||||
private audioQueue: { text: string; voiceName?: string; rate: number; pitch: number; provider: 'system' | 'google-free' }[] = [];
|
||||
private audioQueue: { text: string; voiceName?: string; rate: number; pitch: number; provider: 'system' | 'google-free' | 'openai-tts' }[] = [];
|
||||
private isPlayingQueue = false;
|
||||
private activeReject: ((err: any) => void) | null = null;
|
||||
|
||||
// ASR/STT & TTS Custom API settings
|
||||
private sttProvider: 'browser' | 'qwen3-asr' = 'browser';
|
||||
private sttApiUrl = 'http://127.0.0.1:8000/v1';
|
||||
private sttApiKey = '';
|
||||
private sttModel = 'qwen3-asr';
|
||||
private mediaRecorder: MediaRecorder | null = null;
|
||||
private audioChunks: Blob[] = [];
|
||||
|
||||
private ttsApiUrl = 'http://127.0.0.1:8000/v1';
|
||||
private ttsApiKey = '';
|
||||
private ttsModel = 'tts-1';
|
||||
|
||||
public get isSpeakingActive(): boolean {
|
||||
return this.isPlayingQueue || this.audioQueue.length > 0;
|
||||
}
|
||||
@@ -94,6 +106,21 @@ export class AudioService {
|
||||
this.initSTT();
|
||||
}
|
||||
|
||||
// Permet de mettre à jour dynamiquement la configuration de la reconnaissance vocale
|
||||
public updateSTTConfig(provider: 'browser' | 'qwen3-asr', apiUrl: string, apiKey: string, model: string) {
|
||||
this.sttProvider = provider;
|
||||
this.sttApiUrl = apiUrl;
|
||||
this.sttApiKey = apiKey;
|
||||
this.sttModel = model;
|
||||
}
|
||||
|
||||
// Permet de mettre à jour dynamiquement la configuration du lecteur de voix personnalisé
|
||||
public updateTTSConfig(apiUrl: string, apiKey: string, model: string) {
|
||||
this.ttsApiUrl = apiUrl;
|
||||
this.ttsApiKey = apiKey;
|
||||
this.ttsModel = model;
|
||||
}
|
||||
|
||||
// Initialisation de la reconnaissance vocale locale et gratuite (Web Speech API)
|
||||
private initSTT() {
|
||||
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
|
||||
@@ -339,6 +366,98 @@ export class AudioService {
|
||||
}
|
||||
}
|
||||
|
||||
// Synthese vocale personnalisee via API OpenAI /v1/audio/speech
|
||||
private async speakCustomAPI(text: string, voiceName?: string, rate: number = 1.0): Promise<void> {
|
||||
const cleanText = this._cleanForTTS(text);
|
||||
if (!cleanText.trim()) return;
|
||||
|
||||
if (!this.ttsApiUrl) {
|
||||
throw new Error("L'URL de l'API TTS n'est pas configurée.");
|
||||
}
|
||||
|
||||
const base = this.ttsApiUrl.replace(/\/$/, '');
|
||||
let endpoint: string;
|
||||
if (base.endsWith('/speech')) {
|
||||
endpoint = base;
|
||||
} else if (base.endsWith('/v1')) {
|
||||
endpoint = `${base}/audio/speech`;
|
||||
} else {
|
||||
endpoint = `${base}/v1/audio/speech`;
|
||||
}
|
||||
|
||||
const body = {
|
||||
model: this.ttsModel || 'tts-1',
|
||||
input: cleanText,
|
||||
voice: voiceName || 'alloy'
|
||||
};
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
if (this.ttsApiKey) {
|
||||
headers['Authorization'] = `Bearer ${this.ttsApiKey.trim()}`;
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const rawErr = await response.text().catch(() => '(no body)');
|
||||
let errMsg = `Erreur TTS API: ${response.status}`;
|
||||
try {
|
||||
const parsed = JSON.parse(rawErr);
|
||||
errMsg = parsed.error?.message || parsed.message || rawErr;
|
||||
} catch {}
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const audio = new Audio(url);
|
||||
this.currentAudioElement = audio;
|
||||
audio.playbackRate = rate;
|
||||
this.activeReject = reject;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (this.currentAudioElement === audio) this.currentAudioElement = null;
|
||||
this.activeReject = null;
|
||||
audio.pause();
|
||||
reject(new Error("playback_stopped"));
|
||||
}, 60000); // 60 secondes max pour une portion
|
||||
|
||||
audio.onended = () => {
|
||||
clearTimeout(timeout);
|
||||
if (this.currentAudioElement === audio) this.currentAudioElement = null;
|
||||
this.activeReject = null;
|
||||
URL.revokeObjectURL(url);
|
||||
resolve();
|
||||
};
|
||||
|
||||
audio.onerror = () => {
|
||||
clearTimeout(timeout);
|
||||
if (this.currentAudioElement === audio) this.currentAudioElement = null;
|
||||
this.activeReject = null;
|
||||
URL.revokeObjectURL(url);
|
||||
const code = audio.error ? audio.error.code : 'UNKNOWN';
|
||||
const msg = audio.error ? audio.error.message : 'No message';
|
||||
reject(new Error(`Erreur de lecture audio API (code: ${code}, msg: ${msg})`));
|
||||
};
|
||||
|
||||
audio.play().catch((err) => {
|
||||
clearTimeout(timeout);
|
||||
if (this.currentAudioElement === audio) this.currentAudioElement = null;
|
||||
this.activeReject = null;
|
||||
URL.revokeObjectURL(url);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// File de traitement asynchrone des phrases de synthèse vocale (Zero Trust & Zero Dependency)
|
||||
private async processQueue(): Promise<void> {
|
||||
if (this.isPlayingQueue) return;
|
||||
@@ -351,6 +470,8 @@ export class AudioService {
|
||||
try {
|
||||
if (item.provider === 'google-free') {
|
||||
await this.speakGoogleFree(item.text, item.rate);
|
||||
} else if (item.provider === 'openai-tts') {
|
||||
await this.speakCustomAPI(item.text, item.voiceName, item.rate);
|
||||
} else {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
if (!this.ttsSynth) { reject('TTS non supporté'); return; }
|
||||
@@ -371,7 +492,7 @@ export class AudioService {
|
||||
break;
|
||||
}
|
||||
this.logWarn("AudioService", "Échec de la lecture de la phrase dans la file", err);
|
||||
if (item.provider === 'google-free') {
|
||||
if (item.provider === 'google-free' || item.provider === 'openai-tts') {
|
||||
// Repli automatique transparent sur la voix locale hors-ligne pour ce segment précis en cas de défaillance
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -406,7 +527,7 @@ export class AudioService {
|
||||
voiceName?: string,
|
||||
rate: number = 1.0,
|
||||
pitch: number = 1.1,
|
||||
provider: 'system' | 'google-free' = 'google-free'
|
||||
provider: 'system' | 'google-free' | 'openai-tts' = 'google-free'
|
||||
): Promise<void> {
|
||||
this.stopSpeaking();
|
||||
this.queueSentence(text, voiceName, rate, pitch, provider);
|
||||
@@ -423,7 +544,7 @@ export class AudioService {
|
||||
voiceName?: string,
|
||||
rate: number = 1.0,
|
||||
pitch: number = 1.1,
|
||||
provider: 'system' | 'google-free' = 'google-free'
|
||||
provider: 'system' | 'google-free' | 'openai-tts' = 'google-free'
|
||||
): void {
|
||||
const clean = text.trim();
|
||||
if (!clean) return;
|
||||
@@ -463,6 +584,126 @@ export class AudioService {
|
||||
}
|
||||
}
|
||||
|
||||
// Démarrer l'écoute vocale API via MediaRecorder
|
||||
private async startListeningAPI(
|
||||
onResult: (text: string) => void,
|
||||
onStart: () => void,
|
||||
onEnd: () => void,
|
||||
onError: (err: string) => void,
|
||||
lang: string
|
||||
) {
|
||||
if (this.isListeningActive) {
|
||||
this.stopListening();
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
this.audioChunks = [];
|
||||
|
||||
let options = {};
|
||||
if (MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) {
|
||||
options = { mimeType: 'audio/webm;codecs=opus' };
|
||||
} else if (MediaRecorder.isTypeSupported('audio/webm')) {
|
||||
options = { mimeType: 'audio/webm' };
|
||||
}
|
||||
|
||||
this.mediaRecorder = new MediaRecorder(stream, options);
|
||||
this.isListeningActive = true;
|
||||
|
||||
this.mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data && event.data.size > 0) {
|
||||
this.audioChunks.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
this.mediaRecorder.onstart = () => {
|
||||
onStart();
|
||||
};
|
||||
|
||||
this.mediaRecorder.onstop = async () => {
|
||||
this.isListeningActive = false;
|
||||
onEnd();
|
||||
|
||||
// Stopper toutes les pistes pour éteindre le voyant du microphone
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
|
||||
if (this.audioChunks.length === 0) {
|
||||
onError("Aucune donnée audio capturée");
|
||||
return;
|
||||
}
|
||||
|
||||
const audioBlob = new Blob(this.audioChunks, { type: this.mediaRecorder?.mimeType || 'audio/webm' });
|
||||
|
||||
try {
|
||||
const text = await this.transcribeAudioWithAPI(audioBlob, lang);
|
||||
onResult(text);
|
||||
} catch (err: any) {
|
||||
onError(err.message || "Erreur de transcription de l'API");
|
||||
}
|
||||
};
|
||||
|
||||
this.mediaRecorder.start();
|
||||
} catch (err: any) {
|
||||
this.isListeningActive = false;
|
||||
onError(err.message || "Impossible d'accéder au microphone.");
|
||||
}
|
||||
}
|
||||
|
||||
// Requête API Speech-To-Text compatible OpenAI /v1/audio/transcriptions
|
||||
private async transcribeAudioWithAPI(audioBlob: Blob, lang: string): Promise<string> {
|
||||
if (!this.sttApiUrl) {
|
||||
throw new Error("L'URL de l'API STT n'est pas configurée.");
|
||||
}
|
||||
|
||||
const base = this.sttApiUrl.replace(/\/$/, '');
|
||||
let endpoint: string;
|
||||
if (base.endsWith('/transcriptions')) {
|
||||
endpoint = base;
|
||||
} else if (base.endsWith('/v1')) {
|
||||
endpoint = `${base}/audio/transcriptions`;
|
||||
} else {
|
||||
endpoint = `${base}/v1/audio/transcriptions`;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
const ext = audioBlob.type.includes('wav') ? 'wav' : 'webm';
|
||||
formData.append('file', audioBlob, `audio.${ext}`);
|
||||
formData.append('model', this.sttModel || 'qwen3-asr');
|
||||
|
||||
if (lang) {
|
||||
const isoLang = lang.split('-')[0];
|
||||
formData.append('language', isoLang);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (this.sttApiKey) {
|
||||
headers['Authorization'] = `Bearer ${this.sttApiKey.trim()}`;
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const rawErr = await response.text().catch(() => '(no body)');
|
||||
let errMsg = `Erreur STT API: ${response.status}`;
|
||||
try {
|
||||
const parsed = JSON.parse(rawErr);
|
||||
errMsg = parsed.error?.message || parsed.message || rawErr;
|
||||
} catch {}
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data && typeof data.text === 'string') {
|
||||
return data.text;
|
||||
}
|
||||
|
||||
throw new Error("Format de réponse de l'API STT invalide.");
|
||||
}
|
||||
|
||||
// Lancer l'écoute vocale (STT - Speech-To-Text)
|
||||
public startListening(
|
||||
onResult: (text: string) => void,
|
||||
@@ -471,48 +712,59 @@ export class AudioService {
|
||||
onError: (err: string) => void,
|
||||
lang: string = 'fr-FR'
|
||||
) {
|
||||
if (!this.recognition) {
|
||||
onError("STT non supporté");
|
||||
return;
|
||||
}
|
||||
if (this.sttProvider === 'qwen3-asr') {
|
||||
this.startListeningAPI(onResult, onStart, onEnd, onError, lang);
|
||||
} else {
|
||||
if (!this.recognition) {
|
||||
onError("STT non supporté par ce navigateur");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isListeningActive) {
|
||||
this.recognition.stop();
|
||||
}
|
||||
if (this.isListeningActive) {
|
||||
this.recognition.stop();
|
||||
}
|
||||
|
||||
this.recognition.lang = lang;
|
||||
this.isListeningActive = true;
|
||||
this.recognition.lang = lang;
|
||||
this.isListeningActive = true;
|
||||
|
||||
this.recognition.onstart = () => {
|
||||
onStart();
|
||||
};
|
||||
this.recognition.onstart = () => {
|
||||
onStart();
|
||||
};
|
||||
|
||||
this.recognition.onresult = (event: any) => {
|
||||
const resultText = event.results[0][0].transcript;
|
||||
onResult(resultText);
|
||||
};
|
||||
this.recognition.onresult = (event: any) => {
|
||||
const resultText = event.results[0][0].transcript;
|
||||
onResult(resultText);
|
||||
};
|
||||
|
||||
this.recognition.onerror = (event: any) => {
|
||||
onError(event.error);
|
||||
};
|
||||
this.recognition.onerror = (event: any) => {
|
||||
onError(event.error);
|
||||
};
|
||||
|
||||
this.recognition.onend = () => {
|
||||
this.isListeningActive = false;
|
||||
onEnd();
|
||||
};
|
||||
this.recognition.onend = () => {
|
||||
this.isListeningActive = false;
|
||||
onEnd();
|
||||
};
|
||||
|
||||
try {
|
||||
this.recognition.start();
|
||||
} catch (e: any) {
|
||||
onError(e.message || "Erreur de démarrage de l'écoute");
|
||||
try {
|
||||
this.recognition.start();
|
||||
} catch (e: any) {
|
||||
onError(e.message || "Erreur de démarrage de l'écoute");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Arrêter l'écoute vocale
|
||||
public stopListening() {
|
||||
if (this.recognition && this.isListeningActive) {
|
||||
this.recognition.stop();
|
||||
this.isListeningActive = false;
|
||||
if (this.sttProvider === 'qwen3-asr') {
|
||||
if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
|
||||
this.mediaRecorder.stop();
|
||||
this.isListeningActive = false;
|
||||
}
|
||||
} else {
|
||||
if (this.recognition && this.isListeningActive) {
|
||||
this.recognition.stop();
|
||||
this.isListeningActive = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user