diff --git a/Dockerfile b/Dockerfile index 318a190..64ff2e9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,6 +36,14 @@ RUN GOBIN=/out GOTOOLCHAIN=auto go install golang.org/x/tools/gopls@latest # trimballer, et pas de compilation CUDA (des minutes de build pour un gain # nul sur des dictées de quelques secondes). Ubuntu 22.04 : glibc plus # ancienne que l'image runtime, donc compatible quoi qu'elle embarque. +# +# ⚠️ GGML_NATIVE=OFF est OBLIGATOIRE. Par défaut ggml compile en -march=native, +# c'est-à-dire pour le processeur DU RUNNER DE BUILD — un Xeon récent chez +# GitHub, avec AVX-512 et AMX. Le binaire partait alors sur une machine qui +# n'a pas ces instructions et mourait d'un SIGILL en pleine transcription : +# « AMX is not ready to be used! », puis plus rien, l'interface affichant un +# échec sans raison. OFF retombe sur la ligne de base AVX2/FMA/F16C de ggml, +# présente sur tout x86-64 depuis 2013. FROM ubuntu:22.04 AS whisperbuild RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential cmake git ca-certificates \ @@ -43,6 +51,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ RUN git clone --depth 1 https://github.com/ggml-org/whisper.cpp /w \ && cmake -S /w -B /w/build -DCMAKE_BUILD_TYPE=Release \ -DBUILD_SHARED_LIBS=OFF -DWHISPER_BUILD_TESTS=OFF \ + -DGGML_NATIVE=OFF -DGGML_AVX512=OFF -DGGML_AMX_TILE=OFF \ + -DGGML_AMX_INT8=OFF -DGGML_AMX_BF16=OFF \ && cmake --build /w/build -j --target whisper-cli # ── Étape 2 : runtime sur l'image serveur CUDA officielle ─────────────── diff --git a/internal/loki/web_transcribe.go b/internal/loki/web_transcribe.go index 6ba13c5..9a50218 100644 --- a/internal/loki/web_transcribe.go +++ b/internal/loki/web_transcribe.go @@ -8,6 +8,7 @@ package loki import ( "context" + "errors" "fmt" "io" "net/http" @@ -180,12 +181,46 @@ func handleTranscribe(w http.ResponseWriter, r *http.Request) { cmd.Stderr = &stderr out, err := cmd.Output() if err != nil { - msg := strings.TrimSpace(stderr.String()) - if msg == "" { - msg = err.Error() + // Ne garder QUE stderr est trompeur quand le binaire meurt d'un signal : + // la dernière ligne écrite avant l'exécution du coup fatal ressemble à + // une explication (« AMX is not ready to be used! ») alors que la vraie + // cause est la mort brutale. On dit donc toujours comment il a fini — + // un SIGILL/SIGSEGV désigne un binaire compilé pour un autre + // processeur, pas un problème d'audio. + msg := whisperExitReason(err) + if last := lastLine(stderr.String()); last != "" { + msg += " — dernière sortie : " + last } sendJSON(w, 500, map[string]any{"error": "whisper-cli : " + msg}) return } sendJSON(w, 200, map[string]any{"text": strings.TrimSpace(string(out))}) } + +// whisperExitReason traduit la fin du processus en une phrase utilisable. +// ProcessState.String() dit déjà « exit status 2 » ou « signal: illegal +// instruction » ; le second cas mérite son explication, parce que rien dans la +// dictée ne laisse deviner que le binaire ne tourne pas sur ce processeur. +func whisperExitReason(err error) string { + var ee *exec.ExitError + if !errors.As(err, &ee) { + return err.Error() + } + st := ee.ProcessState.String() + if strings.Contains(st, "signal:") { + return st + " (binaire compilé pour un autre processeur : image à reconstruire)" + } + return st +} + +// lastLine : la dernière ligne non vide. whisper-cli bavarde beaucoup avant de +// tomber ; seule la fin renseigne, et un pavé ne tient pas dans un bandeau. +func lastLine(s string) string { + lines := strings.Split(strings.TrimSpace(s), "\n") + for i := len(lines) - 1; i >= 0; i-- { + if l := strings.TrimSpace(lines[i]); l != "" { + return l + } + } + return "" +} diff --git a/internal/loki/web_transcribe_test.go b/internal/loki/web_transcribe_test.go new file mode 100644 index 0000000..f8e3693 --- /dev/null +++ b/internal/loki/web_transcribe_test.go @@ -0,0 +1,48 @@ +package loki + +import ( + "os" + "strings" + "testing" +) + +// La dictée est morte en production parce que whisper-cli 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. Le drapeau qui l'évite ne se voit pas à l'exécution — rien ne +// le rappelle au prochain qui touchera cette étape de build. Ce test le fait. +func TestDockerfileWhisperNonNatif(t *testing.T) { + b, err := os.ReadFile("../../Dockerfile") + if err != nil { + t.Fatalf("lecture du Dockerfile : %v", err) + } + src := string(b) + _, apres, ok := strings.Cut(src, "AS whisperbuild") + if !ok { + t.Fatal("étape whisperbuild introuvable dans le Dockerfile") + } + // L'étape suivante commence au FROM d'après : ne pas déborder dessus. + if fin := strings.Index(apres, "\nFROM "); fin >= 0 { + apres = apres[:fin] + } + for _, drapeau := range []string{"-DGGML_NATIVE=OFF", "-DGGML_AMX_TILE=OFF"} { + if !strings.Contains(apres, drapeau) { + t.Errorf("l'étape whisperbuild ne passe plus %s : le binaire sera compilé pour le processeur du runner et mourra d'un SIGILL ailleurs", drapeau) + } + } +} + +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) + } + } +}