mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-24 07:28:13 +02:00
Dictée : Parakeet remplace whisper.cpp
Parakeet TDT 0.6B v3 (NVIDIA), servi par sherpa-onnx. La v3 et non la v2 : c'est la seule des deux qui parle français — la v2 est anglais seul. CE QUI DISPARAÎT DU DOCKERFILE Deux étapes de compilation, dont une CUDA de ~200 fichiers nvcc qui a été tuée par l'OOM du runner plus d'une fois, et avec elles tout l'appareillage de garde-fous qu'elles réclamaient (GGML_NATIVE=OFF contre le SIGILL en production, bornage des architectures CUDA, deux binaires CPU/CUDA à choisir à l'exécution). À la place : le téléchargement d'un binaire statique de 35 Mo, version épinglée et empreinte SHA-256 vérifiée — le binaire s'exécute sur la machine de l'utilisateur, une release remplacée en amont ne doit pas passer en silence. CE QUI CHANGE DANS LE CODE La forme est la même — un processus local supervisé, éteint après dix minutes d'inactivité. Le dialogue, lui, change : whisper-server exposait du HTTP multipart, sherpa-onnx n'expose qu'un WebSocket dont le protocole tient en deux entiers et des flottants. D'où un client WebSocket et une conversion WAV → float32 côté serveur. Le parcours des blocs du WAV n'est pas du zèle : l'offset 44 codé en dur transforme un bloc LIST intercalé en craquement au début de chaque phrase. DEUX RÉGLAGES DISPARAISSENT, ET C'EST LE MOTEUR QUI L'IMPOSE La LANGUE : Parakeet la détecte lui-même, il n'a aucun drapeau pour la forcer. Le réglage n'aurait servi qu'à mentir. À surveiller : whisper avait précisément écarté la détection automatique parce qu'elle se trompait sur des tranches courtes. Le GPU : le build livré est le statique CPU. Annoncer un sélecteur de carte sans pouvoir l'honorer serait pire que de ne rien annoncer — et un modèle de 0,6 B en int8 sur des tranches de quelques secondes n'en a pas besoin, la carte reste au moteur de chat. MIGRATION Un réglage enregistré du temps de whisper retombe sur le défaut au lieu de casser la dictée. Les modèles ggml de /data/whisper/ ne servent plus à rien mais ne sont PAS effacés : ce sont des données que personne n'a demandé de perdre. Ils sont à supprimer à la main. Le paquet UI regénéré ici couvre aussi les sources du commit précédent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
17f8fa8f34
commit
54468b3336
17 files changed
+1807
-894
No files matched your search
@@ -1,12 +1,11 @@
|
||||
// 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.
|
||||
// par Parakeet (NVIDIA) servi par sherpa-onnx. Le modèle est téléchargé au
|
||||
// premier usage dans LOKI_HOME/asr/ — 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.
|
||||
// La transcription elle-même passe par le serveur de dictée (dictate_server.go),
|
||||
// qui garde le modèle chargé entre deux phrases.
|
||||
package loki
|
||||
|
||||
import (
|
||||
@@ -35,19 +34,19 @@ var wspDl struct {
|
||||
err string
|
||||
}
|
||||
|
||||
func whisperStartDownload(id string) {
|
||||
func asrStartDownload(id string) {
|
||||
wspMu.Lock()
|
||||
defer wspMu.Unlock()
|
||||
if wspDl.running {
|
||||
return
|
||||
}
|
||||
if whisperModelURLFor(id) == "" {
|
||||
if asrModelURLFor(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)
|
||||
err := asrDownload(id)
|
||||
wspMu.Lock()
|
||||
wspDl.running = false
|
||||
if err != nil {
|
||||
@@ -57,65 +56,57 @@ func whisperStartDownload(id string) {
|
||||
}()
|
||||
}
|
||||
|
||||
func whisperDownload(id string) error {
|
||||
dst := whisperModelPathFor(id)
|
||||
// asrDownload télécharge et DÉCOMPRESSE un modèle. Un modèle Parakeet est une
|
||||
// archive .tar.bz2 qui contient un dossier de quatre fichiers, là où whisper.cpp
|
||||
// livrait un .bin unique : on décompresse donc au fil du téléchargement plutôt
|
||||
// que de garder l'archive sur le disque (près de 500 Mo pour rien).
|
||||
//
|
||||
// La progression est mesurée sur les octets REÇUS, pas sur les octets écrits :
|
||||
// c'est le téléchargement qui prend le temps, la décompression suit.
|
||||
func asrDownload(id string) error {
|
||||
dst := asrModelDirFor(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))
|
||||
resp, err := http.Get(asrModelURLFor(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)
|
||||
return fmt.Errorf("téléchargement du modèle de dictée : 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)
|
||||
// asrExtract écrit dans un dossier .part renommé à la fin : une extraction
|
||||
// interrompue ne laisse jamais un modèle à moitié installé sous son nom
|
||||
// définitif, que asrModelPresent croirait bon.
|
||||
return asrExtract(&asrProgress{r: resp.Body, total: resp.ContentLength}, dst)
|
||||
}
|
||||
|
||||
// whisperDlEtat : instantané de la progression, pour l'UI et pour les 503.
|
||||
func whisperDlEtat() (id string, running bool, pct int, errMsg string) {
|
||||
// asrProgress compte les octets lus et publie l'avancement. Enveloppe le corps
|
||||
// de la réponse pour que la barre bouge pendant la décompression, qui consomme
|
||||
// le flux à son rythme.
|
||||
type asrProgress struct {
|
||||
r io.Reader
|
||||
total int64
|
||||
lus int64
|
||||
}
|
||||
|
||||
func (p *asrProgress) Read(b []byte) (int, error) {
|
||||
n, err := p.r.Read(b)
|
||||
if n > 0 && p.total > 0 {
|
||||
p.lus += int64(n)
|
||||
wspMu.Lock()
|
||||
wspDl.pct = int(p.lus * 100 / p.total)
|
||||
wspMu.Unlock()
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// asrDlEtat : instantané de la progression, pour l'UI et pour les 503.
|
||||
func asrDlEtat() (id string, running bool, pct int, errMsg string) {
|
||||
wspMu.Lock()
|
||||
defer wspMu.Unlock()
|
||||
return wspDl.id, wspDl.running, wspDl.pct, wspDl.err
|
||||
@@ -128,18 +119,18 @@ func handleTranscribe(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
cfg := dictateCfgLoad()
|
||||
if !whisperModelPresent(cfg.Model) {
|
||||
_, _, pct, derr := whisperDlEtat()
|
||||
if !asrModelPresent(cfg.Model) {
|
||||
_, _, pct, derr := asrDlEtat()
|
||||
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)
|
||||
asrStartDownload(cfg.Model)
|
||||
sendJSON(w, 503, map[string]any{"downloading": true, "pct": 0, "error": derr})
|
||||
return
|
||||
}
|
||||
whisperStartDownload(cfg.Model)
|
||||
asrStartDownload(cfg.Model)
|
||||
sendJSON(w, 503, map[string]any{"downloading": true, "pct": pct})
|
||||
return
|
||||
}
|
||||
@@ -147,7 +138,7 @@ func handleTranscribe(w http.ResponseWriter, r *http.Request) {
|
||||
// transcription elle-même ne l'est pas.
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
|
||||
defer cancel()
|
||||
txt, err := whisperInfer(ctx, audio)
|
||||
txt, err := asrInfer(ctx, audio)
|
||||
if err != nil {
|
||||
sendJSON(w, 500, map[string]any{"error": err.Error()})
|
||||
return
|
||||
@@ -170,7 +161,7 @@ func handleDictateConfig(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
// Sans ça, le serveur continuerait de tourner avec l'ancien modèle et
|
||||
// le nouveau réglage semblerait sans effet.
|
||||
whisperShutdown()
|
||||
asrShutdown()
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
return
|
||||
}
|
||||
@@ -178,7 +169,7 @@ func handleDictateConfig(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func handleDictateModels(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 200, whisperCatalogueTrie())
|
||||
sendJSON(w, 200, asrCatalogueTrie())
|
||||
}
|
||||
|
||||
func handleDictateDownload(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -191,28 +182,28 @@ func handleDictateDownload(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(req.ID)
|
||||
if whisperModelURLFor(id) == "" {
|
||||
if asrModelURLFor(id) == "" {
|
||||
sendJSON(w, 400, map[string]any{"error": "modèle de dictée inconnu : " + id})
|
||||
return
|
||||
}
|
||||
whisperStartDownload(id)
|
||||
asrStartDownload(id)
|
||||
}
|
||||
id, running, pct, errMsg := whisperDlEtat()
|
||||
id, running, pct, errMsg := asrDlEtat()
|
||||
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 := asrEtat()
|
||||
id, running, pct, errMsg := asrDlEtat()
|
||||
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.
|
||||
// asrExitReason 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 {
|
||||
func asrExitReason(err error) string {
|
||||
var ee *exec.ExitError
|
||||
if !errors.As(err, &ee) {
|
||||
if err == nil {
|
||||
@@ -227,7 +218,7 @@ func whisperExitReason(err error) string {
|
||||
return st
|
||||
}
|
||||
|
||||
// lastLine : la dernière ligne non vide. whisper bavarde beaucoup avant de
|
||||
// lastLine : la dernière ligne non vide. Le serveur de dictée bavarde 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")
|
||||
|
||||
Reference in new issue
Block a user