import { Injectable, signal } from '@angular/core'; @Injectable({ providedIn: 'root' }) export class AudioEngineService { public isListening = signal(false); public wordsDetected = signal(0); public linesDetected = signal(0); public energyLevel = signal(0); // For visual feedback public searchTranscript = signal(''); public isSearching = signal(false); public sensitivity = signal(75); // Default sensitivity (50-100) public clearSearchTranscript() { this.searchTranscript.set(''); } private audioContext: AudioContext | null = null; private analyser: AnalyserNode | null = null; private stream: MediaStream | null = null; private animationFrame: number | null = null; private recognition: any = null; // Detection logic state private isSpeaking: boolean = false; private lastSilenceTime: number = Date.now(); private lastWordTime: number = 0; // Constants for tuning - Optimized for close proximity (singer/guitarist) private readonly SILENCE_GAP = 100; // ms private readonly LINE_SILENCE_GAP = 600; // ms private readonly COOLDOWN = 1000; // ms private peakEnergy: number = 0; private lineStarted: boolean = false; constructor() { this.initSpeechRecognition(); } private initSpeechRecognition() { const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition; if (SpeechRecognition) { this.recognition = new SpeechRecognition(); this.recognition.continuous = false; // Stop after one phrase for search this.recognition.interimResults = true; this.recognition.lang = 'it-IT'; this.recognition.onresult = (event: any) => { const text = event.results[0][0].transcript; this.searchTranscript.set(text); }; this.recognition.onend = () => { this.isSearching.set(false); }; this.recognition.onerror = (err: any) => { console.error('Search recognition error:', err); this.isSearching.set(false); }; } } startSearchRecognition() { if (!this.recognition) { alert('Il riconoscimento vocale non è supportato in questo browser.'); return; } this.searchTranscript.set(''); this.isSearching.set(true); try { this.recognition.start(); } catch (e) { console.warn('Recognition already started', e); } } stopSearchRecognition() { this.recognition?.stop(); this.isSearching.set(false); } async startListening() { if (this.isListening()) return; try { this.stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: false // Prevents boosting background noise during silence } }); this.audioContext = new AudioContext(); const source = this.audioContext.createMediaStreamSource(this.stream); this.analyser = this.audioContext.createAnalyser(); this.analyser.fftSize = 512; source.connect(this.analyser); this.isListening.set(true); this.processAudio(); } catch (err) { console.error('Error accessing microphone', err); alert('Errore microfono: assicurati di usare HTTPS e di aver dato i permessi.'); } } stopListening() { if (this.animationFrame) cancelAnimationFrame(this.animationFrame); this.stream?.getTracks().forEach(track => track.stop()); this.audioContext?.close(); this.isListening.set(false); this.energyLevel.set(0); } private processAudio() { if (!this.analyser) return; const bufferLength = this.analyser.frequencyBinCount; const dataArray = new Uint8Array(bufferLength); const analyze = () => { this.analyser!.getByteFrequencyData(dataArray); // Calculate average energy (volume) let sum = 0; for (let i = 0; i < bufferLength; i++) { sum += dataArray[i]; } const avgEnergy = sum / bufferLength; this.energyLevel.set(avgEnergy); const now = Date.now(); // Balanced mapping: 0% -> 220 (very quiet), 100% -> 20 (very sensitive) const currentThreshold = 220 - (this.sensitivity() * 2.0); if (avgEnergy > currentThreshold) { if (avgEnergy > this.peakEnergy) { this.peakEnergy = avgEnergy; } if (!this.isSpeaking) { this.isSpeaking = true; this.peakEnergy = avgEnergy; this.lastSilenceTime = now; } // Se siamo in "speaking" e sentiamo un calo significativo rispetto al picco recente (almeno 30% di calo) // Questo permette di avanzare anche se c'è rumore di fondo sopra la soglia base. const dropRatio = (this.peakEnergy - avgEnergy) / this.peakEnergy; if (this.isSpeaking && dropRatio > 0.35 && this.peakEnergy > currentThreshold * 1.2) { if (now - this.lastWordTime > this.COOLDOWN) { this.linesDetected.update(v => v + 1); this.lastWordTime = now; this.peakEnergy = avgEnergy; // Reset peak this.isSpeaking = false; console.log('Line advanced - relative drop detected', { dropRatio, avgEnergy, peak: this.peakEnergy }); } } this.lastSilenceTime = now; } else { // Sotto soglia (silenzio per il sistema) if (this.isSpeaking && (now - this.lastSilenceTime > 200)) { if (now - this.lastWordTime > this.COOLDOWN) { this.linesDetected.update(v => v + 1); this.lastWordTime = now; console.log('Line advanced - silence detected'); } this.isSpeaking = false; this.peakEnergy = 0; } } this.animationFrame = requestAnimationFrame(analyze); }; analyze(); } resetWordCount() { this.wordsDetected.set(0); this.linesDetected.set(0); this.lineStarted = false; } }