Dictée : whisper-server supervisé, modèle chargé une seule fois

Chaque dictée relançait whisper-cli, qui relisait le modèle depuis le disque
avant de transcrire. Ce chargement dominait le temps de réponse — 3,2 s de
calcul pour 3,4 s d'audio — et aucun réglage ne pouvait le rattraper.

whisper-server garde le modèle en mémoire entre deux phrases. Loki le
supervise : démarrage au premier clic sur le micro, extinction après dix
minutes sans dictée. Une dictée n'est pas un service permanent, et garder un
modèle chargé toute la journée priverait le moteur de chat de sa VRAM.

- Réglages serveur (modèle, langue, matériel, réactivité) dans bkState, avec
  leurs routes. La langue par défaut passe de « auto » à « fr » : sur quelques
  secondes d'audio la détection se trompe, et une langue mal détectée produit
  du charabia — des suites de caractères géorgiens ont été observées.
- Catalogue de quatre modèles, de small à large-v3 ; large-v3-turbo par
  défaut. Téléchargement par identifiant, dans un .part renommé à la fin : un
  transfert interrompu ne laisse plus un .bin tronqué qu'on croirait bon.
- Le GPU se choisit par CUDA_VISIBLE_DEVICES, posé en REMPLAÇANT toute valeur
  héritée. Dupliquée, la variable laisse le gagnant dépendre de la libc.
- Les marqueurs de whisper ([BLANK_AUDIO], (silence)) ne sont plus collés dans
  le champ de saisie comme s'ils étaient du texte dicté.

L'image construit DEUX binaires whisper-server, CPU et CUDA. LLAMACPP_IMAGE
accepte la variante CPU de l'image amont ; sur cette base les .so CUDA sont
absentes et un binaire lié à CUDA n'a même pas de quoi démarrer, donc aucun
repli n'est possible depuis le programme. Le garde-fou du Dockerfile vérifie
maintenant les deux étapes : les drapeaux de portabilité de la PR #18 doivent
survivre à l'arrivée de CUDA.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
This commit is contained in:
MichaelandClaude Opus 5 committed 2026-08-21 21:08:12 +02:00
1 parent 3624194b39
commit 3cd4facb0e
11 files changed
+1222 -146

No files matched your search

