Avanzamento con riconoscimento facciale

This commit is contained in:
David Frassi
2026-06-05 16:00:00 +02:00
parent 92e955915e
commit e7e43468b3
5 changed files with 832 additions and 421 deletions
+41 -242
View File
@@ -3,7 +3,6 @@ import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { AlertController, ToastController, GestureController } from '@ionic/angular';
import { CantiService, Canto } from '../../services/canti.service';
import { AudioEngineService } from '../../services/audio-engine.service';
import { LyricsParserService, ParsedSection } from '../../services/lyrics-parser.service';
import { ThemeService } from '../../services/theme.service';
import { SettingsService } from '../../services/settings.service';
@@ -13,6 +12,7 @@ import { YoutubePlayerService } from '../../services/youtube-player.service';
import { MyCantiService } from '../../services/my-canti.service';
import { ComunitaService } from '../../services/comunita.service';
import { StatsService } from '../../services/stats.service';
import { FaceDetectorService } from '../../services/face-detector.service';
@Component({
selector: 'app-player',
@@ -26,7 +26,6 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
/** true = show chords (accordi mode), false = text only */
public showChords = signal<boolean>(false);
public showSensitivitySlider = signal<boolean>(false);
/** Autoscroll standard */
public isAutoscrolling = signal<boolean>(false);
@@ -98,7 +97,6 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
private lastAdvanceTimestamp: number = 0; // For safety cooldown
private readonly ADVANCE_COOLDOWN = 1500; // 1.5 seconds min
public wordsSpokenInCurrentPage: number = 0;
public lastScrollBlock = signal<'start' | 'center'>('start');
private lastProcessedTranscript: string = '';
private initialStartTime: number = 0;
@@ -106,7 +104,6 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
private route = inject(ActivatedRoute);
public router = inject(Router);
public cantiService = inject(CantiService);
public audioEngine = inject(AudioEngineService);
private lyricsParser = inject(LyricsParserService);
private alertCtrl = inject(AlertController);
private toastCtrl = inject(ToastController);
@@ -120,6 +117,9 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
private gestureCtrl = inject(GestureController);
private el = inject(ElementRef);
private sanitizer = inject(DomSanitizer);
public faceDetector = inject(FaceDetectorService);
public enableCameraNavigation = signal<boolean>(false);
private songStartTime: number = 0;
@@ -135,23 +135,6 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
this.showChords.set(this.settingsService.showChordsDefault());
}, { allowSignalWrites: true });
// Avanzamento guidato dai picchi acustici con conteggio parole della riga in corso
effect(() => {
const count = this.audioEngine.linesDetected();
if (count > 0 && this.audioEngine.isListening()) {
this.handleAcousticPeak();
}
});
// Avanzamento in tempo reale tramite riconoscimento vocale di prossimità (driver primario veloce)
effect(() => {
const transcript = this.audioEngine.backgroundTranscript();
const isSpeechActive = this.audioEngine.speechRecognitionActive();
if (transcript && this.audioEngine.isListening() && isSpeechActive) {
this.checkWordAdvancement();
}
});
// Auto-scroll logic: keep active line in view
effect(() => {
const idx = this.currentLineIndex();
@@ -275,26 +258,6 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
}
});
gestureX.enable();
const gestureY = this.gestureCtrl.create({
el: this.el.nativeElement,
direction: 'y',
gestureName: 'swipe-song-y',
canStart: (ev) => {
const target = ev.event.target as HTMLElement;
return !target.closest('ion-footer') && !target.closest('ion-header');
},
onEnd: (ev) => {
if (Math.abs(ev.deltaY) > 50) {
if (ev.deltaY > 0) {
this.prev();
} else {
this.next();
}
}
}
});
gestureY.enable();
}
@@ -361,25 +324,6 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
this.initialPinchDistance = null;
}
private lastWheelTime = 0;
private readonly WHEEL_COOLDOWN = 400; // ms to avoid too rapid advancement
onWheel(event: WheelEvent) {
event.preventDefault();
const now = Date.now();
if (now - this.lastWheelTime < this.WHEEL_COOLDOWN) {
return;
}
if (Math.abs(event.deltaY) > 5) {
if (event.deltaY > 0) {
this.next();
} else {
this.prev();
}
this.lastWheelTime = now;
}
}
private getDistance(t1: Touch, t2: Touch): number {
return Math.sqrt(Math.pow(t1.clientX - t2.clientX, 2) + Math.pow(t1.clientY - t2.clientY, 2));
}
@@ -434,40 +378,6 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
}
}
toggleListening() {
if (this.audioEngine.isListening()) {
this.audioEngine.stopListening();
this.showSensitivitySlider.set(false);
} else {
this.audioEngine.startListening();
this.showSensitivitySlider.set(true);
}
}
toggleSensitivitySlider(event: Event) {
event.stopPropagation();
this.showSensitivitySlider.update(v => !v);
}
onSensitivityChange(event: any) {
this.audioEngine.sensitivity.set(event.detail.value);
}
handleSensitivityTouch(event: TouchEvent) {
event.preventDefault();
const touch = event.touches[0];
const target = event.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
// Calculate percentage based on Y position (bottom is 50%, top is 100%)
const rawPercentage = 100 - ((touch.clientY - rect.top) / rect.height * 100);
// Map 0-100 raw to 50-100 range
let percentage = 50 + (rawPercentage * 0.5);
percentage = Math.max(50, Math.min(100, Math.round(percentage)));
this.audioEngine.sensitivity.set(percentage);
}
toggleAudio() {
if (this.youtubePlayerService.currentCantoId() === this.canto()?.id) {
this.youtubePlayerService.togglePlayPause();
@@ -492,10 +402,8 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
restart() {
this.currentLineIndex.set(0);
this.audioEngine.resetWordCount();
this.youtubePlayerService.seekTo(0);
this.channel.postMessage({ type: 'SYNC_INDEX', index: 0 });
this.wordsSpokenInCurrentPage = 0;
this.lastScrollBlock.set('center');
}
@@ -549,26 +457,6 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
}
}
private getLineWords(lineIndex: number): string[] {
const allLines = this.getAllLines();
const lineText = allLines[lineIndex]?.text || '';
return lineText
.toLowerCase()
.split(/\s+/)
.map((w: string) => w.replace(/[^a-zA-Z0-9àèìòùáéíóú]/g, ''))
.filter((w: string) => w.length >= 3);
}
public getPageWords(startIndex: number, pageSize: number): string[] {
const allWords: string[] = [];
const totalLines = this.getTotalLines();
const end = Math.min(totalLines, startIndex + pageSize);
for (let i = startIndex; i < end; i++) {
allWords.push(...this.getLineWords(i));
}
return allWords;
}
public getVisibleLineIndices(): number[] {
try {
const container = document.querySelector('.lyrics-container') as HTMLElement;
@@ -607,127 +495,6 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
}
}
public getPageTargetWordCount(): number {
const visibleIndices = this.getVisibleLineIndices();
let totalRawWords = 0;
const currentIdx = this.currentLineIndex();
if (visibleIndices.length > 0) {
if (currentIdx === 0) {
// Sulla prima pagina, contiamo solo la prima metà delle righe visibili
// per far avanzare l'applicazione non appena si arriva a metà della prima schermata
const halfLength = Math.max(1, Math.ceil(visibleIndices.length / 2));
for (let i = 0; i < halfLength; i++) {
const idx = visibleIndices[i];
totalRawWords += this.getLineRawWordCount(idx);
}
} else {
// Dalla seconda pagina in poi, contiamo solo le righe attive e future (escludendo il testo già cantato in alto)
for (const idx of visibleIndices) {
if (idx >= currentIdx) {
totalRawWords += this.getLineRawWordCount(idx);
}
}
}
} else {
// Fallback: calcolo teorico basato sulla pagina corrente
const pageSize = this.calculatePageStepSize();
const totalLines = this.getTotalLines();
let end: number;
if (currentIdx === 0) {
// Fallback prima pagina: metà delle righe della pagina teorica
const halfPageSize = Math.max(1, Math.ceil(pageSize / 2));
end = Math.min(totalLines, currentIdx + halfPageSize);
} else {
end = Math.min(totalLines, currentIdx + pageSize);
}
for (let i = currentIdx; i < end; i++) {
totalRawWords += this.getLineRawWordCount(i);
}
}
// Soglia al 70% delle parole contate nella schermata visibile
return Math.max(1, Math.ceil(totalRawWords * 0.70));
}
private checkWordAdvancement() {
const now = Date.now();
// Cooldown per prevenire avanzamenti multipli troppo veloci
if (now - this.lastAdvanceTimestamp < this.ADVANCE_COOLDOWN) {
return;
}
const currentIdx = this.currentLineIndex();
let nextPageIdx: number;
const totalLines = this.getTotalLines();
if (currentIdx === 0) {
// Sulla prima pagina, la "pagina successiva" è in realtà la seconda metà della prima schermata
const visibleIndices = this.getVisibleLineIndices();
if (visibleIndices.length > 0) {
const halfLength = Math.max(1, Math.ceil(visibleIndices.length / 2));
nextPageIdx = visibleIndices[halfLength] !== undefined ? visibleIndices[halfLength] : halfLength;
} else {
const pageSize = this.calculatePageStepSize();
const halfPageSize = Math.max(1, Math.ceil(pageSize / 2));
nextPageIdx = currentIdx + halfPageSize;
}
} else {
nextPageIdx = this.calculateNextPageStartIndex();
}
if (nextPageIdx >= totalLines) return;
const pageSize = this.calculatePageStepSize();
// Raccoglie le parole del blocco successivo (metà pagina se siamo all'inizio, altrimenti intera pagina)
const nextPageWords = this.getPageWords(nextPageIdx, currentIdx === 0 ? Math.max(1, Math.ceil(pageSize / 2)) : pageSize);
if (nextPageWords.length === 0) return;
// Parole rilevate di recente (con tolleranza di ritardo della Speech API)
const recentWords = this.audioEngine.getRecentWordsSince(this.lastAdvanceTimestamp);
// Controlla se l'utente ha cantato parole della pagina successiva -> Avanzamento istantaneo immediato
const matchNext = nextPageWords.some(word => recentWords.includes(word));
if (matchNext) {
console.log(`[SmartKaraoke] Parola chiave del blocco successivo rilevata! Avanzamento immediato a: ${nextPageIdx}`);
this.lastScrollBlock.set('center');
this.currentLineIndex.set(nextPageIdx);
this.channel.postMessage({ type: 'SYNC_INDEX', index: nextPageIdx });
this.lastAdvanceTimestamp = now;
this.audioEngine.resetWordCount();
this.wordsSpokenInCurrentPage = 0;
}
}
public getLineRawWordCount(lineIndex: number): number {
const allLines = this.getAllLines();
const lineText = allLines[lineIndex]?.text || '';
const words = lineText.trim().split(/\s+/).filter((w: string) => w.length > 0);
return words.length;
}
public getLineTargetWordCount(lineIndex: number): number {
const rawCount = this.getLineRawWordCount(lineIndex);
// Soglia proporzionale tollerante (65% del conteggio parole effettivo) per compensare legature melodiche
return Math.max(1, Math.ceil(rawCount * 0.65));
}
private handleAcousticPeak() {
const targetWordCount = this.getPageTargetWordCount();
this.wordsSpokenInCurrentPage++;
console.log(`[PeakAdvance] Peak detected. Spoken words on page: ${this.wordsSpokenInCurrentPage}/${targetWordCount}`);
if (this.wordsSpokenInCurrentPage >= targetWordCount) {
console.log(`[PeakAdvance] Target page word count reached (${targetWordCount}). Advancing page.`);
this.next(true);
}
}
private calculateNextPageStartIndex(): number {
try {
const container = document.querySelector('.lyrics-container') as HTMLElement;
@@ -781,8 +548,6 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
next(isAutomatic: boolean = false) {
this.lastAdvanceTimestamp = Date.now();
this.audioEngine.resetWordCount();
this.wordsSpokenInCurrentPage = 0;
const totalLines = this.getTotalLines();
const prevIdx = this.currentLineIndex();
@@ -825,8 +590,6 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
prev() {
this.lastAdvanceTimestamp = Date.now();
this.audioEngine.resetWordCount();
this.wordsSpokenInCurrentPage = 0;
const prevIdx = this.currentLineIndex();
if (prevIdx > 0) {
@@ -1064,10 +827,46 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
}
}
toggleCameraNavigation() {
if (this.enableCameraNavigation()) {
this.stopCameraNavigation();
} else {
this.startCameraNavigation();
}
}
startCameraNavigation() {
this.enableCameraNavigation.set(true);
setTimeout(async () => {
const videoEl = document.querySelector('#face-preview-video') as HTMLVideoElement;
if (videoEl) {
try {
await this.faceDetector.start(videoEl, (direction) => {
console.log(`[PlayerPage] Head tilt trigger received: ${direction}`);
if (direction === 'next') {
this.next(false);
} else {
this.prev();
}
});
} catch (e) {
this.enableCameraNavigation.set(false);
alert('Impossibile accedere alla fotocamera. Assicurati di aver concesso i permessi e di usare HTTPS.');
}
}
}, 300);
}
stopCameraNavigation() {
this.enableCameraNavigation.set(false);
this.faceDetector.stop();
}
ngOnDestroy() {
this.stopAutoscroll();
this.logPreviousSongTime();
this.audioEngine.stopListening();
this.stopCameraNavigation();
this.channel.close();
}
}