Files
Loki/internal/loki/web_transcribe_test.go
T
MichaelandClaude Opus 5 3cd4facb0e Dictée : whisper-server supervisé, modèle chargé une seule fois
Chaque dictée relançait whisper-cli, qui relisait le modèle depuis le disque
avant de transcrire. Ce chargement dominait le temps de réponse — 3,2 s de
calcul pour 3,4 s d'audio — et aucun réglage ne pouvait le rattraper.

whisper-server garde le modèle en mémoire entre deux phrases. Loki le
supervise : démarrage au premier clic sur le micro, extinction après dix
minutes sans dictée. Une dictée n'est pas un service permanent, et garder un
modèle chargé toute la journée priverait le moteur de chat de sa VRAM.

- Réglages serveur (modèle, langue, matériel, réactivité) dans bkState, avec
  leurs routes. La langue par défaut passe de « auto » à « fr » : sur quelques
  secondes d'audio la détection se trompe, et une langue mal détectée produit
  du charabia — des suites de caractères géorgiens ont été observées.
- Catalogue de quatre modèles, de small à large-v3 ; large-v3-turbo par
  défaut. Téléchargement par identifiant, dans un .part renommé à la fin : un
  transfert interrompu ne laisse plus un .bin tronqué qu'on croirait bon.
- Le GPU se choisit par CUDA_VISIBLE_DEVICES, posé en REMPLAÇANT toute valeur
  héritée. Dupliquée, la variable laisse le gagnant dépendre de la libc.
- Les marqueurs de whisper ([BLANK_AUDIO], (silence)) ne sont plus collés dans
  le champ de saisie comme s'ils étaient du texte dicté.

L'image construit DEUX binaires whisper-server, CPU et CUDA. LLAMACPP_IMAGE
accepte la variante CPU de l'image amont ; sur cette base les .so CUDA sont
absentes et un binaire lié à CUDA n'a même pas de quoi démarrer, donc aucun
repli n'est possible depuis le programme. Le garde-fou du Dockerfile vérifie
maintenant les deux étapes : les drapeaux de portabilité de la PR #18 doivent
survivre à l'arrivée de CUDA.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-21 21:08:12 +02:00

134 lines
4.3 KiB
Go

package loki
import (
"os"
"strings"
"testing"
)
// etapesWhisperbuild découpe le Dockerfile en étapes « FROM … AS whisperbuild* »
// et rend le texte de chacune.
func etapesWhisperbuild(t *testing.T) map[string]string {
t.Helper()
b, err := os.ReadFile("../../Dockerfile")
if err != nil {
t.Fatalf("lecture du Dockerfile : %v", err)
}
out := map[string]string{}
for _, bloc := range strings.Split(string(b), "\nFROM ") {
entete, reste, ok := strings.Cut(bloc, "\n")
if !ok {
continue
}
_, nom, ok := strings.Cut(entete, " AS ")
if !ok || !strings.HasPrefix(strings.TrimSpace(nom), "whisperbuild") {
continue
}
out[strings.TrimSpace(nom)] = entete + "\n" + reste
}
return out
}
// argWhisperFlags rend la valeur de l'ARG WHISPER_CMAKE_FLAGS, continuations
// de ligne comprises. Vide si l'ARG n'existe pas : les étapes devront alors
// porter les drapeaux en clair, et le test le vérifiera.
func argWhisperFlags(t *testing.T) string {
t.Helper()
b, err := os.ReadFile("../../Dockerfile")
if err != nil {
t.Fatalf("lecture du Dockerfile : %v", err)
}
_, apres, ok := strings.Cut(string(b), "ARG WHISPER_CMAKE_FLAGS=")
if !ok {
return ""
}
var val strings.Builder
for _, ligne := range strings.Split(apres, "\n") {
val.WriteString(" " + ligne)
if !strings.HasSuffix(strings.TrimSpace(ligne), "\\") {
break
}
}
return val.String()
}
// La dictée est morte en production parce que whisper avait été compilé avec
// -march=native, donc pour le processeur du runner GitHub (AVX-512, AMX) et pas
// pour la machine qui fait tourner l'image : SIGILL en pleine transcription.
// Les drapeaux qui l'évitent ne se voient pas à l'exécution — rien ne les
// rappelle au prochain qui touchera cette étape de build. Ce test le fait, sur
// CHAQUE étape, y compris celle ajoutée pour CUDA.
func TestDockerfileWhisperNonNatif(t *testing.T) {
etapes := etapesWhisperbuild(t)
if len(etapes) < 2 {
t.Fatalf("%d étape(s) whisperbuild, 2 attendues (CPU et CUDA) : %v", len(etapes), clefs(etapes))
}
// Les drapeaux communs sont factorisés dans WHISPER_CMAKE_FLAGS. Une étape
// est conforme si elle les porte en clair OU si elle référence cet ARG —
// ce qui compte est qu'ils atteignent cmake, pas qu'ils soient recopiés.
commun := argWhisperFlags(t)
for nom, txt := range etapes {
effectif := txt
if strings.Contains(txt, "${WHISPER_CMAKE_FLAGS}") {
effectif += " " + commun
}
for _, drapeau := range []string{"-DGGML_NATIVE=OFF", "-DGGML_AMX_TILE=OFF", "-DGGML_AVX512=OFF"} {
if !strings.Contains(effectif, drapeau) {
t.Errorf("étape %s : %s absent — le binaire sera compilé pour le processeur du runner et mourra d'un SIGILL ailleurs", nom, drapeau)
}
}
if !strings.Contains(txt, "whisper-server") {
t.Errorf("étape %s : ne construit pas la cible whisper-server", nom)
}
}
}
// Deux binaires, pas un : sur une image runtime bâtie sans CUDA, un binaire lié
// à CUDA ne démarre pas du tout — l'éditeur de liens échoue avant la première
// instruction, donc aucun repli n'est possible depuis le programme.
func TestDockerfileDeuxBinairesWhisper(t *testing.T) {
b, err := os.ReadFile("../../Dockerfile")
if err != nil {
t.Fatalf("lecture du Dockerfile : %v", err)
}
src := string(b)
var avecCuda bool
for nom, txt := range etapesWhisperbuild(t) {
if strings.Contains(txt, "-DGGML_CUDA=ON") {
avecCuda = true
_ = nom
}
}
if !avecCuda {
t.Error("aucune étape whisperbuild ne passe -DGGML_CUDA=ON : la dictée ne pourra jamais utiliser le GPU")
}
for _, bin := range []string{"whisper-server-cpu", "whisper-server-cuda"} {
if !strings.Contains(src, "/usr/local/bin/"+bin) {
t.Errorf("le runtime ne reçoit pas %s — dictate_server.go le cherche à cet emplacement", bin)
}
}
}
func clefs(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
func TestLastLine(t *testing.T) {
cas := []struct{ in, want string }{
{"", ""},
{" \n\n ", ""},
{"une seule ligne", "une seule ligne"},
{"AMX is not ready to be used!\nread_audio_data: ...\n", "read_audio_data: ..."},
{"fin utile\n\n \n", "fin utile"},
}
for _, c := range cas {
if got := lastLine(c.in); got != c.want {
t.Errorf("lastLine(%q) = %q, attendu %q", c.in, got, c.want)
}
}
}