#!/usr/bin/env python3
# piper-worker — sintetizador de voz aislado para RadioCheck.
#
# Copyright (C) 2026 Rubén Martínez López
#
# Este programa es software libre: puedes redistribuirlo y/o modificarlo bajo los
# términos de la Licencia Pública General GNU publicada por la Free Software
# Foundation, en su versión 3 o cualquier versión posterior.
#
# Se distribuye con la esperanza de que sea útil, pero SIN NINGUNA GARANTÍA; ni
# siquiera la garantía implícita de COMERCIALIZACIÓN o APTITUD PARA UN PROPÓSITO
# PARTICULAR. Consulta la Licencia Pública General GNU para más detalles.
# Deberías haber recibido una copia junto a este programa; si no, ve a
# .
#
# ─────────────────────────────────────────────────────────────────────────────
# POR QUÉ ESTE PROGRAMA EXISTE POR SEPARADO
#
# El motor de síntesis (piper-tts) es GPL-3.0-or-later y lleva espeak-ng
# compilado dentro. La GPL exige que todo programa que ENLACE ese código sea
# también GPL. RadioCheck es una aplicación cerrada, así que no puede importarlo.
#
# La solución es la misma que usa cualquier aplicación que trae ffmpeg dentro:
# el código GPL vive en un programa APARTE —éste—, que se comunica con la
# aplicación a distancia, por su entrada y su salida estándar. Este programa es
# GPL y su código fuente se publica; RadioCheck sólo lo invoca.
#
# Este programa hace UNA cosa: recibir texto y devolver audio crudo. Todo el
# procesado de sonido de RadioCheck (el filtro de radio, los pitidos, la caché)
# ocurre del otro lado y no forma parte de esto.
#
# PROTOCOLO (una petición por línea, por la entrada estándar):
# {"text": "...", "length_scale": 0.65, "noise_scale": 0.9, "speaker_id": null}
# Respuesta por la salida estándar: una línea JSON con la cabecera y, si todo fue
# bien, inmediatamente después los bytes de audio PCM 16 bits:
# {"ok": true, "bytes": 12345, "rate": 22050}\n<12345 bytes>
# {"ok": false, "error": "..."}\n
# La entrada estándar se cierra → el programa termina.
#
# Uso: piper-worker
import json
import sys
def main() -> int:
if len(sys.argv) < 2:
sys.stderr.write("uso: piper-worker \n")
return 2
try:
from piper import PiperVoice, SynthesisConfig
except ImportError as e:
sys.stderr.write(f"piper-tts no disponible: {e}\n")
return 3
try:
voz = PiperVoice.load(sys.argv[1])
except Exception as e:
sys.stderr.write(f"no se pudo cargar el modelo: {e}\n")
return 4
rate = voz.config.sample_rate
salida = sys.stdout.buffer
# Aviso de que estamos listos: la aplicación espera esta línea antes de pedir
# nada, para no confundir un arranque lento con un fallo.
salida.write(json.dumps({"ok": True, "ready": True, "rate": rate}).encode() + b"\n")
salida.flush()
for linea in sys.stdin.buffer:
linea = linea.strip()
if not linea:
continue
try:
pet = json.loads(linea)
cfg = SynthesisConfig(
length_scale=float(pet.get("length_scale", 1.0)),
noise_scale=float(pet.get("noise_scale", 0.667)),
speaker_id=pet.get("speaker_id"),
)
crudo = b"".join(t.audio_int16_bytes
for t in voz.synthesize(pet["text"], syn_config=cfg))
salida.write(json.dumps({"ok": True, "bytes": len(crudo),
"rate": rate}).encode() + b"\n")
salida.write(crudo)
salida.flush()
except Exception as e:
salida.write(json.dumps({"ok": False, "error": str(e)}).encode() + b"\n")
salida.flush()
return 0
if __name__ == "__main__":
raise SystemExit(main())