Spaces:
Sleeping
Sleeping
| // VoiceAnalysis.tsx - Componente React completo per Lovable | |
| // Questo file può essere copiato direttamente in Lovable | |
| import { useState, useRef } from 'react'; | |
| import { Mic, Square, Loader2, Wind, Droplet, Mountain, Flame } from 'lucide-react'; | |
| import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; | |
| import { Button } from '@/components/ui/button'; | |
| import { Progress } from '@/components/ui/progress'; | |
| // Configurazione API - CAMBIA QUESTO URL dopo il deploy su HF | |
| const API_BASE_URL = 'https://YOUR-USERNAME-voice-break-api.hf.space'; | |
| interface AnalysisResult { | |
| element: string; | |
| confidence: number; | |
| prosody_score: { | |
| aria: number; | |
| acqua: number; | |
| terra: number; | |
| fuoco: number; | |
| }; | |
| features: { | |
| rms: number; | |
| pitch: number; | |
| duration: number; | |
| }; | |
| } | |
| export default function VoiceAnalysis() { | |
| const [isRecording, setIsRecording] = useState(false); | |
| const [isAnalyzing, setIsAnalyzing] = useState(false); | |
| const [analysis, setAnalysis] = useState<AnalysisResult | null>(null); | |
| const [error, setError] = useState<string | null>(null); | |
| const mediaRecorderRef = useRef<MediaRecorder | null>(null); | |
| const chunksRef = useRef<Blob[]>([]); | |
| // Funzione per iniziare la registrazione | |
| const startRecording = async () => { | |
| try { | |
| const stream = await navigator.mediaDevices.getUserMedia({ | |
| audio: { | |
| echoCancellation: true, | |
| noiseSuppression: true, | |
| sampleRate: 44100 | |
| } | |
| }); | |
| const mediaRecorder = new MediaRecorder(stream, { | |
| mimeType: 'audio/webm' | |
| }); | |
| mediaRecorderRef.current = mediaRecorder; | |
| chunksRef.current = []; | |
| mediaRecorder.ondataavailable = (e) => { | |
| if (e.data.size > 0) { | |
| chunksRef.current.push(e.data); | |
| } | |
| }; | |
| mediaRecorder.start(); | |
| setIsRecording(true); | |
| setError(null); | |
| } catch (err) { | |
| console.error('Errore accesso microfono:', err); | |
| setError('Impossibile accedere al microfono. Verifica i permessi del browser.'); | |
| } | |
| }; | |
| // Funzione per fermare la registrazione e analizzare | |
| const stopRecordingAndAnalyze = async () => { | |
| const mediaRecorder = mediaRecorderRef.current; | |
| if (!mediaRecorder) return; | |
| mediaRecorder.onstop = async () => { | |
| setIsRecording(false); | |
| setIsAnalyzing(true); | |
| // Crea blob audio | |
| const audioBlob = new Blob(chunksRef.current, { type: 'audio/webm' }); | |
| // Ferma tutti i track del microfono | |
| mediaRecorder.stream.getTracks().forEach(track => track.stop()); | |
| // Invia all'API per analisi | |
| await analyzeVoice(audioBlob); | |
| }; | |
| mediaRecorder.stop(); | |
| }; | |
| // Funzione per chiamare l'API di analisi | |
| const analyzeVoice = async (audioBlob: Blob) => { | |
| try { | |
| const formData = new FormData(); | |
| formData.append('audio', audioBlob, 'voice.webm'); | |
| // Recupera baseline se esiste | |
| const baselineStr = localStorage.getItem('voice_baseline'); | |
| if (baselineStr) { | |
| const baseline = JSON.parse(baselineStr); | |
| formData.append('baseline_rms', baseline.rms_baseline); | |
| formData.append('baseline_pitch', baseline.pitch_baseline); | |
| } | |
| const response = await fetch(`${API_BASE_URL}/api/analyze-voice`, { | |
| method: 'POST', | |
| body: formData | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`API Error: ${response.statusText}`); | |
| } | |
| const result: AnalysisResult = await response.json(); | |
| setAnalysis(result); | |
| } catch (err) { | |
| console.error('Errore analisi:', err); | |
| setError('Errore durante l\'analisi vocale. Riprova.'); | |
| } finally { | |
| setIsAnalyzing(false); | |
| } | |
| }; | |
| // Helper per ottenere icona elemento | |
| const getElementIcon = (element: string) => { | |
| switch (element) { | |
| case 'aria': return <Wind className="w-12 h-12" />; | |
| case 'acqua': return <Droplet className="w-12 h-12" />; | |
| case 'terra': return <Mountain className="w-12 h-12" />; | |
| case 'fuoco': return <Flame className="w-12 h-12" />; | |
| default: return null; | |
| } | |
| }; | |
| // Helper per ottenere colore elemento | |
| const getElementColor = (element: string) => { | |
| switch (element) { | |
| case 'aria': return 'text-cyan-500 bg-cyan-50'; | |
| case 'acqua': return 'text-blue-500 bg-blue-50'; | |
| case 'terra': return 'text-amber-700 bg-amber-50'; | |
| case 'fuoco': return 'text-red-500 bg-red-50'; | |
| default: return 'text-gray-500 bg-gray-50'; | |
| } | |
| }; | |
| return ( | |
| <div className="min-h-screen bg-gradient-to-br from-purple-50 via-white to-blue-50 p-8"> | |
| <div className="max-w-4xl mx-auto space-y-8"> | |
| {/* Header */} | |
| <div className="text-center space-y-2"> | |
| <h1 className="text-4xl font-bold bg-gradient-to-r from-purple-600 to-blue-600 bg-clip-text text-transparent"> | |
| Voice Break 🎙️ | |
| </h1> | |
| <p className="text-gray-600"> | |
| Scopri l'elemento della tua voce attraverso l'analisi prosodica | |
| </p> | |
| </div> | |
| {/* Recording Card */} | |
| <Card className="shadow-xl"> | |
| <CardHeader> | |
| <CardTitle>Registra la tua voce</CardTitle> | |
| <CardDescription> | |
| Clicca il pulsante e parla per 3-5 secondi | |
| </CardDescription> | |
| </CardHeader> | |
| <CardContent className="space-y-6"> | |
| {/* Recording Button */} | |
| <div className="flex justify-center"> | |
| <Button | |
| size="lg" | |
| onClick={isRecording ? stopRecordingAndAnalyze : startRecording} | |
| disabled={isAnalyzing} | |
| className={`w-32 h-32 rounded-full transition-all ${ | |
| isRecording | |
| ? 'bg-red-500 hover:bg-red-600 animate-pulse' | |
| : 'bg-purple-500 hover:bg-purple-600' | |
| }`} | |
| > | |
| {isRecording ? ( | |
| <Square className="w-12 h-12" /> | |
| ) : isAnalyzing ? ( | |
| <Loader2 className="w-12 h-12 animate-spin" /> | |
| ) : ( | |
| <Mic className="w-12 h-12" /> | |
| )} | |
| </Button> | |
| </div> | |
| {/* Status Text */} | |
| <div className="text-center"> | |
| {isRecording && ( | |
| <p className="text-red-500 font-medium animate-pulse"> | |
| 🔴 Registrando... Clicca per fermare | |
| </p> | |
| )} | |
| {isAnalyzing && ( | |
| <p className="text-purple-500 font-medium"> | |
| 🔍 Analisi in corso... | |
| </p> | |
| )} | |
| {!isRecording && !isAnalyzing && !analysis && ( | |
| <p className="text-gray-500"> | |
| Clicca il microfono per iniziare | |
| </p> | |
| )} | |
| </div> | |
| {/* Error Message */} | |
| {error && ( | |
| <div className="bg-red-50 border border-red-200 rounded-lg p-4"> | |
| <p className="text-red-700 text-sm">{error}</p> | |
| </div> | |
| )} | |
| </CardContent> | |
| </Card> | |
| {/* Analysis Results */} | |
| {analysis && ( | |
| <Card className="shadow-xl animate-in fade-in-50 duration-700"> | |
| <CardHeader> | |
| <CardTitle>Risultato Analisi</CardTitle> | |
| </CardHeader> | |
| <CardContent className="space-y-6"> | |
| {/* Elemento Dominante */} | |
| <div className={`rounded-xl p-8 ${getElementColor(analysis.element)} transition-all`}> | |
| <div className="flex items-center justify-between"> | |
| <div> | |
| <p className="text-sm font-medium opacity-70 mb-1"> | |
| Elemento Dominante | |
| </p> | |
| <p className="text-3xl font-bold capitalize"> | |
| {analysis.element} | |
| </p> | |
| <p className="text-sm mt-2 opacity-70"> | |
| Confidenza: {(analysis.confidence * 100).toFixed(1)}% | |
| </p> | |
| </div> | |
| <div className="opacity-70"> | |
| {getElementIcon(analysis.element)} | |
| </div> | |
| </div> | |
| </div> | |
| {/* Distribuzione Percentuali */} | |
| <div className="space-y-4"> | |
| <h3 className="font-semibold text-gray-700">Distribuzione Elementi</h3> | |
| {Object.entries(analysis.prosody_score).map(([element, score]) => ( | |
| <div key={element} className="space-y-2"> | |
| <div className="flex justify-between items-center"> | |
| <div className="flex items-center gap-2"> | |
| <div className={`w-3 h-3 rounded-full ${ | |
| element === 'aria' ? 'bg-cyan-500' : | |
| element === 'acqua' ? 'bg-blue-500' : | |
| element === 'terra' ? 'bg-amber-600' : | |
| 'bg-red-500' | |
| }`} /> | |
| <span className="font-medium capitalize">{element}</span> | |
| </div> | |
| <span className="text-sm font-bold">{score.toFixed(1)}%</span> | |
| </div> | |
| <Progress | |
| value={score} | |
| className="h-2" | |
| /> | |
| </div> | |
| ))} | |
| </div> | |
| {/* Feature Tecniche */} | |
| <div className="grid grid-cols-3 gap-4 pt-4 border-t"> | |
| <div className="text-center"> | |
| <p className="text-xs text-gray-500 mb-1">RMS Energy</p> | |
| <p className="text-lg font-bold">{analysis.features.rms.toFixed(4)}</p> | |
| </div> | |
| <div className="text-center"> | |
| <p className="text-xs text-gray-500 mb-1">Pitch (Hz)</p> | |
| <p className="text-lg font-bold">{analysis.features.pitch.toFixed(1)}</p> | |
| </div> | |
| <div className="text-center"> | |
| <p className="text-xs text-gray-500 mb-1">Durata (s)</p> | |
| <p className="text-lg font-bold">{analysis.features.duration.toFixed(1)}</p> | |
| </div> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |