
Delia Derbyshire — El Sonido que Nace del Texto (Puerto 8054)
Por qué Derbyshire
Antes de que existiera un solo sintetizador comercial, Delia Derbyshire ya fabricaba sonido electrónico a mano: osciladores de válvulas, generadores de ruido blanco, kilómetros de cinta magnética cortada, ralentizada, invertida y vuelta a pegar nota a nota. La sintonía de Doctor Who de 1963 —ese sonido que aún hoy sigue sonando “del futuro”— no salió de un teclado. Salió de una mujer con tijeras, cinta adhesiva y un oído absoluto para la ingeniería del sonido.
Hoy hacemos lo mismo con software: convertir texto en voz. Pero antes de escribir una sola línea, hay que hablar de por qué esto importa de verdad.
Esto no es un juguete: es accesibilidad
Un lector de texto a voz no es “la IA leyendo por diversión”. Es la herramienta que usa a diario cualquier persona con discapacidad visual para leer un correo, un libro o una página web. Es también lo que usa mucha gente disléxica para procesar un texto largo sin fatiga. La tecnología de síntesis de voz (TTS, text-to-speech) existe primero como herramienta de asistencia, y solo después como curiosidad o entretenimiento.
Lo que vais a montar hoy en clase es, en miniatura exacta, la misma tecnología que un lector de pantalla como NVDA, JAWS o VoiceOver usa para que una persona ciega pueda usar un ordenador. La diferencia entre “un proyecto simpático” y “una herramienta que cambia la vida de alguien” es a veces solo cuestión de para quién la estás construyendo. Merece la pena tenerlo presente mientras programáis.
El proyecto
El alumnado escribe un texto corto —un cuento, la explicación de un experimento, un poema— y un motor de síntesis de voz local lo lee en alto, con varias voces a elegir. Todo en local, sin conexión externa, sin coste, sin enviar el texto de nadie a ningún servidor de terceros.
- Panel 1: reproductor de audio con el resultado, más el tiempo que ha tardado en generarse.
- Panel 2: el texto exacto enviado, la voz elegida, la duración del audio generado, y los metadatos técnicos.
Motor de síntesis: elección técnica
Para mantenernos 100% locales y offline, en lugar de LM Studio (que sirve LLMs de texto, no motores TTS) usamos Piper, un motor de síntesis de voz neuronal ligero, rápido en CPU y con voces en español de calidad razonable, ejecutable completamente offline. Es el estándar de facto para TTS local en proyectos educativos y de accesibilidad (lo usa, entre otros, Home Assistant).
Instalación previa y descarga manual de voces
1. Dependencias de Python
Ejecuta en tu terminal:
Bash
pip install piper-tts fastapi uvicorn python-multipart
2. Descarga de voces (Manual)
Crea una carpeta llamada voces_piper en el mismo directorio donde guardes el script de Python.
Descarga manualmente los siguientes archivos y muévelos dentro de la carpeta voces_piper (manteniendo exactamente los mismos nombres de archivo):
- Voz femenina (España – davefx):
- Modelo: es_ES-davefx-medium.onnx
- Configuración JSON: es_ES-davefx-medium.onnx.json
- Voz masculina (España – carlfm):
- Modelo: es_ES-carlfm-x_low.onnx
- Configuración JSON: es_ES-carlfm-x_low.onnx.json

