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:
MichaelandClaude Opus 5 committed 2026-09-07 22:34:57 +02:00
1 parent 17f8fa8f34
commit 54468b3336
17 files changed
+1807 -894

No files matched your search

+309 -100
View File
@@ -2,99 +2,130 @@ package loki
import (
"context"
"encoding/binary"
"encoding/json"
"math"
"net"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"github.com/coder/websocket"
)
func argVal(args []string, flag string) (string, bool) {
for i, a := range args {
if a == flag && i+1 < len(args) {
return args[i+1], true
// wavTest fabrique un WAV 16 kHz mono PCM 16 bits, comme celui que le
// navigateur envoie.
func wavTest(ech []int16, taux int) []byte {
data := make([]byte, 2*len(ech))
for i, v := range ech {
binary.LittleEndian.PutUint16(data[2*i:], uint16(v))
}
b := make([]byte, 0, 44+len(data))
ajout32 := func(v uint32) {
var x [4]byte
binary.LittleEndian.PutUint32(x[:], v)
b = append(b, x[:]...)
}
ajout16 := func(v uint16) {
var x [2]byte
binary.LittleEndian.PutUint16(x[:], v)
b = append(b, x[:]...)
}
b = append(b, "RIFF"...)
ajout32(uint32(36 + len(data)))
b = append(b, "WAVE"...)
b = append(b, "fmt "...)
ajout32(16)
ajout16(1) // PCM entier
ajout16(1) // mono
ajout32(uint32(taux))
ajout32(uint32(taux * 2))
ajout16(2) // alignement de bloc
ajout16(16) // bits par échantillon
b = append(b, "data"...)
ajout32(uint32(len(data)))
return append(b, data...)
}
func TestServerArgsFormeCleValeur(t *testing.T) {
testHome(t)
args, err := asrServerArgs(DictateCfg{Model: "parakeet-tdt-0.6b-v3"}, 4242)
if err != nil {
t.Fatal(err)
}
joint := strings.Join(args, " ")
// sherpa-onnx ne reconnaît QUE --clef=valeur. La forme séparée par une
// espace le laisse démarrer sans modèle, sans se plaindre.
for _, a := range args {
if !strings.HasPrefix(a, "--") || !strings.Contains(a, "=") {
t.Errorf("argument %q : sherpa-onnx exige la forme --clef=valeur", a)
}
}
for _, attendu := range []string{"--model-type=nemo_transducer", "--host=127.0.0.1", "--port=4242"} {
if !strings.Contains(joint, attendu) {
t.Errorf("argument manquant : %s\nobtenu : %s", attendu, joint)
}
}
// Les quatre fichiers du modèle, sinon le serveur démarre puis meurt.
for _, f := range asrFichiers {
if !strings.Contains(joint, f) {
t.Errorf("le fichier %s du modèle n'est pas passé au serveur", f)
}
}
return "", false
}
func TestServerArgs(t *testing.T) {
func TestServerArgsRefuseModeleInconnu(t *testing.T) {
testHome(t)
c := DictateCfg{Model: "small-q5_1", Lang: "fr", Device: "cpu"}.withDefauts()
args, err := whisperServerArgs(c, 8091)
if err != nil {
t.Fatalf("construction des arguments : %v", err)
}
if v, ok := argVal(args, "-m"); !ok || !strings.HasSuffix(v, "ggml-small-q5_1.bin") {
t.Errorf("-m = %q, doit pointer sur le .bin du modèle choisi", v)
}
if v, ok := argVal(args, "-l"); !ok || v != "fr" {
t.Errorf("-l = %q, attendu fr", v)
}
if v, ok := argVal(args, "--port"); !ok || v != "8091" {
t.Errorf("--port = %q, attendu 8091", v)
}
// whisper-server n'a aucune authentification : l'exposer sur 0.0.0.0
// ouvrirait la transcription à tout le réseau.
if v, ok := argVal(args, "--host"); !ok || v != "127.0.0.1" {
t.Errorf("--host = %q, attendu 127.0.0.1", v)
if _, err := asrServerArgs(DictateCfg{Model: "nawak"}, 1234); err == nil {
t.Error("un modèle hors catalogue doit être refusé avant le lancement")
}
}
func TestServerArgsModeleInconnu(t *testing.T) {
testHome(t)
if _, err := whisperServerArgs(DictateCfg{Model: "nawak", Lang: "fr", Device: "cpu"}, 8091); err == nil {
t.Error("un modèle hors catalogue doit produire une erreur, pas une commande avec -m vide")
// Le serveur ne doit jamais prendre une carte : le binaire livré est le build
// CPU, et la VRAM appartient au moteur de chat.
func TestServerEnvMasqueLesGPU(t *testing.T) {
got := asrServerEnv([]string{"PATH=/bin", "CUDA_VISIBLE_DEVICES=0,1", "HOME=/root"})
n := 0
for _, v := range got {
if strings.HasPrefix(v, "CUDA_VISIBLE_DEVICES=") {
n++
if v != "CUDA_VISIBLE_DEVICES=" {
t.Errorf("CUDA_VISIBLE_DEVICES = %q, attendu vide", v)
}
}
}
}
func TestServerEnvGPU(t *testing.T) {
base := []string{"PATH=/usr/bin", "CUDA_VISIBLE_DEVICES=7", "HOME=/root"}
env := whisperServerEnv(DictateCfg{Device: "1"}, base)
if !contientExact(env, "CUDA_VISIBLE_DEVICES=1") {
t.Errorf("env = %v, doit contenir CUDA_VISIBLE_DEVICES=1", env)
}
// L'ancienne valeur doit être REMPLACÉE, pas doublée : avec deux
// occurrences, le gagnant dépend de l'implémentation — le choix de
// l'utilisateur ne doit pas dépendre de ça.
if n := compte(env, "CUDA_VISIBLE_DEVICES="); n != 1 {
// Une variable en double laisse le gagnant dépendre de la libc.
if n != 1 {
t.Errorf("%d occurrences de CUDA_VISIBLE_DEVICES, attendu exactement 1", n)
}
if !contientExact(env, "PATH=/usr/bin") {
if !contientTout(got, "PATH=/bin", "HOME=/root") {
t.Error("le reste de l'environnement doit être conservé")
}
env = whisperServerEnv(DictateCfg{Device: "cpu"}, base)
if !contientExact(env, "CUDA_VISIBLE_DEVICES=") {
t.Errorf("env = %v, en mode CPU la variable doit être posée VIDE (et non retirée) pour masquer tous les GPU", env)
}
}
func contientExact(l []string, s string) bool {
for _, v := range l {
if v == s {
return true
func contientTout(l []string, vals ...string) bool {
for _, v := range vals {
trouve := false
for _, x := range l {
if x == v {
trouve = true
break
}
}
if !trouve {
return false
}
}
return false
}
func compte(l []string, prefixe string) int {
n := 0
for _, v := range l {
if strings.HasPrefix(v, prefixe) {
n++
}
}
return n
return true
}
func TestPortLibre(t *testing.T) {
p, err := portLibre()
if err != nil {
t.Fatalf("portLibre : %v", err)
t.Fatal(err)
}
if p < 1024 || p > 65535 {
t.Errorf("port = %d, hors de la plage utilisable", p)
@@ -106,40 +137,167 @@ func TestPortLibre(t *testing.T) {
_ = l.Close()
}
func TestInferParseLaReponse(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/inference" {
t.Errorf("chemin appelé = %q, attendu /inference", r.URL.Path)
}
if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "multipart/form-data") {
t.Errorf("Content-Type = %q, whisper-server attend du multipart", ct)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"text":" bonjour ceci est un test "}`))
}))
defer srv.Close()
// ─── Conversion WAV → float32 ────────────────────────────────────────────────
txt, err := whisperInferSur(context.Background(), srv.URL, []byte("RIFFfaux"))
// L'offset 44 codé en dur est le piège classique : un WAV n'a pas d'en-tête de
// taille garantie, et des blocs (LIST, fact…) s'intercalent avant `data`. Les
// prendre pour de l'audio produit un craquement au début de chaque phrase.
func TestWavIgnoreLesBlocsIntercales(t *testing.T) {
base := wavTest([]int16{0, 16384, -16384, 32767}, 16000)
// Insère un bloc « LIST » de 6 octets juste après l'en-tête RIFF/WAVE.
extra := append([]byte("LIST"), 6, 0, 0, 0, 'I', 'N', 'F', 'O', 'x', 'y')
avec := append(append(append([]byte{}, base[:12]...), extra...), base[12:]...)
binary.LittleEndian.PutUint32(avec[4:8], uint32(len(avec)-8))
taux, ech, err := wavVersFloat32(avec)
if err != nil {
t.Fatalf("whisperInferSur : %v", err)
t.Fatalf("wavVersFloat32 : %v", err)
}
if taux != 16000 {
t.Errorf("taux = %d, attendu 16000", taux)
}
if len(ech) != 4 {
t.Fatalf("%d échantillons, attendu 4 — un bloc intercalé a été pris pour de l'audio", len(ech))
}
if ech[0] != 0 || math.Abs(float64(ech[1])-0.5) > 0.001 {
t.Errorf("échantillons mal normalisés : %v", ech)
}
}
func TestWavRefuseCeQuiNEnEstPas(t *testing.T) {
for nom, in := range map[string][]byte{
"vide": {},
"pas un WAV": []byte("ceci n'est pas un fichier audio du tout"),
"sans data": append([]byte("RIFF\x00\x00\x00\x00WAVE"), "fmt "...),
} {
if _, _, err := wavVersFloat32(in); err == nil {
t.Errorf("%s : accepté alors qu'il est illisible", nom)
}
}
}
// Le stéréo doit être MOYENNÉ, pas réduit au premier canal : un navigateur qui
// livrerait deux canaux donnerait sinon un silence une fois sur deux.
func TestPcmStereoMoyenne(t *testing.T) {
data := make([]byte, 8) // deux trames stéréo
binary.LittleEndian.PutUint16(data[0:], uint16(int16(0)))
binary.LittleEndian.PutUint16(data[2:], uint16(int16(32767)))
binary.LittleEndian.PutUint16(data[4:], uint16(0x8000)) // -32768
binary.LittleEndian.PutUint16(data[6:], uint16(int16(32767)))
ech, err := pcmVersFloat32(data, 1, 16, 2)
if err != nil {
t.Fatal(err)
}
if len(ech) != 2 {
t.Fatalf("%d échantillons, attendu 2", len(ech))
}
if math.Abs(float64(ech[0])-0.5) > 0.01 {
t.Errorf("premier échantillon = %v, attendu la moyenne des deux canaux (~0,5)", ech[0])
}
if math.Abs(float64(ech[1])) > 0.01 {
t.Errorf("second échantillon = %v, attendu ~0 (canaux opposés)", ech[1])
}
}
func TestPcmFormatNonGere(t *testing.T) {
if _, err := pcmVersFloat32(make([]byte, 8), 1, 24, 1); err == nil {
t.Error("un PCM 24 bits doit être refusé explicitement, pas décodé de travers")
}
}
// ─── Protocole WebSocket ─────────────────────────────────────────────────────
// serveurASRFactice rejoue le protocole de sherpa-onnx : il lit l'en-tête et
// les échantillons, puis répond en JSON. Il vérifie au passage que le client
// annonce le BON nombre d'octets — un compte faux et le vrai serveur attend
// indéfiniment de quoi finir, sans jamais répondre.
func serveurASRFactice(t *testing.T, texte string) (adresse string, arret func(), recu *struct {
Taux int
N int
}) {
t.Helper()
recu = &struct {
Taux int
N int
}{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := websocket.Accept(w, r, &websocket.AcceptOptions{OriginPatterns: []string{"*"}})
if err != nil {
return
}
defer func() { _ = c.CloseNow() }()
// Le vrai serveur reconstitue l'utterance a partir du compte annonce :
// il ne borne pas la taille des trames recues, ce test non plus.
c.SetReadLimit(-1)
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
var buf []byte
for {
_, b, err := c.Read(ctx)
if err != nil {
return
}
buf = append(buf, b...)
if len(buf) < asrEnteteOctets {
continue
}
recu.Taux = int(binary.LittleEndian.Uint32(buf[0:4]))
attendu := int(binary.LittleEndian.Uint32(buf[4:8]))
if len(buf)-asrEnteteOctets < attendu {
continue
}
recu.N = attendu / 4
_ = c.Write(ctx, websocket.MessageText, []byte(`{"text":"`+texte+`"}`))
return
}
}))
return "ws" + strings.TrimPrefix(srv.URL, "http"), srv.Close, recu
}
func TestInferEnvoieLeBonEnTete(t *testing.T) {
adresse, arret, recu := serveurASRFactice(t, " bonjour ceci est un test ")
defer arret()
wav := wavTest(make([]int16, 800), 16000)
txt, err := asrInferSur(context.Background(), adresse, wav)
if err != nil {
t.Fatalf("asrInferSur : %v", err)
}
if txt != "bonjour ceci est un test" {
t.Errorf("texte = %q, les espaces de bord doivent être retirés", txt)
}
if recu.Taux != 16000 {
t.Errorf("taux annoncé = %d, attendu 16000", recu.Taux)
}
if recu.N != 800 {
t.Errorf("%d échantillons annoncés, attendu 800 — un compte faux fige le serveur", recu.N)
}
}
// Sur du silence, whisper renvoie ses marqueurs internes. Les laisser passer
// les collerait tels quels dans le champ de saisie — c'est ce qui est arrivé
// avec [BLANK_AUDIO].
// Un audio plus gros qu'une trame doit arriver ENTIER : le serveur recolle les
// morceaux à partir du compte annoncé, encore faut-il tout envoyer.
func TestInferDecoupeLesGrosEnvois(t *testing.T) {
adresse, arret, recu := serveurASRFactice(t, "ok")
defer arret()
n := asrTrameMax // > une trame une fois converti en float32
if _, err := asrInferSur(context.Background(), adresse, wavTest(make([]int16, n), 16000)); err != nil {
t.Fatalf("asrInferSur : %v", err)
}
if recu.N != n {
t.Errorf("%d échantillons reçus, attendu %d", recu.N, n)
}
}
// Sur du silence, le moteur peut rendre une annotation plutôt qu'un texte. La
// laisser passer la collerait telle quelle dans le champ de saisie.
func TestInferFiltreLesMarqueurs(t *testing.T) {
for _, marqueur := range []string{"[BLANK_AUDIO]", "(silence)", "[SOUND]", " [ Silence ] ", " "} {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"text":"` + marqueur + `"}`))
}))
txt, err := whisperInferSur(context.Background(), srv.URL, []byte("RIFFfaux"))
srv.Close()
adresse, arret, _ := serveurASRFactice(t, marqueur)
txt, err := asrInferSur(context.Background(), adresse, wavTest(make([]int16, 100), 16000))
arret()
if err != nil {
t.Fatalf("whisperInferSur(%q) : %v", marqueur, err)
t.Fatalf("asrInferSur(%q) : %v", marqueur, err)
}
if txt != "" {
t.Errorf("marqueur %q rendu comme texte %q, attendu vide", marqueur, txt)
@@ -147,25 +305,65 @@ func TestInferFiltreLesMarqueurs(t *testing.T) {
}
}
func TestInferErreurHTTP(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(500)
_, _ = w.Write([]byte("boom"))
}))
defer srv.Close()
if _, err := whisperInferSur(context.Background(), srv.URL, []byte("RIFFfaux")); err == nil {
t.Error("un 500 de whisper-server doit remonter une erreur")
func TestInferServeurInjoignable(t *testing.T) {
p, err := portLibre()
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err = asrInferSur(ctx, "ws://127.0.0.1:"+strconv.Itoa(p), wavTest(make([]int16, 100), 16000))
if err == nil {
t.Error("un serveur injoignable doit remonter une erreur")
}
}
// Un modèle absent doit être annoncé comme tel, pas produire un lancement de
// whisper-server voué à mourir sur un fichier introuvable.
func TestInferReponseIllisible(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := websocket.Accept(w, r, &websocket.AcceptOptions{OriginPatterns: []string{"*"}})
if err != nil {
return
}
defer func() { _ = c.CloseNow() }()
// Le vrai serveur reconstitue l'utterance a partir du compte annonce :
// il ne borne pas la taille des trames recues, ce test non plus.
c.SetReadLimit(-1)
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if _, _, err := c.Read(ctx); err != nil {
return
}
_ = c.Write(ctx, websocket.MessageText, []byte("ceci n'est pas du JSON"))
}))
defer srv.Close()
_, err := asrInferSur(context.Background(), "ws"+strings.TrimPrefix(srv.URL, "http"), wavTest(make([]int16, 100), 16000))
if err == nil {
t.Error("une réponse illisible doit remonter une erreur")
}
}
// Un audio vide n'est pas une panne : on rend une transcription vide sans même
// ouvrir de connexion.
func TestInferAudioVide(t *testing.T) {
txt, err := asrInferSur(context.Background(), "ws://127.0.0.1:1", wavTest(nil, 16000))
if err != nil {
t.Fatalf("un WAV sans échantillon ne doit pas être une erreur : %v", err)
}
if txt != "" {
t.Errorf("texte = %q, attendu vide", txt)
}
}
// ─── Supervision ─────────────────────────────────────────────────────────────
// Un modèle absent doit être annoncé comme tel, pas produire un lancement du
// serveur voué à mourir sur un fichier introuvable.
func TestEnsureModeleAbsent(t *testing.T) {
testHome(t)
if err := dictateCfgSave(DictateCfg{Model: "small-q5_1", Lang: "fr", Device: "cpu", Reactivity: "moyen"}); err != nil {
if err := dictateCfgSave(DictateCfg{Model: "parakeet-tdt-0.6b-v3", Reactivity: "moyen"}); err != nil {
t.Fatal(err)
}
_, err := whisperEnsure()
_, err := asrEnsure()
if err == nil {
t.Fatal("un modèle absent doit produire une erreur")
}
@@ -176,13 +374,24 @@ func TestEnsureModeleAbsent(t *testing.T) {
func TestEtatSansServeur(t *testing.T) {
testHome(t)
e := whisperEtat()
e := asrEtat()
if e["actif"] != false {
t.Errorf("actif = %v, attendu false sans serveur lancé", e["actif"])
}
for _, clef := range []string{"modele", "present", "device", "note"} {
for _, clef := range []string{"modele", "present", "device"} {
if _, ok := e[clef]; !ok {
t.Errorf("clé %q manquante — l'UI en a besoin", clef)
}
}
// Le binaire livré est CPU : l'état ne doit pas laisser croire à une carte.
if e["device"] != "cpu" {
t.Errorf("device = %v, attendu \"cpu\"", e["device"])
}
}
func TestEtatSerialisable(t *testing.T) {
testHome(t)
if _, err := json.Marshal(asrEtat()); err != nil {
t.Fatalf("l'état doit être sérialisable pour /api/dictate/state : %v", err)
}
}