Architettura (come funziona)
1) La WebApp chiama Hostinger con header X-APP-KEY e file audio (webm/opus).
2) transcribe.php valida la chiave e inoltra il file a Raspberry su:
https://giuseppemontisci.sytes.net/stt/transcribe aggiungendo X-API-KEY.
3) Apache su Raspberry inoltra /stt/* verso Flask (porta 5005).
4) Flask converte in WAV 16k mono con ffmpeg e trascrive con Vosk usando il modello grande su disco esterno.
Cartelle (cd) • Dove eseguire i comandi Linux
Questa sezione ti evita dubbi: prima di ogni blocco comandi, trovi già il cd corretto.
Qui comunque hai una “mappa” rapida.
# Torna alla home dell'utente pi
cd /home/pi
# Vai nella cartella progetto STT (Raspberry)
cd /home/pi/stt_vosk
# Controlla il modello sul disco esterno
cd /media/pi/SARDO1
ls -la
# Configurazioni di sistema (nessun cd obbligatorio, ma se vuoi)
cd /etc
cd /etc/systemd/system
cd /etc/apache2
Raspberry 01 • Dipendenze di sistema
Questi comandi puoi eseguirli da qualsiasi cartella (consigliato dalla home):
cd /home/pi
sudo apt update
sudo apt install -y python3 python3-venv python3-pip ffmpeg apache2
sudo a2enmod proxy proxy_http ssl headers rewrite
Se Apache è già installato Apri
Se Apache esiste già, puoi eseguire solo la parte dei moduli:
cd /home/pi
sudo a2enmod proxy proxy_http ssl headers rewrite
sudo systemctl restart apache2
Raspberry 02 • Progetto in /home/pi/stt_vosk + symlink modello
Crea la cartella progetto e imposta il link model verso il modello grande sul disco:
/media/pi/SARDO1/vosk-model-it-0.22.
cd /home/pi
mkdir -p /home/pi/stt_vosk
cd /home/pi/stt_vosk
# Crea/aggiorna il symlink "model" verso il modello grande
rm -f model
ln -s /media/pi/SARDO1/vosk-model-it-0.22 model
# Verifica
pwd
ls -la | grep model
SARDO1 deve essere montato automaticamente al boot.
Nella sezione systemd useremo RequiresMountsFor= per aspettare il mount.
Raspberry 03 • Virtualenv + install Flask/Vosk
Nel tuo caso il server deve girare dal venv (non dal pip globale).
Quindi: entri in /home/pi/stt_vosk e fai tutto da lì.
cd /home/pi/stt_vosk
python3 -m venv venv
./venv/bin/pip install --upgrade pip wheel setuptools
./venv/bin/pip install flask vosk
Verifica pacchetti nel venv Apri
cd /home/pi/stt_vosk
./venv/bin/python -V
./venv/bin/pip show flask vosk | sed -n "1,12p"
./venv/bin/pip.
Raspberry 04 • server.py + systemd (auto-start al riavvio)
Qui trovi server.py completo.
Va salvato come: /home/pi/stt_vosk/server.py.
cd /home/pi/stt_vosk
nano server.py
chmod +x server.py
server.py (completo) Apri
#!/usr/bin/env python3
import os
import re
import json
import time
import tempfile
import subprocess
import wave
import urllib.request
from flask import Flask, request, jsonify, make_response
from vosk import Model, KaldiRecognizer
APP_HOST = "0.0.0.0"
APP_PORT = 5005
API_KEY = "QueSTa_MiaChiaVE_PERTraduZIOne!3"
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
MODEL_PATH = os.path.join(BASE_DIR, "model") # symlink -> /media/pi/SARDO1/vosk-model-it-0.22
# ✅ URL API Hostinger che restituisce i piatti
# Esempio: "https://tuodominio.it/api/stt_piatti.php"
DISH_API_URL = os.getenv("STT_DISH_API_URL", "https://mydigitaltools.it/api_stt/api/stt_piatti.php")
# Azienda di default se non passa header
DEFAULT_AZIENDA_ID = int(os.getenv("STT_AZIENDA_ID", "1"))
# Cache phrases (secondi)
PHRASES_TTL_SECONDS = int(os.getenv("STT_PHRASES_TTL", "120"))
# Limiti (per non far esplodere la phrase_list)
LIMIT_FULL_DISH_PHRASES = int(os.getenv("STT_LIMIT_FULL_DISH_PHRASES", "250")) # quante frasi "ugo togli "
MAX_QTY_VARIANTS = int(os.getenv("STT_MAX_QTY_VARIANTS", "3")) # "ugo togli 2 " ecc.
print("Caricamento modello da:", MODEL_PATH)
model = Model(MODEL_PATH)
print("Modello caricato correttamente.")
app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 15 * 1024 * 1024
def _options_204():
return make_response("", 204)
_re_keep = re.compile(r"[^a-z0-9àèéìòù\s]", re.IGNORECASE)
def norm_phrase(s: str) -> str:
s = (s or "").strip().lower()
s = s.replace("’", " ")
s = s.replace("'", " ")
s = _re_keep.sub(" ", s)
s = " ".join(s.split())
return s
def get_azienda_id_from_request():
h = (request.headers.get("X-AZIENDA-ID") or "").strip()
if h.isdigit() and int(h) > 0:
return int(h)
q = (request.args.get("azienda_id") or "").strip()
if q.isdigit() and int(q) > 0:
return int(q)
return DEFAULT_AZIENDA_ID
_phrases_cache = {} # azienda_id -> {"at": ts, "phrases": [...]}
def base_words():
nums_words = [
"zero","uno","due","tre","quattro","cinque","sei","sette","otto","nove","dieci",
"undici","dodici","tredici","quattordici","quindici","sedici","diciassette","diciotto","diciannove","venti","trenta"
]
digits = [str(i) for i in range(0, 51)]
return [
"ugo","apri","chiudi","azzera","togli","tavolo",
"il","lo","la","i","gli","le","al","allo","alla","ai","agli","alle"
] + nums_words + digits
def fetch_dishes_from_api(azienda_id: int):
url = f"{DISH_API_URL}?azienda_id={azienda_id}"
req = urllib.request.Request(
url,
headers={
"X-API-KEY": API_KEY,
"X-AZIENDA-ID": str(azienda_id),
"User-Agent": "stt_vosk/1.0"
},
method="GET"
)
with urllib.request.urlopen(req, timeout=6) as resp:
raw = resp.read().decode("utf-8", errors="replace")
data = json.loads(raw)
if not isinstance(data, dict) or not data.get("ok"):
raise RuntimeError("API piatti non valida: " + str(data))
dishes = data.get("dishes", [])
if not isinstance(dishes, list):
return []
out = []
for d in dishes:
n = norm_phrase(str(d))
if n:
out.append(n)
out = sorted(list(set(out)))
print(f"[STT] Piatti da API azienda_id={azienda_id}: {len(out)}")
return out
def build_phrases(azienda_id: int, force: bool = False):
now = int(time.time())
cached = _phrases_cache.get(azienda_id)
if (not force) and cached and (now - cached["at"] < PHRASES_TTL_SECONDS):
return cached["phrases"]
phrases = []
phrases.extend(base_words())
phrases.extend([
"ugo chiudi",
"ugo azzera",
"ugo apri tavolo",
"ugo togli",
])
tavoli_words = [
("uno", 1), ("due", 2), ("tre", 3), ("quattro", 4), ("cinque", 5),
("sei", 6), ("sette", 7), ("otto", 8), ("nove", 9), ("dieci", 10),
("undici", 11), ("dodici", 12), ("tredici", 13), ("quattordici", 14), ("quindici", 15),
("sedici", 16), ("diciassette", 17), ("diciotto", 18), ("diciannove", 19), ("venti", 20),
("trenta", 30),
]
for w, n in tavoli_words:
phrases.append(f"ugo apri tavolo {w}")
phrases.append(f"ugo apri tavolo {n}")
try:
dishes = fetch_dishes_from_api(azienda_id)
dish_words = set()
for d in dishes:
for w in d.split(" "):
if len(w) >= 2:
dish_words.add(w)
phrases.extend(sorted(list(dish_words)))
for d in dishes[:LIMIT_FULL_DISH_PHRASES]:
phrases.append("ugo togli " + d)
qty_words = ["uno", "due", "tre", "quattro", "cinque"]
qty_digits = ["1", "2", "3", "4", "5"]
qN = max(0, min(MAX_QTY_VARIANTS, 5))
for d in dishes[:min(LIMIT_FULL_DISH_PHRASES, 200)]:
for i in range(qN):
phrases.append(f"ugo togli {qty_words[i]} {d}")
phrases.append(f"ugo togli {qty_digits[i]} {d}")
except Exception as e:
print("[STT] ERRORE lettura piatti da API:", e)
phrases = [norm_phrase(p) for p in phrases if p]
phrases = list(dict.fromkeys(phrases))
_phrases_cache[azienda_id] = {"at": now, "phrases": phrases}
print(f"[STT] phrase_list pronta azienda_id={azienda_id}: {len(phrases)} (ttl={PHRASES_TTL_SECONDS}s)")
return phrases
def convert_to_wav(in_path, out_path):
cmd = [
"ffmpeg",
"-y",
"-hide_banner",
"-loglevel", "error",
"-i", in_path,
"-ac", "1",
"-ar", "16000",
"-f", "wav",
out_path
]
subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
def transcribe_wav(wav_path, azienda_id: int):
phrases = build_phrases(azienda_id, force=False)
grammar = json.dumps({"phrase_list": phrases}, ensure_ascii=False)
with wave.open(wav_path, "rb") as wf:
sample_rate = wf.getframerate()
rec = KaldiRecognizer(model, sample_rate, grammar)
rec.SetWords(False)
while True:
data = wf.readframes(4000)
if len(data) == 0:
break
rec.AcceptWaveform(data)
result = json.loads(rec.FinalResult())
text = (result.get("text") or "").strip().lower()
print(f"[STT] Transcript azienda_id={azienda_id}:", text)
return text
@app.get("/health")
def health():
return jsonify({"ok": True})
@app.route("/health", methods=["OPTIONS"])
def health_options():
return _options_204()
@app.post("/reload")
def reload_phrases():
key = request.headers.get("X-API-KEY", "")
if key != API_KEY:
return jsonify({"ok": False, "error": "Unauthorized"}), 401
azienda_id = get_azienda_id_from_request()
phrases = build_phrases(azienda_id, force=True)
return jsonify({"ok": True, "azienda_id": azienda_id, "count": len(phrases)})
@app.route("/reload", methods=["OPTIONS"])
def reload_options():
return _options_204()
# --- testo -> testo (compatibile con Web Speech API del browser) ---
@app.post("/transcribe_text")
def transcribe_text():
key = request.headers.get("X-API-KEY", "")
if key != API_KEY:
return jsonify({"ok": False, "error": "Unauthorized"}), 401
data = request.get_json(silent=True) or {}
txt = data.get("text") if isinstance(data, dict) else ""
if not isinstance(txt, str):
return jsonify({"ok": False, "error": "Invalid text"}), 400
txt = txt.strip()
if not txt:
return jsonify({"ok": False, "error": "Missing text"}), 400
azienda_id = get_azienda_id_from_request()
out = norm_phrase(txt)
return jsonify({"ok": True, "azienda_id": azienda_id, "transcript": out})
@app.route("/transcribe_text", methods=["OPTIONS"])
def transcribe_text_options():
return _options_204()
# --- audio -> testo (Vosk) ---
@app.post("/transcribe")
def transcribe():
key = request.headers.get("X-API-KEY", "")
if key != API_KEY:
return jsonify({"ok": False, "error": "Unauthorized"}), 401
if "audio" not in request.files:
return jsonify({"ok": False, "error": "Missing file"}), 400
azienda_id = get_azienda_id_from_request()
f = request.files["audio"]
with tempfile.TemporaryDirectory() as tmpdir:
in_path = os.path.join(tmpdir, "input.webm")
wav_path = os.path.join(tmpdir, "audio.wav")
f.save(in_path)
convert_to_wav(in_path, wav_path)
text = transcribe_wav(wav_path, azienda_id)
return jsonify({"ok": True, "azienda_id": azienda_id, "transcript": text})
@app.route("/transcribe", methods=["OPTIONS"])
def transcribe_options():
return _options_204()
if __name__ == "__main__":
try:
build_phrases(DEFAULT_AZIENDA_ID, force=True)
except Exception as e:
print("[STT] WARNING build iniziale fallito:", e)
print("Server STT avviato su porta", APP_PORT)
app.run(host=APP_HOST, port=APP_PORT)
Ora creiamo il servizio systemd per avvio automatico.
Usiamo RequiresMountsFor=/media/pi/SARDO1 così il servizio parte solo quando il disco col modello è montato.
cd /home/pi
sudo nano /etc/systemd/system/stt_vosk.service
[Unit]
Description=STT Vosk Flask Server (server.py)
Wants=network-online.target
After=network-online.target
RequiresMountsFor=/media/pi/SARDO1
[Service]
Type=simple
User=pi
Group=pi
WorkingDirectory=/home/pi/stt_vosk
EnvironmentFile=-/etc/stt_vosk.env
ExecStart=/home/pi/stt_vosk/venv/bin/python /home/pi/stt_vosk/server.py
Restart=on-failure
RestartSec=5
TimeoutStartSec=60
[Install]
WantedBy=multi-user.target
(Opzionale ma consigliato) file env per i parametri variabili:
STT_DISH_API_URL=https://mydigitaltools.it/api_stt/api/stt_piatti.php
STT_AZIENDA_ID=1
STT_PHRASES_TTL=120
STT_LIMIT_FULL_DISH_PHRASES=250
STT_MAX_QTY_VARIANTS=3
cd /home/pi
sudo nano /etc/stt_vosk.env
sudo chmod 600 /etc/stt_vosk.env
sudo chown root:root /etc/stt_vosk.env
sudo systemctl daemon-reload
sudo systemctl enable --now stt_vosk
systemctl status stt_vosk --no-pager
Raspberry 05 • Apache reverse proxy su /stt (HTTPS)
Il tuo Hostinger chiama: https://giuseppemontisci.sytes.net/stt/transcribe.
Quindi Apache deve inoltrare /stt verso Flask (porta 5005).
Crea un file di configurazione (o modifica quello del tuo VirtualHost HTTPS):
cd /home/pi
sudo nano /etc/apache2/sites-available/stt.conf
<IfModule mod_ssl.c>
<VirtualHost *:443>
ServerName giuseppemontisci.sytes.net
ServerAlias raspberrysardo75.sytes.net
ProxyPreserveHost On
RequestHeader set X-Forwarded-Proto "https"
ProxyPass "/stt/" "http://127.0.0.1:5005/"
ProxyPassReverse "/stt/" "http://127.0.0.1:5005/"
ProxyTimeout 30
ErrorLog ${APACHE_LOG_DIR}/stt_error.log
CustomLog ${APACHE_LOG_DIR}/stt_access.log combined
# Certificati HTTPS (esempio Let's Encrypt):
# SSLEngine On
# SSLCertificateFile /etc/letsencrypt/live/giuseppemontisci.sytes.net/fullchain.pem
# SSLCertificateKeyFile /etc/letsencrypt/live/giuseppemontisci.sytes.net/privkey.pem
</VirtualHost>
</IfModule>
cd /home/pi
sudo a2ensite stt.conf
sudo apachectl configtest
sudo systemctl restart apache2
Hostinger 06 • Crea cartella public_html/api_stt + include
Su Hostinger (File Manager o FTP) crea questa struttura:
public_html/
api_stt/
.htaccess
transcribe.php
include/
api_keys.php
stt_config.php
include/ viene bloccata via .htaccess.
Hostinger 07 • File esatti (transcribe.php, .htaccess, include/*)
Incolla 1:1 su Hostinger nelle rispettive posizioni. (Se li hai già uguali, non serve riscriverli.)
Options -Indexes
RewriteEngine On
RewriteRule ^include/ - [F,L]
<?php
$API_KEYS = [
"APP1_CHIAVE_LUNGA",
"APP2_CHIAVE_LUNGA"
];
define("APP_KEY_HEADER", "HTTP_X_APP_KEY");
<?php
define("RASPBERRY_STT_URL", "https://giuseppemontisci.sytes.net/stt/transcribe");
define("RASPBERRY_STT_KEY", "QueSTa_MiaChiaVE_PERTraduZIOne!3");
define("MAX_AUDIO_BYTES", 5 * 1024 * 1024);
<?php
header("Content-Type: application/json; charset=utf-8");
require_once __DIR__ . "/include/stt_config.php";
require_once __DIR__ . "/include/api_keys.php";
function out($arr, $code = 200) {
http_response_code($code);
echo json_encode($arr, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
function getAppKey() {
return $_SERVER[APP_KEY_HEADER] ?? "";
}
function isKeyValid($key, $keys) {
foreach ($keys as $k) {
if (hash_equals($k, $key)) return true;
}
return false;
}
$appKey = getAppKey();
if ($appKey === "" || !isKeyValid($appKey, $API_KEYS)) {
out(["ok" => false, "error" => "Unauthorized"], 401);
}
if ($_SERVER["REQUEST_METHOD"] !== "POST") {
out(["ok" => false, "error" => "Metodo non consentito"], 405);
}
if (!isset($_FILES["audio"]) || $_FILES["audio"]["error"] !== UPLOAD_ERR_OK) {
out(["ok" => false, "error" => "File audio mancante"], 400);
}
$tmpPath = $_FILES["audio"]["tmp_name"];
$origName = $_FILES["audio"]["name"] ?? "audio.webm";
$size = filesize($tmpPath);
if ($size === false || $size <= 0) {
out(["ok" => false, "error" => "File audio vuoto"], 400);
}
if ($size > MAX_AUDIO_BYTES) {
out(["ok" => false, "error" => "File troppo grande"], 413);
}
if (!function_exists("curl_init")) {
out(["ok" => false, "error" => "cURL non disponibile su Hostinger"], 500);
}
$ch = curl_init(RASPBERRY_STT_URL);
$postFields = [
"audio" => curl_file_create($tmpPath, "audio/webm", $origName)
];
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $postFields);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 8);
curl_setopt($ch, CURLOPT_TIMEOUT, 20);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"X-API-KEY: " . RASPBERRY_STT_KEY
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($response === false || $response === null) {
out(["ok" => false, "error" => "Errore chiamata Raspberry", "details" => $err], 502);
}
$data = json_decode($response, true);
if ($httpCode !== 200 || !is_array($data) || !isset($data["ok"])) {
out(["ok" => false, "error" => "Risposta Raspberry non valida", "raw" => $response], 502);
}
if (!$data["ok"]) {
out(["ok" => false, "error" => "Trascrizione fallita", "details" => $data], 502);
}
$transcript = trim((string)($data["transcript"] ?? ""));
out([
"ok" => true,
"transcript" => $transcript
]);
Test & verifiche (Raspberry + Hostinger end-to-end)
1) Test locale Raspberry (Flask diretto)
cd /home/pi
curl -s http://127.0.0.1:5005/health
2) Test Raspberry via Apache /stt (HTTPS)
cd /home/pi
curl -s https://giuseppemontisci.sytes.net/stt/health
3) Test Hostinger → Raspberry (simulando la WebApp)
Prepara un file audio di test (webm/opus). Poi:
curl -s -X POST \
-H "X-APP-KEY: APP1_CHIAVE_LUNGA" \
-F "audio=@test.webm" \
https://TUO_DOMINIO/api_stt/transcribe.php
{"ok": true, "transcript": "..."}.
Log (diagnostica veloce)
1) Log server STT (systemd)
cd /home/pi
journalctl -u stt_vosk -f
2) Log Apache (reverse proxy /stt)
cd /home/pi
sudo tail -n 200 /var/log/apache2/stt_error.log
sudo tail -n 200 /var/log/apache2/stt_access.log
3) Stato servizi
cd /home/pi
systemctl status stt_vosk --no-pager
systemctl status apache2 --no-pager
Troubleshooting (problemi comuni)
1) “Model not found” / crash al boot Apri
Quasi sempre è il disco non montato o il symlink model sbagliato. Verifica:
cd /home/pi
mount | grep SARDO1 || true
ls -la /home/pi/stt_vosk | grep model
ls -la /media/pi/SARDO1/vosk-model-it-0.22 | head
2) Hostinger risponde “Unauthorized” Apri
Stai chiamando transcribe.php senza header X-APP-KEY valido
(deve essere dentro include/api_keys.php).
3) Hostinger risponde “Errore chiamata Raspberry” (502) Apri
Di solito è uno di questi:
- DNS/DDNS non risolve verso casa (hostname non aggiornato)
- porta 443 non raggiungibile (router / firewall)
- Apache reverse proxy non configurato correttamente
- certificato HTTPS non valido/scaduto
Test da un PC esterno:
curl -v https://giuseppemontisci.sytes.net/stt/health
4) Trascrizione vuota / ffmpeg non converte Apri
Verifica ffmpeg e guarda i log del servizio:
cd /home/pi
ffmpeg -version
journalctl -u stt_vosk --since "10 minutes ago"