
Proyecto Baker: Reconocimiento de Voz Local con Whisper en 1º y 2º de ESO
Publicado por: Departamento de Tecnología e Innovación Educativa — IES Monterroso
Proyecto: Proyecto Baker (app_baker.py — Puerto :8065)
Plataforma de difusión: MónSTEAM / AIDA-RAC
1. ¿Qué estamos haciendo en el aula?
En el IES Monterroso, el alumnado de 1º y 2º de ESO da un paso más allá en el ámbito de la Inteligencia Artificial: pasamos de interactuar por texto a explorar el Reconocimiento Automático del Habla (ASR / Speech-to-Text).
Con el Proyecto Baker, el aula de informática se convierte en un laboratorio acústico donde los estudiantes:
- Graban su propia voz bajo distintas condiciones de reto: voz clara, con ruido de fondo, a velocidad rápida o utilizando vocabulario técnico difícil.
- Procesan el audio en tiempo real utilizando un modelo local de Inteligencia Artificial (Whisper).
- Analizan la confianza por palabra: La aplicación colorea cada palabra transcrita (verde = alta seguridad, amarillo = dudosa, rojo = probable error) permitiendo ver exactamente cuándo y por qué “duda” la máquina.
- Calculan la tasa de error (WER / Word Error Rate): El alumnado escribe el texto real que pronunció para compararlo con el dictado de la IA, calculando matemáticamente el porcentaje de precisión obtenido.
2. Homenaje a Janet Baker: La pionera del reconocimiento de voz
Este proyecto rinde homenaje a Dr. Janet MacIver Baker (cuyas iniciales JB presiden la aplicación), investigadora en informática y biofísica, y cofundadora en 1982 de Dragon Systems.
En una época en la que los ordenadores eran miles de veces menos potentes que los actuales, Janet Baker y su equipo persiguieron un objetivo visionario: lograr que las computadoras entendieran el habla humana continua en tiempo real. Su trabajo culminó en 1997 con el lanzamiento de Dragon NaturallySpeaking, el primer software comercial capaz de transcribir voz continua sin necesidad de pausar entre palabra y palabra.
Recordar a Janet Baker en 1º y 2º de ESO nos permite enseñar a nuestro alumnado que las tecnologías de voz que hoy usamos a diario en nuestros teléfonos o en asistentes como Whisper o Siri no surgieron por arte de magia, sino gracias al trabajo riguroso de pioneras que dedicaron su carrera a resolver uno de los problemas más complejos de la informática.
3. Guía paso a paso: Instalación del Servidor Whisper con Docker
Para ejecutar el motor de transcripción en local (sin enviar datos fuera del centro educativo ni depender de servidores externos), utilizamos Docker para levantar un contenedor ligero de Whisper.
Paso 1: Instalar y verificar Docker Desktop
- Descarga e instala Docker Desktop para Windows o Linux.
- Inicia Docker Desktop y espera 1-2 minutos hasta que el icono de la ballena se muestre en color verde con el mensaje “Docker Desktop is running”.
Paso 2: Desplegar el contenedor de Whisper
Abre la terminal (PowerShell o CMD en Windows, o la consola de Linux) y ejecuta el siguiente comando en una sola línea:
Bash
docker run -d --name whisper-server -p 8001:8000 -e WHISPER__MODEL=tiny fedirz/faster-whisper-server:latest-cpu
Parámetros explicados:
-d: Ejecuta el servidor en segundo plano.-p 8001:8000: Expone el servidor en el puerto8001local.-e WHISPER__MODEL=tiny: Utiliza la variantetinyde Whisper, optimizada para procesar audio rápidamente en procesadores (CPU) sin requerir tarjetas gráficas de gama alta.
Paso 3: Comprobar la instalación
Verifica que el contenedor esté activo ejecutando:
Bash
docker ps
Si entras desde el navegador a http://localhost:8001/docs, verás la documentación interactiva OpenAPI confirmando que la API de transcripción está lista para recibir peticiones.
4. ¿Cómo funciona por dentro el Proyecto Baker?
La arquitectura del Proyecto Baker combina la captura multimedia en el navegador, un backend ligero en Python y un modelo de IA en Docker:
[ Micrófono Alumno ] ──(WebM)──> [ Frontend HTML/JS ] ──(Multipart POST)──> [ app_baker.py (:8065) ]
│
[ Transcripción + Confianza ] <──(JSON)── [ Docker faster-whisper-server (:8001) ] ┘
- Captura WebRTC: El navegador graba la voz del alumno mediante la API
MediaRecordery empaqueta el audio en formato.webm. - Petición Multipart en FastAPI: La aplicación
app_baker.pyrecibe el archivo mediante la ruta/api/transcribey reenvía el flujo de bytes hacia la API local de Whisper en el puerto:8001. - El ajuste crítico del Timeout (120s): Al procesar IA mediante CPU en equipos modestos de aula, la transcripción de un audio de varios segundos puede demorarse más de 30 segundos. En
app_baker.pyajustamos la llamada conurllib.request.urlopen(req, timeout=120)para garantizar que el servidor espere la respuesta real del modelo sin caer en el modo demostración por interrupción de tiempo. - Métricas de calidad (WER y Confianza): La API extrae la probabilidad o el promedio de log-probabilidades (
avg_logprob) asignado a cada palabra para colorear el resultado en pantalla y utiliza el algoritmoSequenceMatcherpara calcular la precisión frente al texto de referencia introducido por el estudiante.