El código completo (app_derbyshire.py)
Python
"""
Proyecto Delia Derbyshire — Texto a Voz Local
IES Monterroso · MonteSteam / AIDARAC
Puerto 8054
Convierte texto escrito en audio hablado, 100% local, con Piper TTS.
Pensado como introducción a la síntesis de voz y como ejercicio explícito
de accesibilidad: lo que aquí se construye es, en miniatura, la misma
tecnología que usan a diario los lectores de pantalla.
"""
import base64
import os
import subprocess
import sys
import time
import unicodedata
import uuid
from pathlib import Path
from fastapi import FastAPI, Form
from fastapi.responses import HTMLResponse, JSONResponse
app = FastAPI(title="Delia Derbyshire — Texto a Voz")
# --- Configuración de voces disponibles ---
CARPETA_VOCES = Path("voces_piper")
CARPETA_AUDIO_TEMP = Path("audio_generado")
CARPETA_AUDIO_TEMP.mkdir(exist_ok=True)
VOCES_DISPONIBLES = {
"davefx": {
"nombre": "Voz femenina (España)",
"modelo": CARPETA_VOCES / "es_ES-davefx-medium.onnx",
},
"carlfm": {
"nombre": "Voz masculina (España)",
"modelo": CARPETA_VOCES / "es_ES-carlfm-x_low.onnx",
},
}
def limpiar_texto_para_piper(texto: str) -> str:
"""
Limpia y normaliza el texto eliminando caracteres invisibles de la web,
normalizando tildes (NFKC) y reemplazando comillas tipográficas.
"""
# 1. Normalización NFKC (combina vocales con tildes y resuelve símbolos tipográficos)
texto = unicodedata.normalize("NFKC", texto)
# 2. Mapeo y eliminación de caracteres invisibles o tipográficos problemáticos
reemplazos = {
"“": '"', "”": '"', "‘": "'", "’": "'",
"«": '"', "»": '"', "—": "-", "–": "-",
"\xa0": " ", # Espacio no separable
"\u200b": "", # Espacio de ancho cero (Zero-Width Space)
"\ufeff": "", # Marca de orden de bytes (BOM)
"\u00ad": "", # Guion blando
}
for origen, destino in reemplazos.items():
texto = texto.replace(origen, destino)
# 3. Eliminar cualquier otro carácter de control no imprimible (categoría Unicode 'C')
texto = "".join(ch for ch in texto if unicodedata.category(ch)[0] != "C")
return texto.strip()
def generar_audio_con_piper(texto: str, id_voz: str) -> tuple[Path, float]:
"""
Ejecuta Piper garantizando la lectura en UTF-8 (mediante archivo temporal y chcp 65001 en Windows)
para evitar que se salte vocales acentuadas como 'ú', 'á', 'é', 'í', 'ó' o 'ñ'.
"""
voz = VOCES_DISPONIBLES[id_voz]
ruta_modelo = voz["modelo"]
ruta_config = Path(f"{ruta_modelo}.json")
if not ruta_modelo.exists():
raise FileNotFoundError(f"No se encuentra el modelo .onnx en {ruta_modelo}")
if not ruta_config.exists():
raise FileNotFoundError(
f"No se encuentra la configuración .json en {ruta_config}. "
"Asegúrate de que existe el archivo .onnx.json correspondiente."
)
# 1. Limpieza y normalización del texto de entrada
texto_limpio = limpiar_texto_para_piper(texto)
# 2. Crear un archivo de texto temporal forzando UTF-8 estricto
ruta_txt_temp = CARPETA_AUDIO_TEMP / f"input_{uuid.uuid4().hex}.txt"
ruta_txt_temp.write_text(texto_limpio, encoding="utf-8")
nombre_archivo = f"{uuid.uuid4().hex}.wav"
ruta_salida = CARPETA_AUDIO_TEMP / nombre_archivo
# 3. Forzar codificación del entorno de subproceso
env = os.environ.copy()
env["PYTHONIOENCODING"] = "utf-8"
env["LC_ALL"] = "C.UTF-8"
inicio = time.perf_counter()
try:
if sys.platform == "win32":
# En Windows cambiamos la página de códigos activa a UTF-8 (65001) antes de invocar Piper
comando = (
f'chcp 65001 >NUL && piper '
f'--model "{ruta_modelo}" '
f'--config "{ruta_config}" '
f'--output_file "{ruta_salida}" '
f'< "{ruta_txt_temp}"'
)
proceso = subprocess.run(
comando,
shell=True,
capture_output=True,
env=env,
)
else:
# En Linux / macOS redirigimos el archivo directamente por stdin
with open(ruta_txt_temp, "rb") as f_in:
proceso = subprocess.run(
[
"piper",
"--model", str(ruta_modelo),
"--config", str(ruta_config),
"--output_file", str(ruta_salida),
],
stdin=f_in,
capture_output=True,
env=env,
)
tiempo_transcurrido = time.perf_counter() - inicio
if proceso.returncode != 0:
raise RuntimeError(
f"Piper falló al generar el audio: {proceso.stderr.decode('utf-8', errors='ignore')}"
)
return ruta_salida, tiempo_transcurrido
finally:
# Limpieza: borrar el archivo de texto temporal
ruta_txt_temp.unlink(missing_ok=True)
def audio_a_base64(ruta_audio: Path) -> str:
"""Lee un archivo de audio y lo codifica en Base64 para incrustarlo en el HTML."""
with open(ruta_audio, "rb") as f:
contenido = f.read()
return base64.b64encode(contenido).decode("utf-8")
@app.get("/", response_class=HTMLResponse)
def pagina_principal():
opciones_voces = "".join(
f'<option value="{id_voz}">{datos["nombre"]}</option>'
for id_voz, datos in VOCES_DISPONIBLES.items()
)
return f"""
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Delia Derbyshire — Texto a Voz</title>
<style>
:root {{
--fondo: #0d1117;
--panel: #161b22;
--borde: #30363d;
--texto: #c9d1d9;
--acento: #58a6ff;
--acento-suave: #1f6feb33;
}}
* {{ box-sizing: border-box; }}
body {{
background: var(--fondo);
color: var(--texto);
font-family: 'Segoe UI', system-ui, sans-serif;
margin: 0;
padding: 2rem;
}}
.cabecera {{
max-width: 900px;
margin: 0 auto 2rem auto;
}}
.cabecera h1 {{
font-size: 1.8rem;
margin-bottom: 0.3rem;
}}
.cabecera p {{
color: #8b949e;
font-size: 0.95rem;
line-height: 1.5;
}}
.aviso-accesibilidad {{
background: var(--acento-suave);
border-left: 4px solid var(--acento);
padding: 1rem 1.2rem;
border-radius: 6px;
margin: 1.2rem 0;
font-size: 0.9rem;
line-height: 1.5;
}}
.contenedor-principal {{
max-width: 900px;
margin: 0 auto;
}}
.panel-entrada {{
background: var(--panel);
border: 1px solid var(--borde);
border-radius: 10px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}}
textarea {{
width: 100%;
min-height: 150px;
background: #0d1117;
color: var(--texto);
border: 1px solid var(--borde);
border-radius: 6px;
padding: 0.8rem;
font-family: inherit;
font-size: 1rem;
resize: vertical;
}}
select {{
background: #0d1117;
color: var(--texto);
border: 1px solid var(--borde);
border-radius: 6px;
padding: 0.6rem;
font-size: 0.95rem;
margin-right: 1rem;
}}
button {{
background: var(--acento);
color: #0d1117;
border: none;
border-radius: 6px;
padding: 0.7rem 1.5rem;
font-weight: 600;
cursor: pointer;
font-size: 0.95rem;
}}
button:hover {{ opacity: 0.85; }}
button:disabled {{ opacity: 0.5; cursor: not-allowed; }}
.controles {{
display: flex;
align-items: center;
gap: 1rem;
margin-top: 1rem;
flex-wrap: wrap;
}}
.grid-resultados {{
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}}
@media (max-width: 700px) {{
.grid-resultados {{ grid-template-columns: 1fr; }}
}}
.panel {{
background: var(--panel);
border: 1px solid var(--borde);
border-radius: 10px;
padding: 1.5rem;
min-height: 200px;
}}
.panel h3 {{
margin-top: 0;
font-size: 1rem;
color: var(--acento);
border-bottom: 1px solid var(--borde);
padding-bottom: 0.5rem;
}}
audio {{
width: 100%;
margin-top: 1rem;
}}
.metrica {{
display: flex;
justify-content: space-between;
padding: 0.4rem 0;
border-bottom: 1px solid #21262d;
font-size: 0.88rem;
}}
.metrica span:first-child {{ color: #8b949e; }}
.metrica span:last-child {{
font-family: 'Courier New', monospace;
color: var(--texto);
}}
#texto-enviado-preview {{
background: #0d1117;
border: 1px solid var(--borde);
border-radius: 6px;
padding: 0.8rem;
font-size: 0.85rem;
max-height: 150px;
overflow-y: auto;
margin-top: 0.8rem;
white-space: pre-wrap;
}}
.estado {{
color: #8b949e;
font-style: italic;
text-align: center;
padding: 2rem 0;
}}
</style>
</head>
<body>
<div class="cabecera">
<h1>🎙️ Delia Derbyshire — Texto a Voz</h1>
<p>
Antes de que existieran sintetizadores comerciales, Delia Derbyshire construía
sonido electrónico a mano, con cinta magnética y osciladores, para el BBC
Radiophonic Workshop. La sintonía original de <em>Doctor Who</em> (1963) es
obra suya. Hoy hacemos lo mismo con software: convertir texto en sonido.
</p>
<div class="aviso-accesibilidad">
<strong>Esto no es solo un juguete.</strong> Un lector de texto a voz es la
herramienta que usa a diario cualquier persona con discapacidad visual para leer
un correo, un libro o una web — y también mucha gente disléxica, para procesar
textos largos sin fatiga. Lo que vais a usar hoy es, en miniatura, la misma
tecnología que hay detrás de un lector de pantalla como NVDA o VoiceOver.
</div>
</div>
<div class="contenedor-principal">
<div class="panel-entrada">
<label for="texto-input"><strong>Escribe tu texto</strong> (un cuento, la explicación de un experimento, un poema...):</label>
<textarea id="texto-input" placeholder="Escribe aquí lo que quieres que la voz lea en alto..."></textarea>
<div class="controles">
<select id="selector-voz">
{opciones_voces}
</select>
<button id="boton-generar" onclick="generarVoz()">🔊 Generar audio</button>
</div>
</div>
<div class="grid-resultados">
<div class="panel">
<h3>▶️ Resultado</h3>
<div id="contenedor-resultado">
<p class="estado">Escribe un texto y pulsa "Generar audio"</p>
</div>
</div>
<div class="panel">
<h3>🔍 Metadatos y Log</h3>
<div id="contenedor-metadatos">
<p class="estado">Aquí verás los detalles técnicos de cada generación</p>
</div>
</div>
</div>
</div>
<script>
async function generarVoz() {{
const texto = document.getElementById('texto-input').value.trim();
const idVoz = document.getElementById('selector-voz').value;
const boton = document.getElementById('boton-generar');
const contenedorResultado = document.getElementById('contenedor-resultado');
const contenedorMetadatos = document.getElementById('contenedor-metadatos');
if (!texto) {{
alert('Escribe algún texto primero.');
return;
}}
boton.disabled = true;
boton.textContent = '⏳ Generando...';
contenedorResultado.innerHTML = '<p class="estado">Sintetizando voz, un momento...</p>';
contenedorMetadatos.innerHTML = '<p class="estado">Procesando...</p>';
const inicioCliente = performance.now();
try {{
const formData = new FormData();
formData.append('texto', texto);
formData.append('id_voz', idVoz);
const respuesta = await fetch('/generar-audio', {{
method: 'POST',
body: formData
}});
const datos = await respuesta.json();
const tiempoTotalCliente = ((performance.now() - inicioCliente) / 1000).toFixed(2);
if (!respuesta.ok) {{
throw new Error(datos.error || 'Error desconocido');
}}
contenedorResultado.innerHTML = `
<p>Audio generado correctamente:</p>
<audio controls autoplay>
<source src="data:audio/wav;base64,${{datos.audio_base64}}" type="audio/wav">
</audio>
<div class="metrica">
<span>⏱️ Tiempo de inferencia (servidor)</span>
<span>${{datos.tiempo_generacion.toFixed(2)}} s</span>
</div>
`;
contenedorMetadatos.innerHTML = `
<div class="metrica"><span>Voz seleccionada</span><span>${{datos.nombre_voz}}</span></div>
<div class="metrica"><span>Caracteres enviados</span><span>${{datos.longitud_texto}}</span></div>
<div class="metrica"><span>Tamaño del audio</span><span>${{datos.tamano_audio_kb.toFixed(1)}} KB</span></div>
<div class="metrica"><span>Tiempo servidor (Piper)</span><span>${{datos.tiempo_generacion.toFixed(2)}} s</span></div>
<div class="metrica"><span>Tiempo total (ida y vuelta)</span><span>${{tiempoTotalCliente}} s</span></div>
<div class="metrica"><span>Formato de audio</span><span>WAV</span></div>
<p style="margin-top:0.8rem; color:#8b949e; font-size:0.85rem;">Texto enviado:</p>
<div id="texto-enviado-preview">${{datos.texto_enviado}}</div>
`;
}} catch (error) {{
contenedorResultado.innerHTML = `<p style="color:#f85149;">❌ Error: ${{error.message}}</p>`;
contenedorMetadatos.innerHTML = `<p style="color:#f85149;">Revisa que Piper esté instalado y las voces descargadas.</p>`;
}} finally {{
boton.disabled = false;
boton.textContent = '🔊 Generar audio';
}}
}}
</script>
</body>
</html>
"""
@app.post("/generar-audio")
def generar_audio(texto: str = Form(...), id_voz: str = Form(...)):
if id_voz not in VOCES_DISPONIBLES:
return JSONResponse(status_code=400, content={"error": "Voz no válida"})
if not texto.strip():
return JSONResponse(status_code=400, content={"error": "El texto no puede estar vacío"})
try:
ruta_audio, tiempo_generacion = generar_audio_con_piper(texto, id_voz)
audio_base64 = audio_a_base64(ruta_audio)
tamano_kb = ruta_audio.stat().st_size / 1024
# Limpieza: borramos el archivo de audio temporal tras convertirlo a Base64
ruta_audio.unlink(missing_ok=True)
return JSONResponse(content={
"audio_base64": audio_base64,
"tiempo_generacion": tiempo_generacion,
"nombre_voz": VOCES_DISPONIBLES[id_voz]["nombre"],
"longitud_texto": len(texto),
"tamano_audio_kb": tamano_kb,
"texto_enviado": texto,
})
except FileNotFoundError as e:
return JSONResponse(status_code=500, content={"error": str(e)})
except RuntimeError as e:
return JSONResponse(status_code=500, content={"error": str(e)})
except Exception as e:
return JSONResponse(status_code=500, content={"error": f"Error inesperado: {str(e)}"})
Puesta en marcha
- Guarda el código anterior en un archivo llamado
app_derbyshire.py. - Inicia el servidor con Uvicorn:
Bash
uvicorn app_derbyshire:app --reload --port 8054
- Abre en tu navegador la dirección: http://localhost:8054
Notas para el aula
- Por qué Piper y no un LLM de LM Studio: LM Studio sirve modelos de lenguaje (texto → texto o texto → texto con imagen), no motores de síntesis de voz. Piper es la pieza correcta para esta tarea específica: pequeño, rápido en CPU sin GPU dedicada, completamente offline y con licencia abierta — encaja perfectamente con la filosofía de privacidad total del resto de proyectos.
- El hilo conductor del lote de hoy: Loftus trabajaba con memoria (cómo se reconstruyen los recuerdos), Lovelace con extracción estructurada de datos, Perlman con planificación de acciones, Denning con seguridad frente a manipulación — y Derbyshire cierra con el pilar de audio de salida. Combinado con un proyecto de reconocimiento de voz (como el de Hughes que mencionabais), el alumnado tiene el ciclo completo: voz → texto → (procesamiento) → texto → voz. Es el momento perfecto para explicar que un asistente de voz real no es magia, es exactamente esta cadena de piezas, cada una resoluble por separado.
- Extensión posible si sobra tiempo: añadir un control deslizante de velocidad de habla (Piper lo soporta con el parámetro
--length-scale), o comparar el mismo texto leído por las dos voces disponibles, uno al lado del otro, para hablar de cómo entrenan estos modelos con corpus de voces distintas.



