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
+10 -34
View File
@@ -45,8 +45,7 @@
[class.full-screen-container]="settingsService.fullscreenMode()"
(touchstart)="onTouchStart($event)"
(touchmove)="onTouchMove($event)"
(touchend)="onTouchEnd()"
(wheel)="onWheel($event)">
(touchend)="onTouchEnd()">
<!-- Landscape Side Controls (Scrollable) -->
@@ -137,20 +136,6 @@
</div>
</ion-toolbar>
<!-- Visualizzatore Vocale & Log del Riconoscimento in Tempo Reale -->
<div class="transcript-area" *ngIf="settingsService.enableAcousticAutoscroll() && audioEngine.isListening()" style="background: rgba(0, 0, 0, 0.55); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); padding: 10px 14px; border-top: 1px solid rgba(255,255,255,0.08); display: flex; flex-direction: column; gap: 8px; align-items: center; justify-content: center; position: relative;">
<div class="energy-bar-container" style="width: 100%; height: 5px; background: rgba(255,255,255,0.12); border-radius: 3px; overflow: hidden;">
<div class="energy-bar" [style.width.%]="math.min(100, audioEngine.energyLevel() * 3)" style="height: 100%; background: linear-gradient(90deg, var(--ion-color-secondary) 0%, #20e9ff 100%); border-radius: 3px; transition: width 0.08s ease-out;"></div>
</div>
<div class="speech-log-row" style="display: flex; align-items: center; justify-content: center; width: 100%;">
<div class="acoustic-counter-badge" style="display: inline-flex; align-items: center; gap: 6px; background: rgba(32, 233, 255, 0.12); border: 1px solid rgba(32, 233, 255, 0.25); color: #20e9ff; padding: 4px 10px; border-radius: 20px; font-size: 0.75rem; font-weight: 700;">
<span style="width: 6px; height: 6px; background: #20e9ff; border-radius: 50%; display: inline-block; box-shadow: 0 0 8px #20e9ff; animation: pulse 1.5s infinite;"></span>
<span>Parole Rilevate: {{ wordsSpokenInCurrentPage }} / {{ getPageTargetWordCount() }}</span>
</div>
</div>
</div>
<ion-toolbar class="bg-gradient slim-toolbar">
<div class="slim-controls">
<!-- Autoscroll Standard in Portrait Footer -->
@@ -167,9 +152,9 @@
</ion-button>
</div>
<!-- Karaoke Toggle in Portrait -->
<ion-button *ngIf="settingsService.enableAcousticAutoscroll()" fill="clear" size="small" (click)="toggleListening()" [color]="audioEngine.isListening() ? 'danger' : 'secondary'" class="mic-btn-portrait">
<ion-icon slot="icon-only" [name]="audioEngine.isListening() ? 'mic' : 'mic-off'"></ion-icon>
<!-- Camera Head Gestures Toggle in Portrait -->
<ion-button fill="clear" size="small" (click)="toggleCameraNavigation()" [color]="enableCameraNavigation() ? 'success' : 'secondary'">
<ion-icon slot="icon-only" [name]="enableCameraNavigation() ? 'videocam' : 'videocam-off-outline'"></ion-icon>
</ion-button>
@@ -220,21 +205,12 @@
</ion-toolbar>
</ion-footer>
<!-- Vertical Sensitivity Slider Overlay -->
<div class="mic-sensitivity-overlay" *ngIf="settingsService.enableAcousticAutoscroll() && showSensitivitySlider() && audioEngine.isListening()">
<div class="slider-card glass">
<ion-button fill="clear" color="secondary" (click)="toggleSensitivitySlider($event)" class="close-slider-btn">
<ion-icon name="close-outline"></ion-icon>
</ion-button>
<div class="slider-wrapper"
(touchstart)="handleSensitivityTouch($event)"
(touchmove)="handleSensitivityTouch($event)">
<div class="custom-vertical-slider">
<div class="slider-track"></div>
<div class="slider-fill" [style.height.%]="(audioEngine.sensitivity() - 50) * 2"></div>
<div class="slider-knob" [style.bottom.%]="(audioEngine.sensitivity() - 50) * 2"></div>
</div>
<!-- Floating Camera Preview for Head Navigation -->
<div class="camera-preview-floating glass" *ngIf="enableCameraNavigation()">
<video id="face-preview-video" muted playsinline autoplay></video>
<div class="camera-preview-overlay">
<div class="angle-indicator" [class.tilted]="faceDetector.isTilted()">
{{ faceDetector.currentTiltAngle() }}°
</div>
<span class="sensitivity-label outfit-font">{{ audioEngine.sensitivity() }}%</span>
</div>
</div>
+158
View File
@@ -455,6 +455,113 @@ ion-content.full-screen-content {
}
}
.voice-threshold-overlay {
position: fixed;
left: 15px;
top: 50%;
transform: translateY(-50%);
z-index: 1000;
pointer-events: auto;
@media (orientation: landscape) {
left: 15px;
}
.slider-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 6px 12px 6px;
background: rgba(18, 18, 18, 0.85) !important;
backdrop-filter: blur(25px);
border-radius: 24px;
border: 1px solid rgba(46, 213, 115, 0.4);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
gap: 8px;
animation: slideInLeft 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.close-slider-btn {
--padding-start: 0;
--padding-end: 0;
margin: 0;
height: 36px;
width: 36px;
--color: var(--ion-color-secondary);
ion-icon {
font-size: 1.5rem;
}
}
.slider-wrapper {
height: 180px;
width: 40px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
padding: 10px 0;
}
.custom-vertical-slider {
position: relative;
width: 8px;
height: 100%;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
.slider-track {
position: absolute;
width: 100%;
height: 100%;
}
.slider-fill {
position: absolute;
bottom: 0;
width: 100%;
background: linear-gradient(to top, #2ed573, #7bed9f);
border-radius: 4px;
box-shadow: 0 0 10px rgba(46, 213, 115, 0.3);
transition: height 0.05s linear;
}
.slider-knob {
position: absolute;
left: 50%;
transform: translate(-50%, 50%);
width: 28px;
height: 28px;
background: #2ed573;
border-radius: 50%;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5), 0 0 15px rgba(46, 213, 115, 0.5);
border: 2px solid #fff;
transition: bottom 0.05s linear;
}
}
.sensitivity-label {
font-size: 0.8rem;
font-weight: 800;
color: #2ed573;
min-width: 40px;
text-align: center;
letter-spacing: 0.5px;
}
}
@keyframes slideInLeft {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.floating-autoscroll-bar {
position: fixed;
bottom: 120px;
@@ -612,3 +719,54 @@ ion-content.full-screen-content {
transform: translate(-50%, 0);
}
}
.camera-preview-floating {
position: fixed;
bottom: 60px;
right: 15px;
width: 90px;
height: 120px;
border-radius: 12px;
overflow: hidden;
border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.4);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
z-index: 1000;
display: flex;
flex-direction: column;
background: #000;
@media (orientation: landscape) {
bottom: 15px;
right: 80px;
}
video {
width: 100%;
height: 100%;
object-fit: cover;
transform: scaleX(-1); // Mirror camera preview
}
.camera-preview-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.6);
padding: 2px 0;
display: flex;
justify-content: center;
align-items: center;
.angle-indicator {
font-size: 0.75rem;
font-weight: 700;
color: #fff;
&.tilted {
color: #2ed573;
text-shadow: 0 0 5px #2ed573;
}
}
}
}
+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();
}
}