Avanzamento con riconoscimento facciale
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user