- Python 51.8%
- Shell 48.2%
Il /bulk sincrono bloccava troppo a lungo per code numerose (~9s/file su Pi); ora risponde subito 202 con un job_id e la generazione prosegue in background, consultabile via /bulk-status. |
||
|---|---|---|
| tests | ||
| .gitignore | ||
| abaddon.py | ||
| abaddon.service | ||
| abaddon_logo.svg | ||
| audio_settings.sh | ||
| config.py | ||
| install_abaddon.sh | ||
| README.md | ||
| uninstall_abaddon.sh | ||
Abaddon — Voice Daemon
Servizio TTS offline per Raspberry Pi / totem x86. Sintetizza testo in MP3, lo salva su disco (cache) e lo espone via HTTP.
Stack: Piper TTS · ffmpeg · mpg123 · Python stdlib (no dipendenze esterne)
Requisiti
- Linux x86_64 o Raspberry Pi 3/4 (aarch64/armv7l)
- Python 3.9+
- Utente di sistema
kipanga(usato dal service systemd e dai permessi) - Connessione internet solo durante il setup — poi tutto offline
Installazione
git clone https://git.okone.it/s.moretti/abaddon.git && cd abaddon
sudo bash install_abaddon.sh
Lo script install_abaddon.sh richiede privilegi di root (apt, chown,
loginctl) e:
- installa
gitecurlse mancanti; - clona/aggiorna la repository in
TARGET_DIR(default/var/www/kipanga/system/abaddon, sovrascrivibile via variabile d'ambienteTARGET_DIR); - chiede la cartella di salvataggio MP3 e la scrive in
config.py; - assegna i permessi all'utente
kipanga; - installa
ffmpegempg123tramite apt; - scarica il binario Piper TTS
2023.11.14-2in./piper/; - scarica la voce italiana it_IT-paola-medium in
./piper/; - scarica la voce inglese en_US-hfc_female-medium in
./piper/; - genera e abilita il systemd user service per
kipanga(con il Python rilevato automaticamente) e attiva il linger per l'avvio headless.
Durante il setup viene chiesta la cartella di salvataggio MP3:
==> Cartella di salvataggio file MP3 (cache)
Percorso [/var/www/kipanga/system/abaddon/audios]:
Premi Invio per accettare il default o inserisci un percorso custom. La cartella
viene creata automaticamente e il valore viene scritto in config.py.
Configurazione
Modifica config.py per cambiare i parametri dopo l'installazione:
| Parametro | Default | Note |
|---|---|---|
PORT |
9966 |
porta <1024 richiede sudo |
CACHE_DIR |
/var/www/kipanga/system/abaddon/audios |
impostato da install_abaddon.sh |
PIPER_BIN |
./piper/piper |
installato da install_abaddon.sh |
MODEL_PATH |
./piper/it_IT-paola-medium.onnx |
voce italiana (default) |
MODEL_PATH_EN |
./piper/en_US-hfc_female-medium.onnx |
voce inglese (lang=en) |
PIPER_HZ |
22050 |
sample rate dei modelli Piper |
PAUSE_MS |
150 |
durata pausa |P| in millisecondi |
LENGTH_SCALE |
1.25 |
velocità voce: 1.0 = normale, >1.0 = lento |
MP3_HZ |
48000 |
sample rate file MP3 di output |
MP3_BITRATE |
320k |
bitrate file MP3 di output |
Avvio
Manuale
python3 abaddon.py
# [TTS] Pronto → http://localhost:9966
# [TTS] Cache → /var/www/kipanga/system/abaddon/audios (0 file)
Il server è multi-thread (ThreadingHTTPServer) e ascolta su 0.0.0.0:PORT.
Automatico all'avvio (systemd)
Gestito da install_abaddon.sh, che rileva il Python installato e genera il
service (abaddon.service, con i placeholder %PYTHON% e %SCRIPT_DIR%
risolti) nel profilo systemd user di kipanga. Per gestione manuale:
sudo -u kipanga XDG_RUNTIME_DIR=/run/user/$(id -u kipanga) systemctl --user status abaddon
sudo -u kipanga XDG_RUNTIME_DIR=/run/user/$(id -u kipanga) systemctl --user restart abaddon
sudo -u kipanga XDG_RUNTIME_DIR=/run/user/$(id -u kipanga) journalctl --user -u abaddon -f
install_abaddon.shabilita automaticamente il linger (loginctl enable-linger kipanga) così il servizio parte anche senza login attivo (headless/SSH).
API
Tutte le risposte sono JSON (UTF-8). Sono impostati gli header CORS e
Access-Control-Allow-Private-Network per le richieste cross-origin / Private
Network Access dei browser (incluso il preflight OPTIONS).
Sintesi frase — GET /speak
GET /speak?text=<testo>[&lang=it|en][&folder=<percorso>]
Sintetizza il testo, salva il file MP3 nella cartella indicata (parametro
folder, altrimenti CACHE_DIR) e restituisce l'hash del file. Lingua di
default it; valori ammessi it ed en (altri valori → it).
# Cartella default (CACHE_DIR), lingua italiana
curl "http://localhost:9966/speak?text=Serviamo+il+numero+A1+allo+sportello+3&lang=it"
# Lingua inglese
curl "http://localhost:9966/speak?text=Now+serving+A1+at+counter+3&lang=en"
# Cartella custom
curl "http://localhost:9966/speak?text=Serviamo+il+numero+A1&folder=/var/www/audio"
Risposta:
{ "hash": "33f40c2a...", "file": "33f40c2a....mp3" }
Se il file è già in cache la risposta è istantanea. Il nome file è
SHA-1("<lang>:<testo normalizzato>"), quindi lingue diverse producono file
distinti.
Generazione in bulk — GET /bulk
GET /bulk?codice_coda=<cod>&sportello=<S>&count=<N>[&lang=it,en][&folder=<percorso>]
Genera un file MP3 per ogni numero da 1 a N (numero = <cod><i>, es. A1,
A2, … A200; se codice_coda è vuoto il numero è 1, 2, …) e per ogni
lingua indicata in lang (separate da virgola, default it). Il testo è
costruito dal template per lingua definito in config.py (BULK_TEMPLATES),
con i marker di pausa |P|.
Importante: i template in
BULK_TEMPLATESdevono combaciare byte-per-byte con quelli usati dal frontend per l'annuncio del singolo numero (/speak). Solo così l'hash SHA-1 coincide e l'audio pre-generato viene trovato in cache alla chiamata; altrimenti verrebbe risintetizzato, vanificando la pre-generazione.
I file usano lo stesso schema di naming SHA-1 di /speak, quindi la cache è
condivisa: i file già presenti vengono saltati istantaneamente. codice_coda
può essere vuoto; sportello è obbligatorio. count è limitato a BULK_MAX
(default 1000).
Asincrono: la sintesi è lenta (~secondi per file: 200 numeri possono
richiedere minuti su Raspberry Pi), quindi /bulk non attende. Valida i
parametri, avvia la generazione in un thread di background e risponde subito
202 Accepted con un job_id. L'avanzamento si segue con /bulk-status.
curl "http://localhost:9966/bulk?codice_coda=A&sportello=3&count=200&lang=it,en"
Risposta immediata (202):
{
"job_id": "9f3c1a2b…",
"state": "running",
"total": 400,
"count": 200,
"langs": ["it", "en"],
"sportello": "3",
"codice_coda": "A"
}
total: numeri da produrre (count× lingue supportate).
Parametri non validi (sportello vuoto, count fuori 1..BULK_MAX, nessuna
lingua supportata) → 400.
Stato job bulk — GET /bulk-status
GET /bulk-status?job_id=<id>
Ritorna lo stato del job avviato da /bulk (404 se job_id sconosciuto).
curl "http://localhost:9966/bulk-status?job_id=9f3c1a2b…"
{
"job_id": "9f3c1a2b…",
"state": "done",
"total": 400,
"generated": 180,
"cached": 220,
"errors_count": 0,
"errors": [],
"dest": "/var/www/kipanga/system/abaddon/audios"
}
state:running→done(oppureerrorse il job è fallito in blocco).generated/cached: aggiornati in tempo reale durante l'esecuzione.errors: dettaglio dei problemi non fatali (popolato a fine job).
I job sono tenuti in memoria (si azzerano al riavvio del servizio).
Riproduzione file — GET /audio/<hash>.mp3
Serve un MP3 dalla CACHE_DIR con Content-Type: audio/mpeg. Il nome file deve
corrispondere a ^[0-9a-f]{40}\.mp3$ (protezione da path traversal); altrimenti
400. File mancante → 404.
curl "http://localhost:9966/audio/33f40c2a....mp3" --output out.mp3
Marker di pausa |P|
Inserisci |P| nel testo per aggiungere silenzio (durata: PAUSE_MS in
config.py):
curl "http://localhost:9966/speak?text=Serviamo+il+numero+%7CP%7C+A1+%7CP%7C+allo+sportello+3"
Il marker è case-insensitive. Frasi con pause diverse producono file MP3 distinti.
File in cache — GET /cache
curl http://localhost:9966/cache
{ "cached_files": 42, "folder": "/var/www/kipanga/system/abaddon/audios" }
IP del client — GET /client-ip
Restituisce l'indirizzo IP del chiamante.
curl http://localhost:9966/client-ip
{ "client_ip": "192.168.1.50" }
Cache
Ogni frase viene sintetizzata una sola volta:
<folder>/<sha1-del-testo>.mp3
La sintesi usa una scrittura atomica (file temporaneo → rename) e un lock
per-hash, così la cache resta consistente anche con richieste concorrenti. La
cache sopravvive ai riavvii. Per pre-generare tutte le frasi note:
python3 - <<'EOF'
from abaddon import synth_mp3
frasi = [l.strip() for l in open("frasi.txt") if l.strip()]
for i, f in enumerate(frasi, 1):
print(f"[{i}/{len(frasi)}] {f}")
synth_mp3(f)
print("Fatto.")
EOF
Troubleshooting
Piper non trovato / permission denied
ls -la ./piper/piper # deve mostrare -rwxr-xr-x
chmod +x ./piper/piper # se manca il bit eseguibile
Porta già in uso
ss -tlnp | grep 9966
# Cambia PORT in config.py
Log del daemon
sudo -u kipanga XDG_RUNTIME_DIR=/run/user/$(id -u kipanga) journalctl --user -u abaddon -f