mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
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
189 lines
5.7 KiB
Go
189 lines
5.7 KiB
Go
package loki
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
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
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func TestServerArgs(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)
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
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 {
|
|
t.Errorf("%d occurrences de CUDA_VISIBLE_DEVICES, attendu exactement 1", n)
|
|
}
|
|
if !contientExact(env, "PATH=/usr/bin") {
|
|
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
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func compte(l []string, prefixe string) int {
|
|
n := 0
|
|
for _, v := range l {
|
|
if strings.HasPrefix(v, prefixe) {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
func TestPortLibre(t *testing.T) {
|
|
p, err := portLibre()
|
|
if err != nil {
|
|
t.Fatalf("portLibre : %v", err)
|
|
}
|
|
if p < 1024 || p > 65535 {
|
|
t.Errorf("port = %d, hors de la plage utilisable", p)
|
|
}
|
|
l, err := net.Listen("tcp", "127.0.0.1:"+strconv.Itoa(p))
|
|
if err != nil {
|
|
t.Fatalf("port %d annoncé libre mais inutilisable : %v", p, err)
|
|
}
|
|
_ = 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()
|
|
|
|
txt, err := whisperInferSur(context.Background(), srv.URL, []byte("RIFFfaux"))
|
|
if err != nil {
|
|
t.Fatalf("whisperInferSur : %v", err)
|
|
}
|
|
if txt != "bonjour ceci est un test" {
|
|
t.Errorf("texte = %q, les espaces de bord doivent être retirés", txt)
|
|
}
|
|
}
|
|
|
|
// 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].
|
|
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()
|
|
if err != nil {
|
|
t.Fatalf("whisperInferSur(%q) : %v", marqueur, err)
|
|
}
|
|
if txt != "" {
|
|
t.Errorf("marqueur %q rendu comme texte %q, attendu vide", marqueur, txt)
|
|
}
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
// Un modèle absent doit être annoncé comme tel, pas produire un lancement de
|
|
// whisper-server 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 {
|
|
t.Fatal(err)
|
|
}
|
|
_, err := whisperEnsure()
|
|
if err == nil {
|
|
t.Fatal("un modèle absent doit produire une erreur")
|
|
}
|
|
if !strings.Contains(err.Error(), "absent") {
|
|
t.Errorf("erreur = %q, elle doit dire que le modèle est absent", err)
|
|
}
|
|
}
|
|
|
|
func TestEtatSansServeur(t *testing.T) {
|
|
testHome(t)
|
|
e := whisperEtat()
|
|
if e["actif"] != false {
|
|
t.Errorf("actif = %v, attendu false sans serveur lancé", e["actif"])
|
|
}
|
|
for _, clef := range []string{"modele", "present", "device", "note"} {
|
|
if _, ok := e[clef]; !ok {
|
|
t.Errorf("clé %q manquante — l'UI en a besoin", clef)
|
|
}
|
|
}
|
|
}
|