Procedura STT • Raspberry Pi + Hostinger • Vosk modello grande IT 0.22

Trascrizione audio (STT) con Vosk su Raspberry Pi
+ Gateway API su Hostinger

Questa guida replica la tua architettura:
Hostinger espone /public_html/api_stt/transcribe.php (con chiave app) e inoltra la richiesta verso Raspberry su https://giuseppemontisci.sytes.net/stt/transcribe (con chiave interna).
Sul Raspberry gira server.py in /home/pi/stt_vosk, con model/ che è un link a /media/pi/SARDO1/vosk-model-it-0.22.

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.

Vantaggio: la WebApp non espone direttamente la porta 5005 (se usi Apache+HTTPS), e le chiavi sono separate (esterna vs interna).

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.

Mappa cartelle bash
# 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):

Comandi bash
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:

Solo moduli bash
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.

Cartelle + symlink bash
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
Importante: il disco 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ì.

Crea venv + pip install bash
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
Check pacchetti bash
cd /home/pi/stt_vosk

./venv/bin/python -V
./venv/bin/pip show flask vosk | sed -n "1,12p"
Se vedevi “Package(s) not found: vosk”, stavi usando il pip globale e non ./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.

Vai nella cartella giusta e crea/edita server.py bash
cd /home/pi/stt_vosk

nano server.py
chmod +x server.py
server.py (completo) Apri
/home/pi/stt_vosk/server.py python
#!/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.

Crea service bash
cd /home/pi

sudo nano /etc/systemd/system/stt_vosk.service
/etc/systemd/system/stt_vosk.service systemd
[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:

/etc/stt_vosk.env env
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
Abilita e avvia bash
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
Se vedi Active: active (running) il server è su e riparte da solo a ogni riavvio.

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).

Se vuoi aumentare la sicurezza: non aprire la porta 5005 sul router. Lascia la 5005 solo locale e fai esporre solo 443/HTTPS via Apache.

Crea un file di configurazione (o modifica quello del tuo VirtualHost HTTPS):

Crea vhost bash
cd /home/pi

sudo nano /etc/apache2/sites-available/stt.conf
/etc/apache2/sites-available/stt.conf apache
<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>
Abilita sito + restart bash
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:

Struttura path
public_html/
  api_stt/
    .htaccess
    transcribe.php
    include/
      api_keys.php
      stt_config.php
Sicurezza: la cartella 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.)

public_html/api_stt/.htaccess apache
Options -Indexes

RewriteEngine On
RewriteRule ^include/ - [F,L]
public_html/api_stt/include/api_keys.php php
<?php

$API_KEYS = [
    "APP1_CHIAVE_LUNGA",
    "APP2_CHIAVE_LUNGA"
];

define("APP_KEY_HEADER", "HTTP_X_APP_KEY");
public_html/api_stt/include/stt_config.php php
<?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);
public_html/api_stt/transcribe.php php
<?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)

Health (locale) bash
cd /home/pi

curl -s http://127.0.0.1:5005/health

2) Test Raspberry via Apache /stt (HTTPS)

Health (HTTPS) bash
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:

Chiamata a Hostinger bash
curl -s -X POST \
  -H "X-APP-KEY: APP1_CHIAVE_LUNGA" \
  -F "audio=@test.webm" \
  https://TUO_DOMINIO/api_stt/transcribe.php
Se tutto è OK, ricevi JSON con {"ok": true, "transcript": "..."}.

Log (diagnostica veloce)

1) Log server STT (systemd)

journalctl stt_vosk bash
cd /home/pi

journalctl -u stt_vosk -f

2) Log Apache (reverse proxy /stt)

Apache logs bash
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

Status bash
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:

Check mount + symlink bash
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:

Test esterno bash
curl -v https://giuseppemontisci.sytes.net/stt/health
4) Trascrizione vuota / ffmpeg non converte Apri

Verifica ffmpeg e guarda i log del servizio:

Check ffmpeg + log bash
cd /home/pi

ffmpeg -version
journalctl -u stt_vosk --since "10 minutes ago"
Suggerimento “pro”: lascia la porta 5005 solo su localhost e pubblica solo HTTPS 443 via Apache (/stt).