Files
Loki/internal/loki/dictate_server.go
T
MichaelandClaude Opus 5 54468b3336 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>
2026-09-07 22:34:57 +02:00

456 lines
16 KiB
Go

// Supervision du serveur de transcription : le modèle est chargé UNE fois et
// reste en mémoire, au lieu d'être relu depuis le disque à chaque phrase dictée.
//
// Le moteur est sherpa-onnx (Parakeet, NVIDIA), qui a remplacé whisper.cpp. Ce
// qui n'a PAS changé : un processus local supervisé, arrêté après un temps
// d'inactivité, redémarré quand les réglages bougent. Ce qui a changé : le
// dialogue. whisper-server exposait du HTTP multipart ; sherpa-onnx n'expose
// qu'un WebSocket, dont le protocole tient en deux entiers et des flottants
// (voir asrInferSur).
package loki
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"math"
"net"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/coder/websocket"
)
// asrServerBin : le binaire du serveur de transcription. Un seul, contrairement
// aux deux binaires whisper d'avant (CPU et CUDA) : le build sherpa-onnx livré
// est le statique CPU, il n'a donc aucune dépendance CUDA à satisfaire et
// démarre sur n'importe quelle base d'image.
func asrServerBin() string {
for _, p := range []string{os.Getenv("LOKI_ASR_SERVER"), "/usr/local/bin/sherpa-onnx-offline-websocket-server"} {
if p == "" {
continue
}
if _, err := os.Stat(p); err == nil {
return p
}
}
if p, err := exec.LookPath("sherpa-onnx-offline-websocket-server"); err == nil {
return p
}
return ""
}
// asrServerArgs. Les arguments de sherpa-onnx s'écrivent --clef=valeur : la
// forme séparée par une espace n'est PAS reconnue et le serveur démarre alors
// sans modèle.
func asrServerArgs(c DictateCfg, port int) ([]string, error) {
if asrModelDirFor(c.Model) == "" {
return nil, fmt.Errorf("modèle de dictée inconnu : %s", c.Model)
}
return []string{
"--encoder=" + asrModelFile(c.Model, "encoder.int8.onnx"),
"--decoder=" + asrModelFile(c.Model, "decoder.int8.onnx"),
"--joiner=" + asrModelFile(c.Model, "joiner.int8.onnx"),
"--tokens=" + asrModelFile(c.Model, "tokens.txt"),
// nemo_transducer : sans ce type, les sorties du modèle sont mal
// interprétées et la transcription est du bruit, pas une erreur.
"--model-type=nemo_transducer",
"--port=" + strconv.Itoa(port),
// Le serveur n'a AUCUNE authentification. Il n'écoute donc que la boucle
// locale : sur un conteneur au réseau partagé, l'exposer offrirait la
// transcription à tout le réseau.
"--host=127.0.0.1",
}, nil
}
// asrServerEnv masque tous les GPU, en REMPLAÇANT toute valeur déjà présente.
// Une variable dupliquée laisse le gagnant dépendre de la libc. Le binaire livré
// est CPU de toute façon ; la variable est là pour que ça reste vrai si l'image
// venait à embarquer un build GPU sans qu'on repense à la dictée.
func asrServerEnv(base []string) []string {
const clef = "CUDA_VISIBLE_DEVICES="
out := make([]string, 0, len(base)+1)
for _, v := range base {
if !strings.HasPrefix(v, clef) {
out = append(out, v)
}
}
return append(out, clef)
}
// portLibre demande au système un port disponible puis le relâche. Coder un
// port en dur casserait sur une machine qui l'occupe déjà ; la fenêtre entre
// la fermeture et la reprise par le serveur est négligeable devant le risque
// d'un conflit permanent.
func portLibre() (int, error) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, err
}
defer func() { _ = l.Close() }()
return l.Addr().(*net.TCPAddr).Port, nil
}
// asrMarqueur reconnaît ce qui n'est pas de la parole dictée : le vide, et les
// annotations entre crochets ou parenthèses. Parakeet en produit beaucoup moins
// que whisper, mais le filtre ne coûte rien et un « (silence) » collé dans le
// champ de saisie se remarque tout de suite.
func asrMarqueur(s string) bool {
s = strings.TrimSpace(s)
if s == "" {
return true
}
return (strings.HasPrefix(s, "[") && strings.HasSuffix(s, "]")) ||
(strings.HasPrefix(s, "(") && strings.HasSuffix(s, ")"))
}
// ─── Protocole ───────────────────────────────────────────────────────────────
// asrEnteteOctets : l'en-tête du serveur sherpa-onnx, deux entiers 32 bits en
// petit-boutien — la fréquence d'échantillonnage, puis le NOMBRE D'OCTETS
// d'audio annoncé. Le serveur décode dès qu'il a reçu exactement ce nombre : un
// compte faux, et il attend indéfiniment de quoi finir.
const asrEnteteOctets = 8
// asrTrameMax borne la taille d'une trame WebSocket envoyée. Le serveur
// reconstitue l'utterance à partir du compte annoncé, donc le découpage est
// libre — mais pas la TAILLE : une pile WebSocket refuse par défaut les messages
// au-delà de quelques dizaines de kilooctets et ferme la connexion au lieu de
// répondre. 10 Ko est la valeur qu'emploie le client de référence de
// sherpa-onnx ; on s'aligne dessus plutôt que de parier sur une limite.
const asrTrameMax = 10240
// asrInferSur prend l'adresse du serveur en paramètre : c'est ce qui rend
// l'appel testable sans lancer de processus.
//
// Le protocole, en entier : on ouvre une connexion, on envoie UNE trame binaire
// contenant l'en-tête suivi des échantillons en float32 petit-boutien, et on lit
// UNE trame texte qui porte le résultat en JSON.
func asrInferSur(ctx context.Context, adresse string, wav []byte) (string, error) {
taux, ech, err := wavVersFloat32(wav)
if err != nil {
return "", err
}
if len(ech) == 0 {
return "", nil // rien à transcrire : ce n'est pas une erreur
}
c, _, err := websocket.Dial(ctx, adresse, nil)
if err != nil {
return "", fmt.Errorf("connexion au serveur de dictée : %w", err)
}
defer func() { _ = c.CloseNow() }()
// Une transcription peut légitimement dépasser la taille par défaut acceptée
// en lecture ; on ne borne pas ce que le serveur nous renvoie.
c.SetReadLimit(-1)
corps := make([]byte, asrEnteteOctets+4*len(ech))
binary.LittleEndian.PutUint32(corps[0:4], uint32(taux))
binary.LittleEndian.PutUint32(corps[4:8], uint32(4*len(ech)))
for i, v := range ech {
binary.LittleEndian.PutUint32(corps[asrEnteteOctets+4*i:], math.Float32bits(v))
}
for off := 0; off < len(corps); off += asrTrameMax {
fin := off + asrTrameMax
if fin > len(corps) {
fin = len(corps)
}
if err := c.Write(ctx, websocket.MessageBinary, corps[off:fin]); err != nil {
return "", fmt.Errorf("envoi de l'audio : %w", err)
}
}
_, brut, err := c.Read(ctx)
if err != nil {
return "", fmt.Errorf("lecture du résultat : %w", err)
}
var j struct {
Text string `json:"text"`
}
if err := json.Unmarshal(brut, &j); err != nil {
return "", fmt.Errorf("réponse illisible du serveur de dictée : %w", err)
}
if asrMarqueur(j.Text) {
return "", nil
}
return strings.TrimSpace(j.Text), nil
}
// wavVersFloat32 lit le WAV produit par le navigateur (16 kHz mono) et rend ses
// échantillons normalisés dans [-1, 1], ce qu'attend sherpa-onnx.
//
// On parcourt les blocs plutôt que de sauter à un offset fixe : un WAV n'a pas
// d'en-tête de taille garantie (« LIST », « fact » et consorts s'intercalent
// avant « data »), et l'offset 44 codé en dur transformerait ces octets en
// craquement au début de chaque phrase.
func wavVersFloat32(wav []byte) (taux int, ech []float32, err error) {
if len(wav) < 12 || string(wav[0:4]) != "RIFF" || string(wav[8:12]) != "WAVE" {
return 0, nil, errors.New("audio illisible : ce n'est pas un WAV")
}
var canaux, bits, format int
pos := 12
for pos+8 <= len(wav) {
id := string(wav[pos : pos+4])
taille := int(binary.LittleEndian.Uint32(wav[pos+4 : pos+8]))
corps := pos + 8
if taille < 0 || corps+taille > len(wav) {
// Bloc annoncé plus grand que le fichier : on prend ce qui reste
// plutôt que d'échouer — un enregistrement coupé net reste utile.
taille = len(wav) - corps
}
switch id {
case "fmt ":
if taille < 16 {
return 0, nil, errors.New("audio illisible : bloc fmt tronqué")
}
format = int(binary.LittleEndian.Uint16(wav[corps : corps+2]))
canaux = int(binary.LittleEndian.Uint16(wav[corps+2 : corps+4]))
taux = int(binary.LittleEndian.Uint32(wav[corps+4 : corps+8]))
bits = int(binary.LittleEndian.Uint16(wav[corps+14 : corps+16]))
case "data":
if canaux <= 0 || taux <= 0 {
return 0, nil, errors.New("audio illisible : bloc data avant fmt")
}
ech, err = pcmVersFloat32(wav[corps:corps+taille], format, bits, canaux)
if err != nil {
return 0, nil, err
}
return taux, ech, nil
}
// Les blocs sont alignés sur un nombre pair d'octets.
pos = corps + taille + taille%2
}
return 0, nil, errors.New("audio illisible : aucun bloc data")
}
// pcmVersFloat32 convertit les échantillons bruts. Les canaux sont MOYENNÉS
// plutôt que de garder le premier : un navigateur qui livrerait du stéréo par
// erreur donnerait sinon un canal muet une fois sur deux.
func pcmVersFloat32(data []byte, format, bits, canaux int) ([]float32, error) {
const pcmEntier, pcmFlottant = 1, 3
switch {
case format == pcmEntier && bits == 16:
n := len(data) / 2 / canaux
out := make([]float32, n)
for i := 0; i < n; i++ {
somme := 0.0
for c := 0; c < canaux; c++ {
v := int16(binary.LittleEndian.Uint16(data[2*(i*canaux+c):]))
somme += float64(v) / 32768
}
out[i] = float32(somme / float64(canaux))
}
return out, nil
case format == pcmFlottant && bits == 32:
n := len(data) / 4 / canaux
out := make([]float32, n)
for i := 0; i < n; i++ {
somme := 0.0
for c := 0; c < canaux; c++ {
somme += float64(math.Float32frombits(binary.LittleEndian.Uint32(data[4*(i*canaux+c):])))
}
out[i] = float32(somme / float64(canaux))
}
return out, nil
}
return nil, fmt.Errorf("format audio non géré (format %d, %d bits) — attendu PCM 16 bits", format, bits)
}
// ─── Supervision ─────────────────────────────────────────────────────────────
// asrIdle : au-delà, le serveur s'éteint et rend sa mémoire. Une dictée n'est
// pas un service permanent ; garder un modèle chargé toute la journée pour
// quelques phrases prive la machine de mémoire vive.
const asrIdle = 10 * time.Minute
// asrReady : plafond d'attente au démarrage. Un modèle de 600 Mo lu depuis un
// disque lent prend du temps ; échouer trop tôt afficherait une panne là où il
// n'y a qu'une lenteur.
const asrReady = 120 * time.Second
var wsrvMu sync.Mutex
// wsrvDemarrage : le processus en cours de démarrage, visible SANS le verrou.
// asrStartLocked garde wsrvMu pendant tout le chargement du modèle ; c'est la
// seule poignée qu'un autre appelant (asrShutdownVite) a sur lui pour
// l'interrompre. Nil hors démarrage.
var wsrvDemarrage atomic.Pointer[exec.Cmd]
var wsrv struct {
cmd *exec.Cmd
port int
cfg DictateCfg
log *bytes.Buffer
timer *time.Timer
depuis time.Time
}
// asrEnsure garantit un serveur vivant pour la configuration COURANTE, et
// renvoie son port. Si les réglages ont changé depuis le lancement, l'ancien
// est arrêté : c'est ce qui rend un changement de modèle effectif sans
// redémarrer Loki.
func asrEnsure() (int, error) {
cfg := dictateCfgLoad()
wsrvMu.Lock()
defer wsrvMu.Unlock()
if wsrv.cmd != nil && wsrv.cfg == cfg && wsrv.cmd.ProcessState == nil {
asrTouchLocked()
return wsrv.port, nil
}
asrStopLocked()
return asrStartLocked(cfg)
}
func asrStartLocked(cfg DictateCfg) (int, error) {
if !asrModelPresent(cfg.Model) {
return 0, fmt.Errorf("modèle de dictée absent : %s (à télécharger dans Paramètres → Dictée)", cfg.Model)
}
bin := asrServerBin()
if bin == "" {
return 0, fmt.Errorf("serveur de dictée introuvable (image à reconstruire)")
}
port, err := portLibre()
if err != nil {
return 0, fmt.Errorf("aucun port libre pour le serveur de dictée : %w", err)
}
args, err := asrServerArgs(cfg, port)
if err != nil {
return 0, err
}
log := &bytes.Buffer{}
cmd := exec.Command(bin, args...)
cmd.Env = asrServerEnv(os.Environ())
cmd.Stdout, cmd.Stderr = log, log
if err := cmd.Start(); err != nil {
return 0, fmt.Errorf("démarrage du serveur de dictée : %w", err)
}
wsrv.cmd, wsrv.port, wsrv.cfg = cmd, port, cfg
wsrv.log, wsrv.depuis = log, time.Now()
wsrvDemarrage.Store(cmd)
err = asrAttendPret(cmd, port, log)
wsrvDemarrage.Store(nil)
if err != nil {
asrStopLocked()
return 0, err
}
asrTouchLocked()
return port, nil
}
// asrAttendPret sonde le serveur jusqu'à ce qu'il accepte une connexion. Le
// serveur sherpa-onnx construit son moteur AVANT d'ouvrir sa socket : le simple
// fait qu'elle réponde signifie donc que le modèle est chargé — inutile de lui
// envoyer un audio de test.
//
// Si le processus meurt entre-temps, on rapporte COMMENT il est mort : sur une
// mort par signal, la dernière ligne du journal est celle d'avant le coup fatal —
// elle ressemble à une explication sans en être une.
func asrAttendPret(cmd *exec.Cmd, port int, log *bytes.Buffer) error {
fin := time.Now().Add(asrReady)
adr := "127.0.0.1:" + strconv.Itoa(port)
mort := make(chan error, 1)
go func() { mort <- cmd.Wait() }()
for time.Now().Before(fin) {
select {
case err := <-mort:
msg := asrExitReason(err)
if l := lastLine(log.String()); l != "" {
msg += " — dernière sortie : " + l
}
return fmt.Errorf("le serveur de dictée s'est arrêté au démarrage : %s", msg)
case <-time.After(200 * time.Millisecond):
}
conn, err := net.DialTimeout("tcp", adr, 2*time.Second)
if err == nil {
_ = conn.Close()
return nil
}
}
return fmt.Errorf("le serveur de dictée n'a pas répondu en %s", asrReady)
}
func asrTouchLocked() {
if wsrv.timer != nil {
wsrv.timer.Stop()
}
wsrv.timer = time.AfterFunc(asrIdle, asrShutdown)
}
func asrStopLocked() {
if wsrv.timer != nil {
wsrv.timer.Stop()
wsrv.timer = nil
}
if wsrv.cmd != nil && wsrv.cmd.Process != nil {
_ = wsrv.cmd.Process.Kill()
}
wsrv.cmd, wsrv.port, wsrv.log = nil, 0, nil
wsrv.cfg = DictateCfg{}
}
// asrShutdown arrête le serveur. Appelé par la minuterie d'inactivité et à
// chaque changement de réglages — sans ça, le serveur continuerait de tourner
// avec l'ancien modèle et le nouveau réglage semblerait sans effet.
func asrShutdown() {
wsrvMu.Lock()
defer wsrvMu.Unlock()
asrStopLocked()
}
// asrShutdownVite arrête le serveur sans jamais attendre derrière un démarrage.
// asrShutdown prend wsrvMu, et asrEnsure le garde pendant tout le chargement du
// modèle — jusqu'à asrReady (deux minutes) depuis un disque lent. Appelé par un
// geste de l'interface, ce blocage ferait abandonner le navigateur alors que le
// moteur, lui, est déjà arrêté.
//
// Si le verrou est pris, on tue le processus en train de démarrer : sa mort fait
// sortir asrAttendPret, le verrou se libère, et on finit le ménage par la voie
// normale. La dictée qui attendait reçoit une erreur de démarrage — c'est
// l'utilisateur qui vient de demander la place, pas une panne.
func asrShutdownVite() {
for i := 0; i < 20; i++ {
if wsrvMu.TryLock() {
asrStopLocked()
wsrvMu.Unlock()
return
}
if cmd := wsrvDemarrage.Load(); cmd != nil && cmd.Process != nil {
_ = cmd.Process.Kill()
}
time.Sleep(100 * time.Millisecond)
}
}
// asrInfer garantit le serveur puis lui soumet le WAV.
func asrInfer(ctx context.Context, wav []byte) (string, error) {
port, err := asrEnsure()
if err != nil {
return "", err
}
return asrInferSur(ctx, "ws://127.0.0.1:"+strconv.Itoa(port), wav)
}
func asrEtat() map[string]any {
cfg := dictateCfgLoad()
wsrvMu.Lock()
defer wsrvMu.Unlock()
etat := map[string]any{
"actif": wsrv.cmd != nil,
"modele": cfg.Model,
"present": asrModelPresent(cfg.Model),
// Le moteur livré est le build statique CPU (voir asrServerBin) : le dire
// vaut mieux que laisser chercher un réglage de carte qui n'existe pas.
"device": "cpu",
}
if wsrv.cmd != nil {
etat["depuis_s"] = int(time.Since(wsrv.depuis).Seconds())
}
return etat
}