mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-16 19:59:56 +02:00
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
This commit is contained in:
21 files changed
+2699
-19
No files matched your search
@@ -414,6 +414,10 @@ func asrShutdown() {
|
||||
// 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() {
|
||||
// Dictée tenue par la façade (LOKI_DICTATE=rust) : son serveur est le sien.
|
||||
if facadeDictateStop() {
|
||||
return
|
||||
}
|
||||
for i := 0; i < 20; i++ {
|
||||
if wsrvMu.TryLock() {
|
||||
asrStopLocked()
|
||||
|
||||
@@ -141,7 +141,8 @@ var facadeChatRoutes = map[string]bool{
|
||||
}
|
||||
|
||||
// withFacadeChat remet la requête à la façade quand elle tient la
|
||||
// conversation — ou les routes des modèles (facade_models.go).
|
||||
// conversation — ou les routes des modèles (facade_models.go), ou la dictée
|
||||
// (facade_dictate.go).
|
||||
// Posé APRÈS requireWebAuth : seule une requête authentifiée ici part, avec le
|
||||
// jeton interne qui en tient lieu là-bas.
|
||||
func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc {
|
||||
@@ -150,6 +151,8 @@ func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc {
|
||||
case facadeChatRoutes[path]:
|
||||
case facadeModelRoutes[path]:
|
||||
held, unavailable = facadeModels, "modèles indisponibles : "
|
||||
case facadeDictateRoutes[path]:
|
||||
held, unavailable = facadeDictate, "dictée indisponible : "
|
||||
default:
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -379,3 +379,36 @@ func TestFacadeTelechargementsGardeLesPart(t *testing.T) {
|
||||
t.Fatalf(".part orphelin gardé : %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// LOKI_DICTATE=rust : les routes de la dictée partent à la façade, et arrêter
|
||||
// la dictée (libération de la VRAM, optimiseur) le lui demande au lieu de
|
||||
// toucher au serveur de ce process. Sans la variable, rien ne change.
|
||||
func TestFacadeDictee(t *testing.T) {
|
||||
testHome(t)
|
||||
f := demarrerFacade(t)
|
||||
t.Setenv(facadeChatEnv, "")
|
||||
t.Setenv(facadeDictateEnv, "rust")
|
||||
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
|
||||
for p := range facadeDictateRoutes {
|
||||
w := httptest.NewRecorder()
|
||||
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}")))
|
||||
if w.Body.String() != f.reply {
|
||||
t.Fatalf("%s : réponse %q", p, w.Body.String())
|
||||
}
|
||||
}
|
||||
asrShutdownVite()
|
||||
got := f.recu()
|
||||
if len(got) != len(facadeDictateRoutes)+1 || got[len(got)-1] != "POST /internal/facade/v1/dictate/stop jeton {}" {
|
||||
t.Fatalf("reçu par la façade : %v", got)
|
||||
}
|
||||
t.Setenv(facadeDictateEnv, "")
|
||||
w := httptest.NewRecorder()
|
||||
withFacadeChat("/api/transcribe", local)(w, httptest.NewRequest("POST", "/api/transcribe", nil))
|
||||
if w.Body.String() != "go" {
|
||||
t.Fatalf("relayé sans la variable : %q", w.Body.String())
|
||||
}
|
||||
asrShutdownVite()
|
||||
if n := len(f.recu()); n != len(facadeDictateRoutes)+1 {
|
||||
t.Fatalf("arrêt demandé à la façade sans la variable (%d appels)", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// facade_dictate.go — dictée vocale tenue par la façade Rust (LOKI_DICTATE=rust,
|
||||
// lot 9 de la migration ; expérimental, le Go reste le défaut).
|
||||
//
|
||||
// La façade sert elle-même /api/transcribe et /api/dictate/* sur le port
|
||||
// public, et supervise le serveur de transcription. Deux choses restent à
|
||||
// régler ici :
|
||||
//
|
||||
// - le tunnel du relais et sa redistribution E2E arrivent dans ce process :
|
||||
// ces routes sont remises à la façade (withFacadeChat) ;
|
||||
// - libérer la VRAM (web_vram.go) et l'optimiseur (backend_tune_run.go)
|
||||
// arrêtent la dictée : c'est alors à la façade qu'on le demande, son
|
||||
// serveur n'étant pas un enfant de ce process.
|
||||
|
||||
const facadeDictateEnv = "LOKI_DICTATE"
|
||||
|
||||
// facadeDictate : la façade tient-elle la dictée ? Il faut aussi son adresse
|
||||
// et le jeton interne, sans lesquels rien ne peut lui être remis.
|
||||
func facadeDictate() bool {
|
||||
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeDictateEnv)), "rust") &&
|
||||
facadeAddr() != "" && internalToken() != ""
|
||||
}
|
||||
|
||||
// facadeDictateRoutes : les routes que la façade sert alors (dictate.rs).
|
||||
var facadeDictateRoutes = map[string]bool{
|
||||
"/api/transcribe": true,
|
||||
"/api/dictate/config": true,
|
||||
"/api/dictate/models": true,
|
||||
"/api/dictate/models/download": true,
|
||||
"/api/dictate/state": true,
|
||||
}
|
||||
|
||||
// facadeDictateStop demande à la façade d'arrêter son serveur de dictée, sans
|
||||
// attendre derrière un démarrage (elle tue le processus qui démarre). Rend
|
||||
// false quand la façade ne tient pas la dictée : l'appelant arrête alors le
|
||||
// serveur de ce process.
|
||||
func facadeDictateStop() bool {
|
||||
if !facadeDictate() {
|
||||
return false
|
||||
}
|
||||
if err := facadePost("/internal/facade/v1/dictate/stop", map[string]any{}); err != nil {
|
||||
fmt.Printf("[dictée] arrêt demandé à la façade : %v\n", err)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -220,7 +220,8 @@ func newWebMux() *http.ServeMux {
|
||||
// api enregistre une route /api/* protégée par la clé de pilotage (web_auth.go).
|
||||
// withFacadeChat : routes du chat remises à la façade quand elle tient la
|
||||
// conversation (LOKI_CHAT=rust, facade_chat.go), celles des modèles quand
|
||||
// elle les tient (LOKI_MODELS=rust, facade_models.go) ; sans effet sinon.
|
||||
// elle les tient (LOKI_MODELS=rust, facade_models.go), celles de la dictée
|
||||
// (LOKI_DICTATE=rust, facade_dictate.go) ; sans effet sinon.
|
||||
api := func(path string, h http.HandlerFunc) { mux.HandleFunc(path, requireWebAuth(withFacadeChat(path, h))) }
|
||||
api("/api/ping", handlePing)
|
||||
api("/api/status", handleStatus)
|
||||
|
||||
Reference in new issue
Block a user