+120 -106
View File
@@ -1,13 +1,17 @@
// Dictée vocale — POST /api/transcribe : un WAV (16 kHz mono, encodé côté
// navigateur) entre, le texte transcrit sort. La transcription est 100 % locale,
// par whisper-cli (whisper.cpp, même famille que llama.cpp), compilé dans
// l'image Docker. Le modèle (ggml-small-q5_1, ~190 Mo, multilingue) est
// téléchargé au premier usage dans LOKI_HOME/whisper/ — comme le moteur, il ne
// gonfle pas l'image et survit aux recréations du conteneur via /data.
// par whisper.cpp. Le modèle est téléchargé au premier usage dans
// LOKI_HOME/whisper/ — comme le moteur, il ne gonfle pas l'image et survit aux
// recréations du conteneur via /data.
//
// La transcription elle-même passe par whisper-server (dictate_server.go), qui
// garde le modèle chargé. L'ancien chemin relançait whisper-cli à chaque
// requête, donc rechargeait le modèle à chaque phrase dictée.
package loki
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
@@ -15,52 +19,35 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"time"
)
const whisperModelURL = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small-q5_1.bin"
// whisperBin localise whisper-cli : variable d'environnement (posée par le
// Dockerfile), sinon le PATH. Vide = dictée indisponible (image trop ancienne,
// ou poste de dev sans whisper.cpp) — le handler le dit clairement.
func whisperBin() string {
if p := os.Getenv("LOKI_WHISPER_BIN"); p != "" {
if _, err := os.Stat(p); err == nil {
return p
}
}
if p, err := exec.LookPath("whisper-cli"); err == nil {
return p
}
return ""
}
func whisperModelPath() string {
return filepath.Join(LokiHome(), "whisper", "ggml-small-q5_1.bin")
}
// Téléchargement du modèle : UNE goroutine, progression consultable. Le premier
// POST le déclenche et répond 503 {downloading, pct} ; l'UI invite à réessayer.
// Téléchargement d'un modèle : UNE goroutine à la fois, progression
// consultable. Le premier POST le déclenche et répond 503 {downloading, pct} ;
// l'UI invite à réessayer.
var wspMu sync.Mutex
var wspDl struct {
id string
running bool
pct int
err string
}
func whisperStartDownload() {
func whisperStartDownload(id string) {
wspMu.Lock()
defer wspMu.Unlock()
if wspDl.running {
return
}
wspDl.running, wspDl.pct, wspDl.err = true, 0, ""
if whisperModelURLFor(id) == "" {
wspDl.err = "modèle de dictée inconnu : " + id
return
}
wspDl.id, wspDl.running, wspDl.pct, wspDl.err = id, true, 0, ""
go func() {
err := whisperDownload()
err := whisperDownload(id)
wspMu.Lock()
wspDl.running = false
if err != nil {
@@ -70,19 +57,24 @@ func whisperStartDownload() {
}()
}
func whisperDownload() error {
dst := whisperModelPath()
func whisperDownload(id string) error {
dst := whisperModelPathFor(id)
if dst == "" {
return fmt.Errorf("modèle de dictée inconnu : %s", id)
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
resp, err := http.Get(whisperModelURL)
resp, err := http.Get(whisperModelURLFor(id))
if err != nil {
return err
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != 200 {
return fmt.Errorf("téléchargement du modèle whisper : HTTP %d", resp.StatusCode)
}
// Écriture dans un .part renommé à la fin : un téléchargement interrompu
// ne doit pas laisser un .bin tronqué que whisperModelPresent croirait bon.
tmp := dst + ".part"
f, err := os.Create(tmp)
if err != nil {
@@ -95,8 +87,8 @@ func whisperDownload() error {
n, rerr := resp.Body.Read(buf)
if n > 0 {
if _, werr := f.Write(buf[:n]); werr != nil {
f.Close()
os.Remove(tmp)
_ = f.Close()
_ = os.Remove(tmp)
return werr
}
done += int64(n)
@@ -110,91 +102,110 @@ func whisperDownload() error {
break
}
if rerr != nil {
f.Close()
os.Remove(tmp)
_ = f.Close()
_ = os.Remove(tmp)
return rerr
}
}
if err := f.Close(); err != nil {
os.Remove(tmp)
_ = os.Remove(tmp)
return err
}
return os.Rename(tmp, dst)
}
func handleTranscribe(w http.ResponseWriter, r *http.Request) {
bin := whisperBin()
if bin == "" {
sendJSON(w, 501, map[string]any{"error": "dictée indisponible : whisper-cli absent (image à reconstruire, ou poste sans whisper.cpp)"})
return
}
model := whisperModelPath()
if _, err := os.Stat(model); err != nil {
wspMu.Lock()
derr := wspDl.err
pct := wspDl.pct
wspMu.Unlock()
if derr != "" {
// Échec précédent : on le dit ET on relance — un réseau revenu suffit.
wspMu.Lock()
wspDl.err = ""
wspMu.Unlock()
whisperStartDownload()
sendJSON(w, 503, map[string]any{"downloading": true, "pct": 0, "error": derr})
return
}
whisperStartDownload()
sendJSON(w, 503, map[string]any{"downloading": true, "pct": pct})
return
}
// whisperDlEtat : instantané de la progression, pour l'UI et pour les 503.
func whisperDlEtat() (id string, running bool, pct int, errMsg string) {
wspMu.Lock()
defer wspMu.Unlock()
return wspDl.id, wspDl.running, wspDl.pct, wspDl.err
}
func handleTranscribe(w http.ResponseWriter, r *http.Request) {
audio, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 32<<20))
if err != nil || len(audio) < 1000 {
sendJSON(w, 400, map[string]any{"error": "audio vide ou trop court"})
return
}
tmp, err := os.CreateTemp("", "loki-dictee-*.wav")
if err != nil {
sendJSON(w, 500, map[string]any{"error": err.Error()})
return
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if _, err := tmp.Write(audio); err != nil {
tmp.Close()
sendJSON(w, 500, map[string]any{"error": err.Error()})
return
}
tmp.Close()
// -l auto : détection de langue (le français sort en français) ; -nt sans
// horodatages ; -np sans bavardage de chargement. CPU seul : une dictée de
// quelques dizaines de secondes se transcrit en 1-3 s avec small-q5_1.
threads := runtime.NumCPU()
if threads > 8 {
threads = 8
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
defer cancel()
cmd := exec.CommandContext(ctx, bin, "-m", model, "-f", tmpPath, "-l", "auto", "-nt", "-np", "-t", strconv.Itoa(threads))
var stderr strings.Builder
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
// Ne garder QUE stderr est trompeur quand le binaire meurt d'un signal :
// la dernière ligne écrite avant l'exécution du coup fatal ressemble à
// une explication (« AMX is not ready to be used! ») alors que la vraie
// cause est la mort brutale. On dit donc toujours comment il a fini —
// un SIGILL/SIGSEGV désigne un binaire compilé pour un autre
// processeur, pas un problème d'audio.
msg := whisperExitReason(err)
if last := lastLine(stderr.String()); last != "" {
msg += " — dernière sortie : " + last
cfg := dictateCfgLoad()
if !whisperModelPresent(cfg.Model) {
_, _, pct, derr := whisperDlEtat()
if derr != "" {
// Échec précédent : on le dit ET on relance — un réseau revenu suffit.
wspMu.Lock()
wspDl.err = ""
wspMu.Unlock()
whisperStartDownload(cfg.Model)
sendJSON(w, 503, map[string]any{"downloading": true, "pct": 0, "error": derr})
return
}
sendJSON(w, 500, map[string]any{"error": "whisper-cli : " + msg})
whisperStartDownload(cfg.Model)
sendJSON(w, 503, map[string]any{"downloading": true, "pct": pct})
return
}
sendJSON(w, 200, map[string]any{"text": strings.TrimSpace(string(out))})
// Le chargement du modèle au premier appel peut être long ; la
// transcription elle-même ne l'est pas.
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
defer cancel()
txt, err := whisperInfer(ctx, audio)
if err != nil {
sendJSON(w, 500, map[string]any{"error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"text": txt})
}
// ─── Réglages de la dictée ───────────────────────────────────────────────────
func handleDictateConfig(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
var c DictateCfg
if err := json.NewDecoder(r.Body).Decode(&c); err != nil {
sendJSON(w, 400, map[string]any{"error": "requête illisible"})
return
}
if err := dictateCfgSave(c); err != nil {
sendJSON(w, 400, map[string]any{"error": err.Error()})
return
}
// Sans ça, le serveur continuerait de tourner avec l'ancien modèle et
// le nouveau réglage semblerait sans effet.
whisperShutdown()
sendJSON(w, 200, map[string]any{"ok": true})
return
}
sendJSON(w, 200, dictateCfgLoad())
}
func handleDictateModels(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, whisperCatalogueTrie())
}
func handleDictateDownload(w http.ResponseWriter, r *http.Request) {
var req struct {
ID string `json:"id"`
}
if r.Method == http.MethodPost {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"error": "requête illisible"})
return
}
id := strings.TrimSpace(req.ID)
if whisperModelURLFor(id) == "" {
sendJSON(w, 400, map[string]any{"error": "modèle de dictée inconnu : " + id})
return
}
whisperStartDownload(id)
}
id, running, pct, errMsg := whisperDlEtat()
sendJSON(w, 200, map[string]any{"id": id, "running": running, "pct": pct, "error": errMsg})
}
func handleDictateState(w http.ResponseWriter, r *http.Request) {
etat := whisperEtat()
id, running, pct, errMsg := whisperDlEtat()
etat["dl"] = map[string]any{"id": id, "running": running, "pct": pct, "error": errMsg}
sendJSON(w, 200, etat)
}
// whisperExitReason traduit la fin du processus en une phrase utilisable.
@@ -204,6 +215,9 @@ func handleTranscribe(w http.ResponseWriter, r *http.Request) {
func whisperExitReason(err error) string {
var ee *exec.ExitError
if !errors.As(err, &ee) {
if err == nil {
return "arrêt sans erreur"
}
return err.Error()
}
st := ee.ProcessState.String()
@@ -213,7 +227,7 @@ func whisperExitReason(err error) string {
return st
}
// lastLine : la dernière ligne non vide. whisper-cli bavarde beaucoup avant de
// lastLine : la dernière ligne non vide. whisper bavarde beaucoup avant de
// tomber ; seule la fin renseigne, et un pavé ne tient pas dans un bandeau.
func lastLine(s string) string {
lines := strings.Split(strings.TrimSpace(s), "\n")