mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-12 09:47:20 +02:00
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
241 lines
6.7 KiB
Go
241 lines
6.7 KiB
Go
// 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.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"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// 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(id string) {
|
|
wspMu.Lock()
|
|
defer wspMu.Unlock()
|
|
if wspDl.running {
|
|
return
|
|
}
|
|
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(id)
|
|
wspMu.Lock()
|
|
wspDl.running = false
|
|
if err != nil {
|
|
wspDl.err = err.Error()
|
|
}
|
|
wspMu.Unlock()
|
|
}()
|
|
}
|
|
|
|
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(whisperModelURLFor(id))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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 {
|
|
return err
|
|
}
|
|
total := resp.ContentLength
|
|
var done int64
|
|
buf := make([]byte, 1<<20)
|
|
for {
|
|
n, rerr := resp.Body.Read(buf)
|
|
if n > 0 {
|
|
if _, werr := f.Write(buf[:n]); werr != nil {
|
|
_ = f.Close()
|
|
_ = os.Remove(tmp)
|
|
return werr
|
|
}
|
|
done += int64(n)
|
|
if total > 0 {
|
|
wspMu.Lock()
|
|
wspDl.pct = int(done * 100 / total)
|
|
wspMu.Unlock()
|
|
}
|
|
}
|
|
if rerr == io.EOF {
|
|
break
|
|
}
|
|
if rerr != nil {
|
|
_ = f.Close()
|
|
_ = os.Remove(tmp)
|
|
return rerr
|
|
}
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
_ = os.Remove(tmp)
|
|
return err
|
|
}
|
|
return os.Rename(tmp, dst)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
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
|
|
}
|
|
whisperStartDownload(cfg.Model)
|
|
sendJSON(w, 503, map[string]any{"downloading": true, "pct": pct})
|
|
return
|
|
}
|
|
// 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.
|
|
// ProcessState.String() dit déjà « exit status 2 » ou « signal: illegal
|
|
// instruction » ; le second cas mérite son explication, parce que rien dans la
|
|
// dictée ne laisse deviner que le binaire ne tourne pas sur ce processeur.
|
|
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()
|
|
if strings.Contains(st, "signal:") {
|
|
return st + " (binaire compilé pour un autre processeur : image à reconstruire)"
|
|
}
|
|
return st
|
|
}
|
|
|
|
// 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")
|
|
for i := len(lines) - 1; i >= 0; i-- {
|
|
if l := strings.TrimSpace(lines[i]); l != "" {
|
|
return l
|
|
}
|
|
}
|
|
return ""
|
|
}
|