188 lines
5.9 KiB
TypeScript
188 lines
5.9 KiB
TypeScript
import { Injectable, signal } from '@angular/core';
|
|
|
|
@Injectable({
|
|
providedIn: 'root'
|
|
})
|
|
export class AudioEngineService {
|
|
public isListening = signal<boolean>(false);
|
|
public wordsDetected = signal<number>(0);
|
|
public linesDetected = signal<number>(0);
|
|
public energyLevel = signal<number>(0); // For visual feedback
|
|
public searchTranscript = signal<string>('');
|
|
public isSearching = signal<boolean>(false);
|
|
public sensitivity = signal<number>(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;
|
|
}
|
|
}
|