5. Código fuente completo (app_baker.py)
A continuación se muestra el código ejecutable de la aplicación, listo para ser ejecutado con python app_baker.py:
Python
# app_baker.py
# NOTA DE INSTALACIÓN: además de fastapi, uvicorn y pydantic, esta app necesita
# el paquete "python-multipart" para poder recibir el audio grabado:
# pip install fastapi uvicorn pydantic python-multipart
#
# Para transcripción real, se apoya en un servidor Whisper local compatible con
# la API de OpenAI (ej. faster-whisper-server, whisper.cpp server, LocalAI...)
# escuchando en WHISPER_BASE_URL. Si no hay ningún servidor disponible, la app
# cae automáticamente en transcripciones de demostración para no bloquear la
# clase.
import os
import io
import json
import uuid
import difflib
import urllib.request
from typing import List, Optional
from fastapi import FastAPI, UploadFile, File, Form
from fastapi.responses import HTMLResponse
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import uvicorn
app = FastAPI(
title="Proyecto Baker - El Transcriptor de Voz Local (IES Monterroso)",
description="Laboratorio de Reconocimiento de Voz (Speech-to-Text) para ESO (Puerto 8065)",
version="1.0.0"
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
WHISPER_BASE_URL = os.getenv("WHISPER_BASE_URL", "http://localhost:8001/v1/audio/transcriptions")
WHISPER_MODEL_NAME = os.getenv("WHISPER_MODEL_NAME", "whisper-1")
class AccuracyRequest(BaseModel):
transcript_text: str
reference_text: str
DEMO_TRANSCRIPTS = {
"limpio": {
"text": "La fotosíntesis es el proceso mediante el cual las plantas transforman la luz solar en energía química.",
"confidences": [0.98, 0.97, 0.95, 0.96, 0.94, 0.97, 0.95, 0.93, 0.96, 0.95, 0.92, 0.94, 0.9, 0.93, 0.91, 0.89, 0.9]
},
"ruido": {
"text": "La fotosíntesis es el proceso mediante el cual las [inaudible] transforman la luz solar en [inaudible] química.",
"confidences": [0.9, 0.88, 0.85, 0.8, 0.75, 0.7, 0.65, 0.3, 0.6, 0.55, 0.5, 0.45, 0.35, 0.2, 0.4, 0.55, 0.55]
},
"rapido": {
"text": "La fotosíntesis es proceso mediante cual plantas transforman luz solar en energía química.",
"confidences": [0.85, 0.8, 0.6, 0.7, 0.55, 0.65, 0.7, 0.5, 0.75, 0.6, 0.7, 0.65, 0.55]
},
"tecnico": {
"text": "La fotosíntesis oxigénica utiliza clorofila y libera oxígeno como subproducto de la reducción del dióxido de carbono.",
"confidences": [0.9, 0.55, 0.85, 0.4, 0.88, 0.5, 0.86, 0.9, 0.35, 0.45, 0.6, 0.5, 0.3, 0.4, 0.7, 0.65, 0.6]
}
}
def build_multipart_body(audio_bytes: bytes, filename: str, fields: dict):
boundary = uuid.uuid4().hex
parts = []
for key, value in fields.items():
parts.append(f"--{boundary}\r\n".encode())
parts.append(f'Content-Disposition: form-data; name="{key}"\r\n\r\n'.encode())
parts.append(f"{value}\r\n".encode())
parts.append(f"--{boundary}\r\n".encode())
parts.append(
f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'
f"Content-Type: application/octet-stream\r\n\r\n".encode()
)
parts.append(audio_bytes)
parts.append(f"\r\n--{boundary}--\r\n".encode())
body = b"".join(parts)
content_type = f"multipart/form-data; boundary={boundary}"
return body, content_type
def transcribe_with_whisper(audio_bytes: bytes, filename: str) -> Optional[dict]:
body, content_type = build_multipart_body(
audio_bytes, filename,
{"model": WHISPER_MODEL_NAME, "response_format": "verbose_json"}
)
req = urllib.request.Request(
WHISPER_BASE_URL,
data=body,
headers={"Content-Type": content_type}
)
try:
# Timeout ampliado a 120s para dar tiempo de procesamiento en CPU
with urllib.request.urlopen(req, timeout=120) as response:
return json.loads(response.read().decode("utf-8"))
except Exception:
return None
def extract_words_with_confidence(whisper_result: dict) -> List[dict]:
"""Intenta extraer confianza por palabra de un resultado verbose_json de Whisper.
No todos los servidores locales exponen 'probability' por palabra; si no está
disponible, se aproxima usando el avg_logprob del segmento."""
words_out = []
segments = whisper_result.get("segments", [])
for seg in segments:
seg_conf = None
if "avg_logprob" in seg:
seg_conf = max(0.0, min(1.0, 1 + seg["avg_logprob"]))
seg_words = seg.get("words")
if seg_words:
for w in seg_words:
conf = w.get("probability", seg_conf if seg_conf is not None else 0.8)
words_out.append({"word": w.get("word", "").strip(), "confidence": round(conf, 2)})
else:
for token in seg.get("text", "").split():
words_out.append({"word": token, "confidence": round(seg_conf if seg_conf is not None else 0.8, 2)})
if not words_out and whisper_result.get("text"):
for token in whisper_result["text"].split():
words_out.append({"word": token, "confidence": 0.85})
return words_out
def compute_wer_diff(reference: str, hypothesis: str):
ref_words = reference.strip().split()
hyp_words = hypothesis.strip().split()
matcher = difflib.SequenceMatcher(None, ref_words, hyp_words)
diff_segments = []
errors = 0
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
if tag == "equal":
for w in ref_words[i1:i2]:
diff_segments.append({"word": w, "type": "match"})
elif tag == "replace":
errors += max(i2 - i1, j2 - j1)
for w in ref_words[i1:i2]:
diff_segments.append({"word": w, "type": "missing"})
for w in hyp_words[j1:j2]:
diff_segments.append({"word": w, "type": "wrong"})
elif tag == "delete":
errors += (i2 - i1)
for w in ref_words[i1:i2]:
diff_segments.append({"word": w, "type": "missing"})
elif tag == "insert":
errors += (j2 - j1)
for w in hyp_words[j1:j2]:
diff_segments.append({"word": w, "type": "wrong"})
total_ref = max(len(ref_words), 1)
wer = errors / total_ref
accuracy = round(max(0.0, (1 - wer)) * 100, 1)
return diff_segments, accuracy
HTML_CONTENT = """<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Proyecto BAKER | El Transcriptor de Voz Local (IES Monterroso)</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
:root {
--monterroso-blue: #004B87;
--monterroso-dark: #00335E;
--monterroso-light: #EBF3FA;
}
.bg-monterroso { background-color: var(--monterroso-blue); }
.bg-monterroso-dark { background-color: var(--monterroso-dark); }
.text-monterroso { color: var(--monterroso-blue); }
.border-monterroso { border-color: var(--monterroso-blue); }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: #f1f1f1; }
::-webkit-scrollbar-thumb { background: #004B87; border-radius: 3px; }
.word-high { background-color: #dcfce7; color: #166534; border-radius: 3px; padding: 0 2px; }
.word-mid { background-color: #fef9c3; color: #854d0e; border-radius: 3px; padding: 0 2px; }
.word-low { background-color: #fee2e2; color: #991b1b; border-radius: 3px; padding: 0 2px; }
.word-match { color: #166534; }
.word-missing { background-color: #fee2e2; color: #991b1b; text-decoration: line-through; border-radius: 3px; padding: 0 2px; }
.word-wrong { background-color: #fef9c3; color: #854d0e; border-radius: 3px; padding: 0 2px; }
</style>
</head>
<body class="bg-gray-50 text-gray-800 font-sans min-h-screen flex flex-col">
<header class="bg-monterroso text-white shadow-md border-b-4 border-rose-500">
<div class="max-w-7xl mx-auto px-4 py-3 flex flex-wrap justify-between items-center gap-4">
<div class="flex items-center space-x-3">
<div class="bg-white p-2 rounded-lg text-monterroso font-black text-xl flex items-center justify-center w-10 h-10 shadow">
JB
</div>
<div>
<div class="flex items-center gap-2">
<span class="text-xs tracking-wider uppercase opacity-90 font-semibold">IES Monterroso — Nivel 1º/2º ESO</span>
<span class="px-2 py-0.5 bg-rose-900 text-[10px] font-mono rounded text-rose-200 border border-rose-700">Puerto :8065</span>
</div>
<h1 class="text-xl font-bold tracking-tight flex items-center gap-2">
<span>PROYECTO BAKER</span>
<span class="text-xs bg-rose-400 text-gray-900 px-2 py-0.5 rounded-full font-extrabold uppercase">Transcriptor de Voz Local</span>
</h1>
</div>
</div>
<div class="flex items-center space-x-2">
<span class="px-3 py-1 bg-rose-900 text-rose-100 rounded-full text-xs font-semibold flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-rose-400 animate-pulse"></span>
app_baker.py Activo (:8065)
</span>
</div>
</div>
</header>
<main class="max-w-7xl mx-auto px-4 py-6 flex-1 w-full space-y-6">
<section class="bg-white p-5 rounded-xl shadow-sm border border-gray-200 space-y-3">
<div class="bg-gradient-to-r from-slate-900 via-monterroso to-rose-950 text-white p-3.5 rounded-lg flex items-center justify-between shadow-sm">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 rounded-full bg-rose-400/20 flex items-center justify-center text-rose-300 text-xl font-bold border border-rose-400/30">
<i class="fa-solid fa-microphone font-bold"></i>
</div>
<div>
<div class="text-[11px] uppercase tracking-wider text-rose-200 font-semibold">Taller de Reconocimiento de Voz (1º/2º ESO)</div>
<div class="text-sm font-bold">Graba tu voz y descubre cómo (y cuándo) una IA la convierte en texto</div>
</div>
</div>
<span class="hidden md:inline-block text-xs text-rose-100 italic bg-white/10 px-3 py-1 rounded">
"Habla, transcribe y comprueba dónde duda la máquina."
</span>
</div>
</section>
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
<section class="lg:col-span-5 space-y-4">
<div class="bg-white p-4 rounded-xl shadow-sm border border-gray-200 space-y-3">
<h2 class="text-xs font-bold text-gray-700 uppercase tracking-wider flex items-center gap-2">
<i class="fa-solid fa-circle-dot text-rose-600"></i> 1. Graba tu voz
</h2>
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Elige la condición del reto:</label>
<select id="condition-select" class="w-full p-2 border border-gray-300 rounded text-xs bg-gray-50 focus:bg-white mb-2">
<option value="limpio">🎙️ Voz clara, sin ruido</option>
<option value="ruido">🔊 Con ruido de fondo (música, TV...)</option>
<option value="rapido">⚡ Hablando muy rápido</option>
<option value="tecnico">🧪 Vocabulario técnico difícil</option>
</select>
<div class="flex gap-2">
<button onclick="startRecording()" id="btn-record" class="flex-1 py-2 bg-rose-600 hover:bg-rose-700 text-white font-bold text-xs rounded transition flex items-center justify-center gap-2 shadow">
<i class="fa-solid fa-microphone"></i> Grabar
</button>
<button onclick="stopRecording()" id="btn-stop" disabled class="flex-1 py-2 bg-gray-300 text-gray-500 font-bold text-xs rounded transition flex items-center justify-center gap-2 shadow cursor-not-allowed">
<i class="fa-solid fa-stop"></i> Detener
</button>
</div>
<audio id="audio-playback" controls class="w-full mt-2 hidden"></audio>
</div>
<button onclick="sendForTranscription()" id="btn-transcribe" disabled class="w-full py-2 bg-monterroso hover:bg-monterroso-dark text-white font-bold text-xs rounded transition flex items-center justify-center gap-2 shadow opacity-50 cursor-not-allowed">
<i class="fa-solid fa-wand-magic-sparkles"></i> Transcribir con IA
</button>
<p id="rec-status" class="text-[11px] text-gray-500 text-center">Pulsa "Grabar" y explica un concepto en voz alta.</p>
</div>
<div class="bg-white p-4 rounded-xl shadow-sm border border-gray-200 space-y-2">
<h3 class="text-xs font-bold text-gray-700 uppercase flex items-center gap-1.5">
<i class="fa-solid fa-clock-rotate-left text-indigo-600"></i> Historial de intentos
</h3>
<div id="history-list" class="space-y-1.5 text-[11px] max-h-[220px] overflow-y-auto">
<div class="text-gray-400 italic">Aún no has transcrito ningún audio...</div>
</div>
</div>
</section>
<section class="lg:col-span-7 bg-white p-4 rounded-xl shadow-sm border border-gray-200 flex flex-col">
<div class="flex border-b border-gray-200 text-xs font-bold mb-4">
<button onclick="switchTab('transcript')" id="tab-transcript" class="px-4 py-2 border-b-2 border-monterroso text-monterroso flex items-center gap-1.5">
<i class="fa-solid fa-file-waveform"></i> Transcripción
</button>
<button onclick="switchTab('accuracy')" id="tab-accuracy" class="px-4 py-2 text-gray-500 hover:text-gray-700 flex items-center gap-1.5">
<i class="fa-solid fa-percent"></i> Corrección y Precisión
</button>
</div>
<div id="panel-transcript" class="space-y-3 flex-1 flex flex-col">
<p class="text-xs text-gray-600">
<strong>Confianza por palabra:</strong> el color indica cuánta seguridad tiene el modelo. Verde = alta confianza, amarillo = dudosa, rojo = muy baja.
</p>
<div id="transcript-result" class="flex-1 p-3 bg-gray-50 border border-gray-200 rounded text-sm leading-relaxed overflow-y-auto max-h-[280px]">
<em class="text-gray-400">La transcripción coloreada aparecerá aquí después de grabar y pulsar "Transcribir con IA"...</em>
</div>
<div id="transcript-source" class="text-[10px] font-mono text-gray-400"></div>
</div>
<div id="panel-accuracy" class="space-y-3 flex-1 hidden flex flex-col">
<p class="text-xs text-gray-600">
Escribe aquí <strong>exactamente lo que dijiste en voz alta</strong> (tú sabes la verdad) para comparar con lo que transcribió la IA:
</p>
<textarea id="reference-text" rows="3" placeholder="Ej: La fotosíntesis es el proceso mediante el cual las plantas transforman la luz solar en energía química." class="w-full p-2 border border-gray-300 rounded text-xs bg-gray-50 focus:bg-white"></textarea>
<button onclick="checkAccuracy()" class="py-2 px-4 bg-amber-600 hover:bg-amber-700 text-white font-bold text-xs rounded transition flex items-center justify-center gap-2">
<i class="fa-solid fa-check-double"></i> Comprobar mi Precisión
</button>
<div id="accuracy-result" class="p-3 bg-amber-50 border border-amber-200 rounded text-xs text-amber-900 space-y-2 flex-1 overflow-y-auto max-h-[240px]">
<div class="font-bold">Precisión: - %</div>
<div>Aún no has comparado la transcripción.</div>
</div>
</div>
</section>
</div>
</main>
<footer class="bg-gray-800 text-gray-300 text-xs py-4 mt-auto border-t border-gray-700">
<div class="max-w-7xl mx-auto px-4 flex justify-between items-center">
<div><span class="font-semibold text-white">PROYECTO BAKER</span> — IES Monterroso (1º/2º ESO)</div>
<div class="font-mono">app_baker.py Activo en :8065</div>
</div>
</footer>
<script>
let mediaRecorder;
let audioChunks = [];
let recordedBlob = null;
let lastTranscript = "";
let attemptCount = 0;
function switchTab(tab) {
document.getElementById('panel-transcript').classList.add('hidden');
document.getElementById('panel-accuracy').classList.add('hidden');
document.getElementById('tab-transcript').className = "px-4 py-2 text-gray-500 hover:text-gray-700 flex items-center gap-1.5";
document.getElementById('tab-accuracy').className = "px-4 py-2 text-gray-500 hover:text-gray-700 flex items-center gap-1.5";
if(tab === 'transcript') {
document.getElementById('panel-transcript').classList.remove('hidden');
document.getElementById('tab-transcript').className = "px-4 py-2 border-b-2 border-monterroso text-monterroso flex items-center gap-1.5 font-bold";
} else {
document.getElementById('panel-accuracy').classList.remove('hidden');
document.getElementById('tab-accuracy').className = "px-4 py-2 border-b-2 border-amber-600 text-amber-700 flex items-center gap-1.5 font-bold";
}
}
async function startRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
audioChunks = [];
mediaRecorder = new MediaRecorder(stream);
mediaRecorder.ondataavailable = e => audioChunks.push(e.data);
mediaRecorder.onstop = () => {
recordedBlob = new Blob(audioChunks, { type: 'audio/webm' });
const audioUrl = URL.createObjectURL(recordedBlob);
const player = document.getElementById('audio-playback');
player.src = audioUrl;
player.classList.remove('hidden');
document.getElementById('btn-transcribe').disabled = false;
document.getElementById('btn-transcribe').classList.remove('opacity-50', 'cursor-not-allowed');
document.getElementById('rec-status').innerText = "Audio grabado. Pulsa 'Transcribir con IA' cuando quieras.";
};
mediaRecorder.start();
document.getElementById('btn-record').disabled = true;
document.getElementById('btn-record').className = "flex-1 py-2 bg-gray-300 text-gray-500 font-bold text-xs rounded transition flex items-center justify-center gap-2 shadow cursor-not-allowed";
document.getElementById('btn-stop').disabled = false;
document.getElementById('btn-stop').className = "flex-1 py-2 bg-gray-800 hover:bg-black text-white font-bold text-xs rounded transition flex items-center justify-center gap-2 shadow";
document.getElementById('rec-status').innerText = "🔴 Grabando... habla ahora.";
} catch(e) {
alert("No se pudo acceder al micrófono. Revisa los permisos del navegador.");
}
}
function stopRecording() {
if(mediaRecorder && mediaRecorder.state !== "inactive") {
mediaRecorder.stop();
}
document.getElementById('btn-record').disabled = false;
document.getElementById('btn-record').className = "flex-1 py-2 bg-rose-600 hover:bg-rose-700 text-white font-bold text-xs rounded transition flex items-center justify-center gap-2 shadow";
document.getElementById('btn-stop').disabled = true;
document.getElementById('btn-stop').className = "flex-1 py-2 bg-gray-300 text-gray-500 font-bold text-xs rounded transition flex items-center justify-center gap-2 shadow cursor-not-allowed";
}
function confidenceClass(conf) {
if(conf >= 0.8) return "word-high";
if(conf >= 0.55) return "word-mid";
return "word-low";
}
async function sendForTranscription() {
if(!recordedBlob) { alert("Graba un audio primero."); return; }
const condition = document.getElementById('condition-select').value;
document.getElementById('transcript-result').innerHTML = '<em class="text-gray-400">Transcribiendo audio...</em>';
switchTab('transcript');
const formData = new FormData();
formData.append('file', recordedBlob, 'grabacion.webm');
formData.append('condition_label', condition);
try {
const res = await fetch('/api/transcribe', { method: 'POST', body: formData });
const data = await res.json();
lastTranscript = data.text;
document.getElementById('reference-text').placeholder = "Escribe aquí exactamente lo que dijiste...";
document.getElementById('transcript-result').innerHTML = data.words.map(w =>
`<span class="${confidenceClass(w.confidence)}">${w.word}</span>`
).join(' ');
document.getElementById('transcript-source').innerText =
data.source === "whisper" ? "Fuente: servidor Whisper local" : "Fuente: transcripción de demostración (sin conexión al servidor Whisper)";
attemptCount++;
const avgConf = (data.words.reduce((a,b) => a + b.confidence, 0) / data.words.length * 100).toFixed(0);
const historyList = document.getElementById('history-list');
if(attemptCount === 1) historyList.innerHTML = "";
historyList.innerHTML += `
<div class="p-2 bg-gray-50 border border-gray-200 rounded flex justify-between items-center">
<span>Intento ${attemptCount} (${condition})</span>
<span class="font-bold ${avgConf >= 80 ? 'text-green-700' : avgConf >= 55 ? 'text-amber-700' : 'text-red-700'}">${avgConf}% confianza media</span>
</div>
`;
} catch(e) {
document.getElementById('transcript-result').innerHTML = '<span class="text-red-600">Error al conectar con app_baker.py</span>';
}
}
async function checkAccuracy() {
if(!lastTranscript) { alert("Transcribe un audio primero."); return; }
const referenceText = document.getElementById('reference-text').value.trim();
if(!referenceText) { alert("Escribe lo que dijiste realmente."); return; }
document.getElementById('accuracy-result').innerHTML = '<div class="italic">Comparando...</div>';
try {
const res = await fetch('/api/evaluate-accuracy', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ transcript_text: lastTranscript, reference_text: referenceText })
});
const data = await res.json();
const diffHtml = data.diff.map(seg => {
if(seg.type === "match") return `<span class="word-match">${seg.word}</span>`;
if(seg.type === "missing") return `<span class="word-missing">${seg.word}</span>`;
return `<span class="word-wrong">${seg.word}</span>`;
}).join(' ');
document.getElementById('accuracy-result').innerHTML = `
<div class="text-sm font-bold text-amber-900">Precisión: ${data.accuracy} %</div>
<div class="mt-1 leading-relaxed">${diffHtml}</div>
<div class="mt-2 pt-2 border-t border-amber-200 text-[10px] text-amber-800">
<span class="word-match">Verde</span> = coincide · <span class="word-missing">Tachado rojo</span> = lo dijiste pero la IA no lo transcribió · <span class="word-wrong">Amarillo</span> = la IA transcribió algo distinto
</div>
`;
} catch(e) {
document.getElementById('accuracy-result').innerHTML = '<div class="text-red-600">Error al comparar.</div>';
}
}
</script>
</body>
</html>
"""
@app.get("/", response_class=HTMLResponse)
def serve_web_interface():
return HTMLResponse(content=HTML_CONTENT)
@app.post("/api/transcribe")
async def transcribe(file: UploadFile = File(...), condition_label: str = Form("limpio")):
audio_bytes = await file.read()
whisper_result = transcribe_with_whisper(audio_bytes, file.filename or "audio.webm")
if whisper_result:
words = extract_words_with_confidence(whisper_result)
text = whisper_result.get("text", " ".join(w["word"] for w in words))
return {"text": text.strip(), "words": words, "source": "whisper"}
demo = DEMO_TRANSCRIPTS.get(condition_label, DEMO_TRANSCRIPTS["limpio"])
demo_words = demo["text"].split()
words = [
{"word": w, "confidence": demo["confidences"][i] if i < len(demo["confidences"]) else 0.7}
for i, w in enumerate(demo_words)
]
return {"text": demo["text"], "words": words, "source": "fallback"}
@app.post("/api/evaluate-accuracy")
def evaluate_accuracy(req: AccuracyRequest):
diff_segments, accuracy = compute_wer_diff(req.reference_text, req.transcript_text)
return {"accuracy": accuracy, "diff": diff_segments}
if __name__ == "__main__":
uvicorn.run("app_baker:app", host="0.0.0.0", port=8065, reload=True)
Metadatos para la Publicación Web




