Files
Loki/internal/loki/parity_dictate_test.go
T
Claude 46bfab3343 Lot 9 : dictée vocale tenue par la façade (LOKI_DICTATE=rust)
La façade sert /api/transcribe et /api/dictate/* quand LOKI_DICTATE=rust
(expérimental ; le Go reste le défaut) et supervise elle-même le serveur
de transcription sherpa-onnx : lancé à la demande sur la boucle locale,
GPU masqués et sans le jeton interne, prêt dès que sa socket répond, mort
au démarrage expliquée, éteint après dix minutes d'inactivité ou quand
les réglages changent. Modèles Parakeet téléchargés et décompressés au fil
de l'eau (tar.bz2, entrées hors du dossier refusées, liens ignorés),
protocole WebSocket identique (en-tête de 8 octets, trames de 10 Kio).

Côté Go, facade_dictate.go remet ces routes à la façade quand elles
arrivent par le tunnel du relais, et asrShutdownVite (libération de la
VRAM, optimiseur) lui demande l'arrêt par l'API interne.

Parité : parity_dictate_test.go tire du Go la lecture de treize WAV, les
chemins d'archive, réglages, catalogue, arguments et treize appels de
route, rejoués par tests/dictate.rs ; supervision testée contre un faux
sherpa-onnx qui parle le protocole.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 16:17:29 +00:00

267 lines
9.1 KiB
Go

package loki
import (
"encoding/binary"
"encoding/json"
"math"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// parity_dictate_test.go — journal de référence de la dictée vocale (lot 9 de
// la migration Rust) : lecture des WAV, chemins d'archive, marqueurs,
// réglages, catalogue, arguments du serveur, et les routes qui ne lancent
// rien (réglages, catalogue, état, refus). Rangé dans
// rust/loki-rs/tests/parity_dictate/dictate.json ; tests/dictate.rs le rejoue.
// LOKI_HOME réécrit en {{HOME}}.
//
// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteDictee
const parityDictateFile = "../../rust/loki-rs/tests/parity_dictate/dictate.json"
type parityWav struct {
Name string `json:"name"`
// WAV en octets (JSON : base64).
In []byte `json:"in"`
Rate int `json:"rate,omitempty"`
Ech []float32 `json:"ech,omitempty"`
Err string `json:"err,omitempty"`
}
type parityDictateCall struct {
Method string `json:"method"`
Path string `json:"path"`
Body string `json:"body,omitempty"`
Status int `json:"status"`
Out any `json:"out"`
}
type parityDictateFixture struct {
Wav []parityWav `json:"wav"`
SafeRel []struct {
In string `json:"in"`
Out string `json:"out,omitempty"`
Err string `json:"err,omitempty"`
} `json:"safe_rel"`
Marker map[string]bool `json:"marker"`
Defaults []map[string]string `json:"defaults"`
LastLine map[string]string `json:"last_line"`
Args []string `json:"args"`
ArgsErr string `json:"args_err"`
URLs map[string]string `json:"urls"`
Calls []parityDictateCall `json:"calls"`
Present map[string]bool `json:"present"`
}
// parityWavOf : un WAV avec les blocs donnés après l'en-tête RIFF/WAVE.
func parityWavOf(chunks ...[]byte) []byte {
b := []byte("RIFF\x00\x00\x00\x00WAVE")
for _, c := range chunks {
b = append(b, c...)
}
binary.LittleEndian.PutUint32(b[4:8], uint32(len(b)-8))
return b
}
func parityChunk(id string, body []byte) []byte {
h := make([]byte, 8)
copy(h, id)
binary.LittleEndian.PutUint32(h[4:], uint32(len(body)))
out := append(h, body...)
if len(body)%2 == 1 {
out = append(out, 0)
}
return out
}
func parityFmt(format, canaux, taux, bits int) []byte {
b := make([]byte, 16)
binary.LittleEndian.PutUint16(b[0:], uint16(format))
binary.LittleEndian.PutUint16(b[2:], uint16(canaux))
binary.LittleEndian.PutUint32(b[4:], uint32(taux))
binary.LittleEndian.PutUint32(b[8:], uint32(taux*canaux*bits/8))
binary.LittleEndian.PutUint16(b[12:], uint16(canaux*bits/8))
binary.LittleEndian.PutUint16(b[14:], uint16(bits))
return parityChunk("fmt ", b)
}
func parityPCM16(v ...int16) []byte {
b := make([]byte, 2*len(v))
for i, x := range v {
binary.LittleEndian.PutUint16(b[2*i:], uint16(x))
}
return b
}
func TestPariteDictee(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("journal tiré sous Unix")
}
home := testHome(t)
if r, err := filepath.EvalSymlinks(home); err == nil {
home = r
}
t.Setenv("LOKI_HOME", home)
norm := func(s string) string { return strings.ReplaceAll(s, home, "{{HOME}}") }
var fx parityDictateFixture
f32 := make([]byte, 12)
for i, v := range []float32{0.25, -1, 0.5} {
binary.LittleEndian.PutUint32(f32[4*i:], math.Float32bits(v))
}
cases := []struct {
name string
in []byte
}{
{"mono 16 bits", parityWavOf(parityFmt(1, 1, 16000, 16), parityChunk("data", parityPCM16(0, 16384, -16384, 32767, -32768)))},
{"bloc LIST intercalé", parityWavOf(parityChunk("LIST", []byte("INFOxyz")), parityFmt(1, 1, 16000, 16), parityChunk("data", parityPCM16(100, -100)))},
{"stéréo moyenné", parityWavOf(parityFmt(1, 2, 48000, 16), parityChunk("data", parityPCM16(0, 32767, -32768, 32767)))},
{"flottant 32 bits", parityWavOf(parityFmt(3, 1, 16000, 32), parityChunk("data", f32))},
{"data tronqué", parityWavOf(parityFmt(1, 1, 16000, 16), parityChunk("data", parityPCM16(1, 2, 3)))[:12+24+8+4]},
{"data impair", parityWavOf(parityFmt(1, 1, 8000, 16), parityChunk("data", []byte{1, 2, 3}))},
{"data vide", parityWavOf(parityFmt(1, 1, 16000, 16), parityChunk("data", nil))},
{"24 bits", parityWavOf(parityFmt(1, 1, 16000, 24), parityChunk("data", make([]byte, 6)))},
{"data avant fmt", parityWavOf(parityChunk("data", parityPCM16(1)), parityFmt(1, 1, 16000, 16))},
{"fmt tronqué", parityWavOf(parityChunk("fmt ", make([]byte, 8)))},
{"sans data", parityWavOf(parityFmt(1, 1, 16000, 16))},
{"pas un WAV", []byte("ceci n'est pas un fichier audio du tout")},
{"vide", []byte{}},
}
for _, c := range cases {
w := parityWav{Name: c.name, In: c.in}
taux, ech, err := wavVersFloat32(c.in)
if err != nil {
w.Err = err.Error()
} else {
w.Rate, w.Ech = taux, ech
if w.Ech == nil {
w.Ech = []float32{}
}
}
fx.Wav = append(fx.Wav, w)
}
for _, in := range []string{"", "racine", "racine/", "racine/encoder.onnx", "racine/sous/tokens.txt",
"/abs/x", "racine/../x", `racine\a\b.txt`, " racine/a ", "..", "a/b/../c"} {
out, err := asrSafeRel(in)
c := struct {
In string `json:"in"`
Out string `json:"out,omitempty"`
Err string `json:"err,omitempty"`
}{In: in, Out: out}
if err != nil {
c.Err = err.Error()
}
fx.SafeRel = append(fx.SafeRel, c)
}
fx.Marker = map[string]bool{}
for _, s := range []string{"", " ", "[musique]", "(silence)", " (rires) ", "bonjour", "[a] b", "(x"} {
fx.Marker[s] = asrMarqueur(s)
}
for _, c := range []DictateCfg{{}, {Model: "ggml-base.bin"}, {Model: "parakeet-tdt-0.6b-v2", Reactivity: "court"}} {
d := c.withDefauts()
fx.Defaults = append(fx.Defaults, map[string]string{"model": c.Model, "reactivity": c.Reactivity,
"out_model": d.Model, "out_reactivity": d.Reactivity})
}
fx.LastLine = map[string]string{}
for _, s := range []string{"", "a\nb\n\n ", "un seul", " \n x \n"} {
fx.LastLine[s] = lastLine(s)
}
args, _ := asrServerArgs(DictateCfg{Model: "parakeet-tdt-0.6b-v3"}, 4567)
for _, a := range args {
fx.Args = append(fx.Args, norm(a))
}
if _, err := asrServerArgs(DictateCfg{Model: "x"}, 1); err != nil {
fx.ArgsErr = err.Error()
}
fx.URLs = map[string]string{}
for id := range asrCatalogue {
fx.URLs[id] = asrModelURLFor(id)
}
fx.URLs["inconnu"] = asrModelURLFor("inconnu")
// v2 installé (quatre fichiers non vides), v3 à moitié.
for _, f := range asrFichiers {
p := asrModelFile("parakeet-tdt-0.6b-v2", f)
_ = os.MkdirAll(filepath.Dir(p), 0o755)
_ = os.WriteFile(p, []byte("x"), 0o644)
}
p := asrModelFile("parakeet-tdt-0.6b-v3", "encoder.int8.onnx")
_ = os.MkdirAll(filepath.Dir(p), 0o755)
_ = os.WriteFile(p, []byte("x"), 0o644)
_ = os.WriteFile(asrModelFile("parakeet-tdt-0.6b-v3", "tokens.txt"), nil, 0o644)
fx.Present = map[string]bool{}
for id := range asrCatalogue {
fx.Present[id] = asrModelPresent(id)
}
fx.Present["inconnu"] = asrModelPresent("inconnu")
call := func(method, path, body string) parityDictateCall {
w := httptest.NewRecorder()
r := httptest.NewRequest(method, path, strings.NewReader(body))
switch path {
case "/api/dictate/config":
handleDictateConfig(w, r)
case "/api/dictate/models":
handleDictateModels(w, r)
case "/api/dictate/models/download":
handleDictateDownload(w, r)
case "/api/dictate/state":
handleDictateState(w, r)
case "/api/transcribe":
handleTranscribe(w, r)
default:
t.Fatalf("route inattendue %s", path)
}
var out any
if err := json.Unmarshal([]byte(norm(w.Body.String())), &out); err != nil {
t.Fatalf("%s %s : %v (%s)", method, path, err, w.Body.String())
}
return parityDictateCall{Method: method, Path: path, Body: body, Status: w.Code, Out: out}
}
for _, c := range []struct{ m, p, b string }{
{"GET", "/api/dictate/config", ""},
{"POST", "/api/dictate/config", `pas du json`},
{"POST", "/api/dictate/config", `{"model":"ggml-base.bin"}`},
{"POST", "/api/dictate/config", `{"model":"parakeet-tdt-0.6b-v2"}`},
{"GET", "/api/dictate/config", ""},
{"POST", "/api/dictate/config", `{"model":"parakeet-tdt-0.6b-v3","reactivity":"long","autre":1}`},
{"GET", "/api/dictate/config", ""},
{"GET", "/api/dictate/models", ""},
{"GET", "/api/dictate/models/download", ""},
{"POST", "/api/dictate/models/download", `{"id":" inconnu "}`},
{"POST", "/api/dictate/models/download", `x`},
{"GET", "/api/dictate/state", ""},
{"POST", "/api/transcribe", `trop court`},
} {
fx.Calls = append(fx.Calls, call(c.m, c.p, c.b))
}
got, err := parityJSON(fx)
if err != nil {
t.Fatal(err)
}
if os.Getenv("LOKI_PARITY_UPDATE") == "1" {
if err := os.MkdirAll(filepath.Dir(parityDictateFile), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(parityDictateFile, got, 0o644); err != nil {
t.Fatal(err)
}
return
}
want, err := os.ReadFile(parityDictateFile)
if err != nil {
t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err)
}
if string(got) != string(want) {
t.Errorf("la dictée ne produit plus le journal rangé dans %s.\n"+
"Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteDictee,\n"+
"puis cargo test --test dictate dit si le port Rust suit.\n--- obtenu ---\n%s", parityDictateFile, got)
}
}