export interface VoiceOption { name: string; lang: string; voice: SpeechSynthesisVoice; } // Dictionnaire phonétique local pour corriger la prononciation des acronymes et anglicismes en français const PHONETIC_DICTIONARY: Record = { 'api': 'a-pé-i', 'cors': 'korss', 'svg': 'ess-vé-gé', 'cpu': 'cé-pé-u', 'fps': 'eff-pé-ess', 'tts': 'té-té-ess', 'ui': 'u-i', 'ai': 'a-i', 'ia': 'i-a', 'json': 'dji-zone', 'url': 'u-err-el', 'html': 'ach-té-em-el', 'css': 'cé-ess-ess', 'js': 'ji-ess', 'typescript': 'taïpe-skript', 'javascript': 'djava-skript', 'electron': 'élèktron', 'react': 'ri-akt', 'github': 'gitte-ub', 'git': 'gitte', 'setup': 'sétup', 'c++': 'cé-plus-plus', 'node.js': 'node-j-s', 'nodejs': 'node-j-s', 'database': 'deïta-beïss', 'token': 'tokène', 'cli': 'cé-el-i', 'npm': 'en-pé-em', 'npx': 'en-pé-ix', 'vite': 'vite', 'vscode': 'vé-ess-code', 'vs code': 'vé-ess-code', 'windows': 'windoze', 'macos': 'mak-os', 'linux': 'linux', 'docker': 'dokeur', 'cyberpunk': 'saïbeur-pounk', 'avatar': 'avatar', 'chat': 'tchat', 'prompt': 'prompte', 'welcome': 'ouel-kome', 'ipc': 'i-pé-cé' }; export class AudioService { public ttsSynth: SpeechSynthesis; 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' | '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 = 'Qwen/Qwen3-ASR-0.6B'; 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; } private logError(tag: string, message: string, data?: any) { console.error(`[${tag}] ${message}`, data); if ((window as any).electronAPI?.writeLog) { (window as any).electronAPI.writeLog({ ts: new Date().toISOString(), level: 'ERROR', tag, message, data: data ? { message: data.message, stack: data.stack, ...data } : undefined }); } } private logWarn(tag: string, message: string, data?: any) { console.warn(`[${tag}] ${message}`, data); if ((window as any).electronAPI?.writeLog) { (window as any).electronAPI.writeLog({ ts: new Date().toISOString(), level: 'WARN', tag, message, data: data ? { message: data.message, stack: data.stack, ...data } : undefined }); } } constructor() { this.ttsSynth = window.speechSynthesis; 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; if (SpeechRecognition) { this.recognition = new SpeechRecognition(); this.recognition.continuous = false; // Arrête après une phrase this.recognition.interimResults = false; // Ne renvoie que les résultats finaux this.recognition.lang = 'fr-FR'; // Langue française par défaut } else { console.warn("La reconnaissance vocale (Speech-to-Text) n'est pas supportée dans cette WebView."); } } // Calculer un score de qualité pour trier les voix locales private _getVoiceScore(v: SpeechSynthesisVoice): number { const lang = v.lang.toLowerCase(); const name = v.name.toLowerCase(); if (!lang.startsWith('fr')) return 0; let score = 100; // Base français if (name.includes('google') || name.includes('online')) { score += 50; } if (name.includes('natural') || name.includes('neural')) { score += 40; } if (name.includes('mobile')) { score += 30; } if (name.includes('julie') || name.includes('paul')) { score += 20; } if (name.includes('hortense')) { score += 10; } return score; } // Récupérer les voix françaises et globales disponibles public getVoices(): VoiceOption[] { if (!this.ttsSynth) return []; const allVoices = this.ttsSynth.getVoices(); // Trier pour mettre les meilleures voix françaises en haut const sorted = [...allVoices].sort((a, b) => { const scoreA = this._getVoiceScore(a); const scoreB = this._getVoiceScore(b); if (scoreA !== scoreB) { return scoreB - scoreA; } return a.name.localeCompare(b.name); }); return sorted.map(v => ({ name: v.name, lang: v.lang, voice: v })); } private _resolveVoice(voiceName?: string): SpeechSynthesisVoice | undefined { const voices = this.ttsSynth.getVoices(); if (voiceName) return voices.find(v => v.name === voiceName); const frVoices = voices.filter(v => v.lang.toLowerCase().startsWith('fr')); if (frVoices.length === 0) return voices.find(v => v.lang.startsWith('en')); // Sélectionner la voix française locale avec le meilleur score return frVoices.sort((a, b) => this._getVoiceScore(b) - this._getVoiceScore(a))[0]; } private _applyPhoneticCorrections(text: string): string { let corrected = text; for (const [key, replacement] of Object.entries(PHONETIC_DICTIONARY)) { const escapedKey = key.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); // Regex recherchant le mot technique avec des limites Unicode ou de ponctuations const regex = new RegExp(`(?<=^|\\s|\\p{P})${escapedKey}(?=$|\\s|\\p{P})`, 'giu'); corrected = corrected.replace(regex, replacement); } return corrected; } private _cleanForTTS(raw: string): string { // 0a. Supprimer les tokens MEDIA: (chemins de fichiers envoyés par le bot) pour ne pas les lire à voix haute let noMedia = raw.replace(/MEDIA:\s*\S+/g, ''); // 0b. Supprimer complètement les blocs de code Markdown (```code```) car ils ne sont pas prononçables oralement let noCode = noMedia.replace(/```[\s\S]*?```/g, ' '); // 1. Améliorer la ponctuation Markdown pour forcer des pauses de diction naturelles // Remplacer les puces de liste (ex: - item, * item) par une pause douce (virgule) let formatted = noCode.replace(/(?:\r?\n)\s*[-*+]\s+/g, ', '); // Remplacer les deux-points suivis d'un saut de ligne par un point formatted = formatted.replace(/:\s*(?:\r?\n)/g, '. '); // Remplacer les doubles sauts de ligne par des points (pauses de paragraphes) formatted = formatted.replace(/(?:\r?\n){2,}/g, '. '); // Remplacer les sauts de ligne simples restant par des espaces formatted = formatted.replace(/(?:\r?\n)/g, ' '); // 2. Extraire le texte des liens Markdown [Texte](url) let cleaned = formatted.replace(/\[([^\]]+)\]\((?:[^)(]+|\([^)(]*\))*\)/g, '$1'); // 3. Supprimer toutes les URLs nues (http, https, ftp, file) cleaned = cleaned.replace(/(?:https?|ftp|file):\/\/\S+/gi, ''); // 4. Supprimer les adresses www. cleaned = cleaned.replace(/www\.\S+/gi, ''); // 5. Filtrer les mots techniques complexes (chemins de fichiers, adresses IP) const words = cleaned.split(/\s+/); cleaned = words .filter(word => { if (word.includes('/') || word.includes('\\')) { return false; } if (/(?:[a-zA-Z0-9-]+\.[a-zA-Z]{2,6}|localhost|[\d.]+):\d+/.test(word)) { return false; } if (/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/.test(word)) { return false; } return true; }) .join(' '); // 6. Nettoyer les caractères Markdown, Emojis et symboles non prononçables cleaned = cleaned // Emojis et symboles Unicode .replace(/[\u{1F000}-\u{1FFFF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{FE00}-\u{FEFF}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA9F}]/gu, '') // Markdown restant : **, *, _, __, #, `, ~ .replace(/(\*\*|__|~~|[*_#`~])/g, '') // Balises techniques résiduelles [⚙️ Exécution...] .replace(/\[[^\]]*\]/g, '') // Ponctuation répétée ou décorative : ---, ===, ~~~, ... .replace(/(-{2,}|={2,}|~{2,}|\.{2,})/g, ' ') // Caractères techniques restants non lisibles .replace(/[<>{}|\\^]/g, '') // Espaces multiples .replace(/\s{2,}/g, ' ') .trim(); // 7. Appliquer les corrections phonétiques locales return this._applyPhoneticCorrections(cleaned); } private _makeUtterance(text: string, voiceName?: string, rate = 1.0, pitch = 1.1): SpeechSynthesisUtterance { const clean = this._cleanForTTS(text); const u = new SpeechSynthesisUtterance(clean); u.lang = 'fr-FR'; u.rate = rate; u.pitch = pitch; const voice = this._resolveVoice(voiceName); if (voice) u.voice = voice; return u; } // Moteur Google TTS gratuit en ligne avec découpage intelligent private async speakGoogleFree(text: string, rate: number): Promise { const cleanText = this._cleanForTTS(text); if (!cleanText.trim()) return; const chunks: string[] = []; let currentChunk = ''; // Regex pour couper en phrases const sentences = cleanText.match(/[^.!?]+[.!?]*|.+/g) || [cleanText]; for (const sentence of sentences) { if ((currentChunk + sentence).length > 180) { if (currentChunk) { chunks.push(currentChunk.trim()); currentChunk = ''; } if (sentence.length > 180) { const words = sentence.split(/\s+/); let temp = ''; for (const word of words) { if ((temp + ' ' + word).length > 180) { chunks.push(temp.trim()); temp = word; } else { temp += (temp ? ' ' : '') + word; } } if (temp) currentChunk = temp; } else { currentChunk = sentence; } } else { currentChunk += (currentChunk ? ' ' : '') + sentence; } } if (currentChunk) { chunks.push(currentChunk.trim()); } for (const chunk of chunks) { if (!chunk) continue; const url = `https://translate.google.com/translate_tts?ie=UTF-8&tl=fr&client=tw-ob&q=${encodeURIComponent(chunk)}`; await new Promise((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")); }, 15000); audio.onended = () => { clearTimeout(timeout); if (this.currentAudioElement === audio) this.currentAudioElement = null; this.activeReject = null; resolve(); }; audio.onerror = () => { clearTimeout(timeout); if (this.currentAudioElement === audio) this.currentAudioElement = null; this.activeReject = null; const code = audio.error ? audio.error.code : 'UNKNOWN'; const msg = audio.error ? audio.error.message : 'No message'; reject(new Error(`Erreur de chargement Google TTS (code: ${code}, msg: ${msg})`)); }; audio.play().catch((err) => { clearTimeout(timeout); if (this.currentAudioElement === audio) this.currentAudioElement = null; this.activeReject = null; reject(err); }); }); } } // Synthese vocale personnalisee via API OpenAI /v1/audio/speech private async speakCustomAPI(text: string, voiceName?: string, rate: number = 1.0): Promise { 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 = { '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((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 { if (this.isPlayingQueue) return; this.isPlayingQueue = true; while (this.audioQueue.length > 0) { const item = this.audioQueue.shift(); if (!item) continue; 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((resolve, reject) => { if (!this.ttsSynth) { reject('TTS non supporté'); return; } const u = this._makeUtterance(item.text, item.voiceName, item.rate, item.pitch); u.onend = () => resolve(); u.onerror = (e: any) => { if (e && (e.error === 'interrupted' || e.error === 'canceled')) { reject(new Error("playback_stopped")); } else { reject(new Error("Erreur de diction système")); } }; this.ttsSynth.speak(u); }); } } catch (err: any) { if (err?.message === 'playback_stopped') { break; } this.logWarn("AudioService", "Échec de la lecture de la phrase dans la file", err); 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((resolve, reject) => { if (!this.ttsSynth) { reject('TTS non supporté'); return; } const u = this._makeUtterance(item.text, item.voiceName, item.rate, item.pitch); u.onend = () => resolve(); u.onerror = (e: any) => { if (e && (e.error === 'interrupted' || e.error === 'canceled')) { reject(new Error("playback_stopped")); } else { reject(new Error("Erreur de diction système")); } }; this.ttsSynth.speak(u); }); } catch (fallbackErr: any) { if (fallbackErr?.message === 'playback_stopped') { break; } this.logError("AudioService", "Échec également du repli système", fallbackErr); } } } } this.isPlayingQueue = false; } // Synthèse Vocale complète (annule et relit tout le texte) public async speak( text: string, voiceName?: string, rate: number = 1.0, pitch: number = 1.1, provider: 'system' | 'google-free' | 'openai-tts' = 'google-free' ): Promise { this.stopSpeaking(); this.queueSentence(text, voiceName, rate, pitch, provider); // Attendre la fin complète de la lecture pour résoudre la promesse while (this.isPlayingQueue || this.audioQueue.length > 0) { await new Promise(resolve => setTimeout(resolve, 50)); } } // Ajoute une phrase à la file de lecture vocale pour un enchaînement fluide en temps réel (streaming) public queueSentence( text: string, voiceName?: string, rate: number = 1.0, pitch: number = 1.1, provider: 'system' | 'google-free' | 'openai-tts' = 'google-free' ): void { const clean = text.trim(); if (!clean) return; this.audioQueue.push({ text: clean, voiceName, rate, pitch, provider }); this.processQueue(); } // Vide la file TTS (fin du streaming ou interruption utilisateur) public flushQueue(): void { this.audioQueue = []; this.isPlayingQueue = false; if (this.ttsSynth) this.ttsSynth.cancel(); if (this.currentAudioElement) { this.currentAudioElement.pause(); this.currentAudioElement = null; } if (this.activeReject) { this.activeReject(new Error("playback_stopped")); this.activeReject = null; } } // Arrêter immédiatement de parler et vider la file public stopSpeaking() { this.audioQueue = []; this.isPlayingQueue = false; if (this.ttsSynth) { this.ttsSynth.cancel(); } if (this.currentAudioElement) { this.currentAudioElement.pause(); this.currentAudioElement = null; } if (this.activeReject) { this.activeReject(new Error("playback_stopped")); this.activeReject = null; } } // 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 { 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 = {}; 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, onStart: () => void, onEnd: () => void, onError: (err: string) => void, lang: string = 'fr-FR' ) { 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(); } this.recognition.lang = lang; this.isListeningActive = true; this.recognition.onstart = () => { onStart(); }; this.recognition.onresult = (event: any) => { const resultText = event.results[0][0].transcript; onResult(resultText); }; this.recognition.onerror = (event: any) => { onError(event.error); }; this.recognition.onend = () => { this.isListeningActive = false; onEnd(); }; 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.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; } } } }