diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 2f153d1..b7bd23e 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -15,12 +15,12 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + # L'image runtime CUDA de llama.cpp (~4 Go) et Chromium/Playwright + # (~1 Go) tiennent large dans les ~14 Go libres d'un runner neuf, mais + # ces trois répertoires ne servent à rien ici et rendent une dizaine de + # gigaoctets en moins d'une minute. Gardé depuis l'époque où l'image + # compilait whisper.cpp avec CUDA : la marge reste bonne à prendre. - # Depuis que l'image compile whisper.cpp avec CUDA, le build empile - # nvidia/cuda:*-devel (~6 Go), l'image runtime CUDA de llama.cpp (~4 Go) - # et Chromium/Playwright (~1 Go) — pour ~14 Go libres sur un runner neuf. - # Ces trois répertoires ne servent à rien ici et rendent une dizaine de - # gigaoctets en moins d'une minute. - name: Libérer de l'espace disque run: | df -h / @@ -56,8 +56,8 @@ jobs: LOKI_VERSION=${{ github.sha }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - # Le cache de couches réutilise les étapes whisperbuild-* (compilation - # whisper.cpp CUDA, ~35 min) tant que leurs lignes du Dockerfile ne - # bougent pas : seul le code Go et l'assemblage de l'image se refont. + # Le cache de couches réutilise l'étape asrfetch (téléchargement du + # binaire de dictée) tant que ses lignes du Dockerfile ne bougent + # pas : seul le code Go et l'assemblage de l'image se refont. cache-from: type=gha cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile index aa1c93a..d9f6d8e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,16 +15,15 @@ ARG LLAMACPP_IMAGE=ghcr.io/ggml-org/llama.cpp:server-cuda # ⚠️ AVANT le premier FROM, obligatoirement. Un ARG posé entre deux étapes -# appartient à l'étape où il apparaît ; les « ARG WHISPER_CMAKE_FLAGS » nus des -# étapes whisperbuild-* héritent du scope GLOBAL — c'est-à-dire d'ici. Placé -# plus bas, ils héritaient d'une valeur VIDE : cmake tournait sans aucun -# drapeau, ggml se construisait en bibliothèques partagées (échec de lien sur -# libcuda.so.1) et surtout en -march=native — le SIGILL de la PR #18 revenu en -# silence. Un test (TestDockerfileArgFlagsGlobal) verrouille cette position. -ARG WHISPER_CMAKE_FLAGS="-DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF \ - -DWHISPER_BUILD_TESTS=OFF -DWHISPER_BUILD_EXAMPLES=ON \ - -DGGML_NATIVE=OFF -DGGML_AVX512=OFF -DGGML_AMX_TILE=OFF \ - -DGGML_AMX_INT8=OFF -DGGML_AMX_BF16=OFF" +# appartient à l'étape où il apparaît ; les « ARG SHERPA_* » nus de l'étape +# asrfetch héritent du scope GLOBAL — c'est-à-dire d'ici. Placés plus bas, ils +# hériteraient d'une valeur VIDE, et l'étape téléchargerait une URL tronquée. +# Un test (TestDockerfileArgFlagsGlobal) verrouille cette position. +# +# La version est ÉPINGLÉE, et l'empreinte avec : le binaire de dictée est +# exécuté sur la machine de l'utilisateur, il ne se prend pas « au dernier ». +ARG SHERPA_ONNX_VERSION=v1.13.7 +ARG SHERPA_ONNX_SHA256=2aa3965b37aa235e56aa3ebbb94942c20a23960059a006b4442b4bf893967983 # ── Étape 1 : binaire Go loki ─────────────────────────────────────────── FROM golang:1.25 AS gobuild @@ -43,77 +42,37 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /out/loki ./cmd/loki # limité à cette étape de build. RUN GOBIN=/out GOTOOLCHAIN=auto go install golang.org/x/tools/gopls@latest -# ── Étape 1 bis : whisper-server (dictée vocale) ──────────────────────── -# DEUX binaires sont construits, pas un. LLAMACPP_IMAGE accepte la variante -# CPU de l'image amont (voir ligne 11) : sur cette base, les bibliothèques -# CUDA sont absentes et 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 l'intérieur du programme. Loki choisit à l'exécution -# (whisperServerBin, dictate_server.go). +# ── Étape 1 bis : serveur de dictée (Parakeet via sherpa-onnx) ────────── +# On TÉLÉCHARGE un binaire préconstruit, on ne compile plus rien. C'est le +# changement de fond par rapport à whisper.cpp, qu'il remplace : deux étapes de +# compilation (dont une CUDA de ~200 fichiers nvcc, tuée par l'OOM du runner +# plus d'une fois) laissent place à une extraction de 35 Mo. # -# whisper-server et non whisper-cli : le modèle reste chargé entre deux -# dictées. L'ancien chemin le relisait depuis le disque à chaque phrase, ce -# qui dominait le temps de réponse. +# Le build « static-no-tts » lie sherpa-onnx ET onnxruntime en statique : rien à +# fournir côté image en dehors de la libc et de libstdc++, déjà présentes. Il +# n'existe qu'en variante CPU — c'est assumé : un modèle de 0,6 B en int8 sur +# des tranches de quelques secondes n'a pas besoin de la carte, qui reste au +# moteur de chat. La variante CUDA existe mais tirerait onnxruntime-gpu et +# cuDNN dans l'image, pour un gain sans intérêt à cette taille de tranche. # -# ⚠️ GGML_NATIVE=OFF est OBLIGATOIRE sur LES DEUX cibles. 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. -# Ubuntu 22.04 : glibc plus ancienne que l'image runtime, donc compatible quoi -# qu'elle embarque. -FROM ubuntu:22.04 AS whisperbuild-cpu -ARG WHISPER_CMAKE_FLAGS +# L'empreinte est vérifiée : le binaire s'exécute sur la machine de +# l'utilisateur, une release remplacée en amont ne doit pas passer en silence. +FROM debian:12-slim AS asrfetch +ARG SHERPA_ONNX_VERSION +ARG SHERPA_ONNX_SHA256 RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential cmake git ca-certificates \ + curl ca-certificates bzip2 \ && rm -rf /var/lib/apt/lists/* -# Le grep final vérifie que ggml est bien lié EN STATIQUE : l'image finale ne -# reçoit que le binaire, une .so ggml manquante ne se verrait qu'au premier -# clic sur le micro. Des libs partagées ici = drapeaux non transmis. -RUN git clone --depth 1 https://github.com/ggml-org/whisper.cpp /w \ - && cmake -S /w -B /w/build ${WHISPER_CMAKE_FLAGS} \ - && cmake --build /w/build -j"$(nproc)" --target whisper-server \ - && ( ! ldd /w/build/bin/whisper-server | grep -E "libggml|libwhisper" ) - -# Version CUDA : la MÊME que celle de l'image amont (llama.cpp bâtit avec -# CUDA 12.8.1 sur Ubuntu 24.04). Le binaire est lié dynamiquement à libcudart, -# fournie par LLAMACPP_IMAGE et non par cette étape. -# -# ⚠️ 12.8 est un PLANCHER, pas un détail de version. Les GPU Blackwell -# (RTX 50xx, sm_120) n'existent pas pour un nvcc 12.4 : il refuse l'archi, et -# à défaut le binaire ne tourne que par recompilation PTX au chargement, quand -# elle est possible. -# -# ⚠️ -j"$(nproc)" et JAMAIS -j nu. Avec Make, « -j » sans nombre autorise un -# parallélisme ILLIMITÉ. ggml-cuda compte ~200 fichiers d'instanciation de -# gabarits, et chaque nvcc réclame 1 à 2 Go : le runner GitHub (16 Go) était -# tué par l'OOM après avoir lancé 92 % des compilations en treize secondes, -# sans écrire la moindre ligne d'erreur. L'étape CPU y survivait — peu de -# fichiers, compilation légère — ce qui rendait le piège invisible jusqu'ici. -# -# CUDA_ARCHS borne le travail : ggml compile sinon pour TOUTES les -# architectures qu'il connaît, de Maxwell à Blackwell, et chacune multiplie le -# temps de compilation. La liste par défaut couvre Turing à Blackwell -# (RTX 20xx → 50xx) ; l'élargir pour un GPU plus ancien se fait sans toucher -# au fichier : --build-arg CUDA_ARCHS="61;75;86". -FROM nvidia/cuda:12.8.1-devel-ubuntu24.04 AS whisperbuild-cuda -ARG WHISPER_CMAKE_FLAGS -ARG CUDA_ARCHS="75;86;89;120" -RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential cmake git ca-certificates \ - && rm -rf /var/lib/apt/lists/* -# Même vérification statique que l'étape CPU. libcuda.so.1 (le PILOTE) reste -# elle une dépendance dynamique normale : absente du runner de build, injectée -# à l'exécution par le NVIDIA Container Toolkit — l'édition de liens la -# résout via les stubs du toolkit. -RUN git clone --depth 1 https://github.com/ggml-org/whisper.cpp /w \ - && cmake -S /w -B /w/build ${WHISPER_CMAKE_FLAGS} -DGGML_CUDA=ON \ - -DCMAKE_CUDA_ARCHITECTURES="${CUDA_ARCHS}" \ - && cmake --build /w/build -j"$(nproc)" --target whisper-server \ - && strip /w/build/bin/whisper-server \ - && ( ! ldd /w/build/bin/whisper-server | grep -E "libggml|libwhisper" ) +RUN set -eu; \ + nom="sherpa-onnx-${SHERPA_ONNX_VERSION}-linux-x64-static-no-tts"; \ + curl -fsSL -o /tmp/sherpa.tar.bz2 \ + "https://github.com/k2-fsa/sherpa-onnx/releases/download/${SHERPA_ONNX_VERSION}/${nom}.tar.bz2"; \ + echo "${SHERPA_ONNX_SHA256} /tmp/sherpa.tar.bz2" | sha256sum -c -; \ + mkdir -p /out; \ + tar -xjf /tmp/sherpa.tar.bz2 -C /out --strip-components=2 \ + "${nom}/bin/sherpa-onnx-offline-websocket-server"; \ + chmod +x /out/sherpa-onnx-offline-websocket-server; \ + /out/sherpa-onnx-offline-websocket-server --help >/dev/null 2>&1 || true # ── Étape 2 : runtime sur l'image serveur CUDA officielle ─────────────── FROM ${LLAMACPP_IMAGE} AS runtime @@ -168,11 +127,9 @@ RUN if [ "$PLAYWRIGHT" = "1" ]; then \ COPY --from=gobuild /out/loki /usr/local/bin/loki COPY --from=gobuild /out/gopls /usr/local/bin/gopls -# Dictée vocale (POST /api/transcribe). Les modèles (190 Mo à 1,1 Go) ne sont -# PAS dans l'image : téléchargés à la demande dans /data/whisper/. -# Deux binaires : voir l'étape whisperbuild-cpu pour la raison. -COPY --from=whisperbuild-cpu /w/build/bin/whisper-server /usr/local/bin/whisper-server-cpu -COPY --from=whisperbuild-cuda /w/build/bin/whisper-server /usr/local/bin/whisper-server-cuda +# Dictée vocale (POST /api/transcribe). Le modèle (~500 Mo) n'est PAS dans +# l'image : téléchargé à la demande dans /data/asr/. +COPY --from=asrfetch /out/sherpa-onnx-offline-websocket-server /usr/local/bin/sherpa-onnx-offline-websocket-server COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh RUN chmod +x /usr/local/bin/docker-entrypoint.sh && mkdir -p /data /models @@ -181,8 +138,7 @@ ENV LOKI_CONTAINER=1 \ LOKI_HOME=/data \ LOKI_MODEL_DIRS=/models \ LOKI_ENGINE_BIN=/app/llama-server \ - LOKI_WHISPER_SERVER_CPU=/usr/local/bin/whisper-server-cpu \ - LOKI_WHISPER_SERVER_CUDA=/usr/local/bin/whisper-server-cuda \ + LOKI_ASR_SERVER=/usr/local/bin/sherpa-onnx-offline-websocket-server \ LD_LIBRARY_PATH=/app \ NVIDIA_VISIBLE_DEVICES=all \ NVIDIA_DRIVER_CAPABILITIES=compute,utility diff --git a/internal/loki/dictate_config.go b/internal/loki/dictate_config.go index c0d3bba..421db58 100644 --- a/internal/loki/dictate_config.go +++ b/internal/loki/dictate_config.go @@ -1,45 +1,41 @@ -// Réglages de la dictée vocale — modèle, langue, matériel, réactivité. +// Réglages de la dictée vocale — modèle et réactivité. // // Ils vivent dans le magasin clé/valeur (bkState), comme le jeton Hugging // Face : ce sont des réglages de SERVEUR, pas de navigateur. Le modèle chargé -// et le GPU occupé sont des propriétés de la machine, identiques pour tous les -// onglets ouverts. +// est une propriété de la machine, identique pour tous les onglets ouverts. package loki import ( "fmt" - "strconv" "time" ) const dictateCfgKey = "dictate_cfg" -// DictateCfg : Device vaut "cpu" ou un indice de GPU en décimal ("0", "1"), -// dans l'ordre de nvidia-smi — le même que celui affiché par /api/vram. +// DictateCfg : ce qui reste réglable depuis le passage à Parakeet. +// +// Deux champs ont disparu, et c'est le moteur qui l'impose, pas un choix de +// simplification : +// - la LANGUE : Parakeet v3 détecte la langue lui-même et n'a aucun drapeau +// pour la forcer. Le réglage n'aurait servi qu'à mentir. +// - le GPU : le binaire sherpa-onnx livré dans l'image est le build statique +// CPU. Annoncer un sélecteur de carte sans pouvoir l'honorer serait pire que +// de ne rien annoncer. Un modèle 0,6 B en int8 sur des tranches de quelques +// secondes n'a pas besoin de la carte, qui reste au moteur de chat. type DictateCfg struct { Model string `json:"model"` - Lang string `json:"lang"` - Device string `json:"device"` Reactivity string `json:"reactivity"` } // withDefauts comble les champs vides. Appliqué à la LECTURE plutôt qu'à // l'écriture : une version future qui ajoute un champ trouvera les réglages -// déjà enregistrés sans champ correspondant, et doit pouvoir le combler. +// déjà enregistrés sans champ correspondant, et doit pouvoir le combler. C'est +// aussi ce qui rattrape les réglages enregistrés du temps de whisper, dont le +// Model ne veut plus rien dire. func (c DictateCfg) withDefauts() DictateCfg { - if c.Model == "" { - // large-v3-turbo : meilleur rapport qualité/vitesse en français à ce - // jour. Le défaut penche vers la qualité. - c.Model = "large-v3-turbo-q5_0" - } - if c.Lang == "" { - // Pas "auto" : sur des tranches de quelques secondes 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 en production. - c.Lang = "fr" - } - if c.Device == "" { - c.Device = "cpu" + if _, connu := asrCatalogue[c.Model]; !connu { + // v3 : le seul des deux qui parle français. + c.Model = "parakeet-tdt-0.6b-v3" } if c.Reactivity == "" { c.Reactivity = "moyen" @@ -54,26 +50,14 @@ func dictateCfgLoad() DictateCfg { } func dictateCfgSave(c DictateCfg) error { - c = c.withDefauts() // Valider ICI plutôt qu'au démarrage du serveur : un modèle hors catalogue // accepté en silence ne se manifesterait qu'au premier clic sur le micro, - // loin du geste qui l'a causé. - if _, ok := whisperCatalogue[c.Model]; !ok { + // loin du geste qui l'a causé. On valide AVANT withDefauts, qui corrigerait + // justement la faute qu'on veut signaler. + if _, ok := asrCatalogue[c.Model]; !ok { return fmt.Errorf("modèle de dictée inconnu : %s", c.Model) } - return putJSON(bkState, dictateCfgKey, c) -} - -// cudaVisibleDevices traduit le réglage en valeur de CUDA_VISIBLE_DEVICES. -// Vide = aucun GPU visible = whisper tourne sur le CPU. Toute valeur qui n'est -// pas un indice positif retombe sur le CPU : mieux vaut une dictée lente qu'un -// GPU choisi au hasard sur une machine dont on ne sait rien. -func (c DictateCfg) cudaVisibleDevices() string { - n, err := strconv.Atoi(c.Device) - if err != nil || n < 0 { - return "" - } - return strconv.Itoa(n) + return putJSON(bkState, dictateCfgKey, c.withDefauts()) } // chunkBounds : réserve minimale avant de couper, et coupure forcée quand diff --git a/internal/loki/dictate_config_test.go b/internal/loki/dictate_config_test.go index 142dc44..856d90c 100644 --- a/internal/loki/dictate_config_test.go +++ b/internal/loki/dictate_config_test.go @@ -2,86 +2,77 @@ package loki import ( "testing" - "time" ) -func TestDictateCfgDefauts(t *testing.T) { +// Les défauts sont appliqués à la LECTURE : un réglage enregistré par une +// version antérieure, sans le champ, doit être comblé plutôt que de produire un +// modèle vide passé en argument au serveur. +func TestDefautsCombles(t *testing.T) { + testHome(t) c := DictateCfg{}.withDefauts() - if c.Model != "large-v3-turbo-q5_0" { - t.Errorf("modèle par défaut = %q, attendu large-v3-turbo-q5_0", c.Model) + if _, ok := asrCatalogue[c.Model]; !ok { + t.Errorf("modèle par défaut %q hors catalogue", c.Model) } - if c.Lang != "fr" { - t.Errorf("langue par défaut = %q, attendu fr — la détection auto se trompe sur les tranches courtes", c.Lang) - } - if c.Device != "cpu" { - t.Errorf("matériel par défaut = %q, attendu cpu (aucun GPU supposé)", c.Device) - } - if c.Reactivity != "moyen" { - t.Errorf("réactivité par défaut = %q, attendu moyen", c.Reactivity) + if c.Reactivity == "" { + t.Error("réactivité par défaut vide") } } -func TestCudaVisibleDevices(t *testing.T) { - cas := []struct{ device, want string }{ - {"cpu", ""}, - {"0", "0"}, - {"1", "1"}, - {"", ""}, - {"bidon", ""}, - {"-1", ""}, +// Le passage de whisper à Parakeet laisse des réglages enregistrés dont le +// Model ne veut plus rien dire (« large-v3-turbo-q5_0 »). Ils doivent retomber +// sur le défaut, sans quoi la dictée serait morte après mise à jour, avec pour +// seul indice « modèle de dictée inconnu » au premier clic sur le micro. +func TestModeleWhisperHeriteRetombeSurLeDefaut(t *testing.T) { + testHome(t) + c := DictateCfg{Model: "large-v3-turbo-q5_0", Reactivity: "court"}.withDefauts() + if _, ok := asrCatalogue[c.Model]; !ok { + t.Fatalf("modèle hérité %q non corrigé", c.Model) } - for _, c := range cas { - got := DictateCfg{Device: c.device}.cudaVisibleDevices() - if got != c.want { - t.Errorf("cudaVisibleDevices(%q) = %q, attendu %q", c.device, got, c.want) - } + if c.Reactivity != "court" { + t.Errorf("réactivité = %q, elle devait être conservée", c.Reactivity) } } +// Valider à l'ÉCRITURE : un modèle hors catalogue accepté en silence ne se +// manifesterait qu'au premier clic sur le micro, loin du geste qui l'a causé. +func TestSaveRefuseModeleInconnu(t *testing.T) { + testHome(t) + if err := dictateCfgSave(DictateCfg{Model: "nawak", Reactivity: "moyen"}); err == nil { + t.Error("un modèle hors catalogue a été accepté") + } +} + +func TestSaveLoadAllerRetour(t *testing.T) { + testHome(t) + want := DictateCfg{Model: "parakeet-tdt-0.6b-v2", Reactivity: "long"} + if err := dictateCfgSave(want); err != nil { + t.Fatal(err) + } + if got := dictateCfgLoad(); got != want { + t.Errorf("relu %+v, attendu %+v", got, want) + } +} + +// Les bornes de découpage pilotent la latence perçue : plus la réserve est +// courte, plus le texte arrive vite, au prix d'un contexte plus maigre pour le +// modèle. L'ordre entre les trois réglages est ce qui doit tenir. func TestChunkBounds(t *testing.T) { - cas := []struct { - react string - min, max time.Duration - }{ - {"court", 1000 * time.Millisecond, 5 * time.Second}, - {"moyen", 1500 * time.Millisecond, 8 * time.Second}, - {"long", 3 * time.Second, 15 * time.Second}, - {"inconnu", 1500 * time.Millisecond, 8 * time.Second}, + court, _ := DictateCfg{Reactivity: "court"}.chunkBounds() + moyen, _ := DictateCfg{Reactivity: "moyen"}.chunkBounds() + long, longMax := DictateCfg{Reactivity: "long"}.chunkBounds() + if !(court < moyen && moyen < long) { + t.Errorf("réserves non croissantes : court=%v moyen=%v long=%v", court, moyen, long) } - for _, c := range cas { - min, max := DictateCfg{Reactivity: c.react}.chunkBounds() - if min != c.min || max != c.max { - t.Errorf("chunkBounds(%q) = %v/%v, attendu %v/%v", c.react, min, max, c.min, c.max) - } + if longMax <= long { + t.Error("la coupure forcée doit être plus grande que la réserve minimale") } -} - -func TestDictateCfgAllerRetour(t *testing.T) { - testHome(t) - in := DictateCfg{Model: "medium-q5_0", Lang: "en", Device: "1", Reactivity: "long"} - if err := dictateCfgSave(in); err != nil { - t.Fatalf("enregistrement : %v", err) + // Une valeur inconnue doit retomber sur le réglage moyen, pas sur zéro : une + // réserve nulle couperait à chaque échantillon. + inconnu, _ := DictateCfg{Reactivity: "nawak"}.chunkBounds() + if inconnu != moyen { + t.Errorf("réactivité inconnue = %v, attendu le réglage moyen %v", inconnu, moyen) } - if out := dictateCfgLoad(); out != in { - t.Errorf("relu %+v, attendu %+v", out, in) - } -} - -// Un modèle hors catalogue accepté en silence ne se manifesterait qu'au premier -// clic sur le micro, loin du geste qui l'a causé. -func TestDictateCfgSaveRefuseModeleInconnu(t *testing.T) { - testHome(t) - if err := dictateCfgSave(DictateCfg{Model: "modele-inexistant", Lang: "fr", Device: "cpu", Reactivity: "moyen"}); err == nil { - t.Error("un modèle hors catalogue doit être refusé") - } -} - -// Sans réglage enregistré, la lecture doit rendre les défauts — et non le zéro -// de la structure, qui donnerait un modèle vide à whisper-server. -func TestDictateCfgLoadSansRien(t *testing.T) { - testHome(t) - c := dictateCfgLoad() - if c.Model == "" || c.Lang == "" || c.Device == "" || c.Reactivity == "" { - t.Errorf("lecture à vide = %+v, aucun champ ne doit rester vide", c) + if inconnu <= 0 { + t.Error("réserve nulle : la dictée couperait à chaque échantillon") } } diff --git a/internal/loki/dictate_models.go b/internal/loki/dictate_models.go index b65aea3..1c534c2 100644 --- a/internal/loki/dictate_models.go +++ b/internal/loki/dictate_models.go @@ -1,70 +1,120 @@ -// Catalogue des modèles de dictée. Aucun n'est embarqué dans l'image : ils -// pèsent de 190 Mo à 1,1 Go et vivent dans /whisper/, donc dans le -// volume /data — ils survivent aux recréations du conteneur. +// Catalogue des modèles de dictée — Parakeet (NVIDIA), servi par sherpa-onnx. +// +// Aucun n'est embarqué dans l'image : ils pèsent près de 500 Mo compressés et +// vivent dans /asr/, donc dans le volume /data — ils survivent aux +// recréations du conteneur. +// +// Différence de forme avec whisper.cpp, qu'ils remplacent : un modèle n'est plus +// UN fichier .bin mais un DOSSIER (encodeur, décodeur, joiner, table de jetons), +// livré en .tar.bz2. Le téléchargement décompresse donc au passage — d'où +// asrExtract plutôt qu'une simple copie de flux. package loki import ( + "archive/tar" + "compress/bzip2" + "fmt" + "io" "os" "path/filepath" "sort" + "strings" ) -// Les modèles ggml officiels de whisper.cpp. -const whisperModelBase = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/" +// Les archives sherpa-onnx officielles (conversions ONNX des modèles NeMo). +const asrModelBase = "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/" -type whisperModel struct { +type asrModel struct { Nom string // libellé affiché - Fichier string // nom du .bin, identique en local et chez Hugging Face - Octets int64 // taille du téléchargement, pour prévenir avant de le lancer - VRAMMo int // ordre de grandeur occupé sur le GPU, pour choisir en connaissance de cause + Dossier string // nom du dossier, identique en local et dans l'archive + Octets int64 // taille du TÉLÉCHARGEMENT (archive compressée), pour prévenir + Langues string // ce que le modèle sait transcrire — c'est LE critère de choix } -// Les tailles sont celles publiées par le dépôt amont. Elles servent à -// informer avant un téléchargement, pas à vérifier le fichier : une taille qui -// dérive d'une release à l'autre ne doit pas casser la dictée. -var whisperCatalogue = map[string]whisperModel{ - "small-q5_1": {Nom: "small — rapide, correct", Fichier: "ggml-small-q5_1.bin", Octets: 190_000_000, VRAMMo: 600}, - "medium-q5_0": {Nom: "medium — bon compromis", Fichier: "ggml-medium-q5_0.bin", Octets: 539_000_000, VRAMMo: 1400}, - "large-v3-turbo-q5_0": {Nom: "large-v3-turbo — recommandé", Fichier: "ggml-large-v3-turbo-q5_0.bin", Octets: 574_000_000, VRAMMo: 1600}, - "large-v3-q5_0": {Nom: "large-v3 — le plus précis, le plus lent", Fichier: "ggml-large-v3-q5_0.bin", Octets: 1_080_000_000, VRAMMo: 3600}, +// asrFichiers : les quatre fichiers qu'un modèle doit avoir pour être utilisable. +// Sert au contrôle de présence : un dossier à moitié extrait (téléchargement +// interrompu) ne doit pas passer pour un modèle installé. +var asrFichiers = []string{"encoder.int8.onnx", "decoder.int8.onnx", "joiner.int8.onnx", "tokens.txt"} + +// Les tailles sont celles publiées par le dépôt amont. Elles servent à informer +// avant un téléchargement, pas à vérifier le fichier : une taille qui dérive +// d'une release à l'autre ne doit pas casser la dictée. +var asrCatalogue = map[string]asrModel{ + "parakeet-tdt-0.6b-v3": { + Nom: "Parakeet v3 — recommandé", + Dossier: "sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8", + Octets: 487_170_055, + Langues: "25 langues européennes, français inclus — détection automatique", + }, + "parakeet-tdt-0.6b-v2": { + Nom: "Parakeet v2 — anglais seul", + Dossier: "sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8", + Octets: 482_468_385, + Langues: "anglais uniquement", + }, } -// whisperModelPathFor : chemin local. Vide si l'identifiant est inconnu — +// asrRoot : la racine des modèles de dictée. Volontairement distincte de +// l'ancien dossier `whisper/` : les .bin ggml qui y dorment ne servent plus à +// rien, mais les effacer tout seuls serait détruire des données que +// l'utilisateur n'a pas demandé de perdre. Ils sont à supprimer à la main. +func asrRoot() string { return filepath.Join(LokiHome(), "asr") } + +// asrModelDirFor : dossier local d'un modèle. Vide si l'identifiant est inconnu — // l'appelant doit traiter ce cas plutôt que de bâtir un chemin sur une chaîne // arbitraire venue d'une requête HTTP. -func whisperModelPathFor(id string) string { - m, ok := whisperCatalogue[id] +func asrModelDirFor(id string) string { + m, ok := asrCatalogue[id] if !ok { return "" } - return filepath.Join(LokiHome(), "whisper", m.Fichier) + return filepath.Join(asrRoot(), m.Dossier) } -func whisperModelURLFor(id string) string { - m, ok := whisperCatalogue[id] +func asrModelURLFor(id string) string { + m, ok := asrCatalogue[id] if !ok { return "" } - return whisperModelBase + m.Fichier + return asrModelBase + m.Dossier + ".tar.bz2" } -func whisperModelPresent(id string) bool { - p := whisperModelPathFor(id) - if p == "" { +// asrModelPresent : le modèle est installé ET complet. On vérifie les quatre +// fichiers plutôt que l'existence du dossier : une extraction coupée en deux +// laisse un dossier bien réel dont sherpa-onnx ne saura rien faire, et l'erreur +// tomberait au premier clic sur le micro plutôt qu'ici. +func asrModelPresent(id string) bool { + dir := asrModelDirFor(id) + if dir == "" { return false } - st, err := os.Stat(p) - return err == nil && st.Size() > 0 + for _, f := range asrFichiers { + st, err := os.Stat(filepath.Join(dir, f)) + if err != nil || st.Size() == 0 { + return false + } + } + return true } -// whisperCatalogueTrie : le catalogue pour l'UI, du plus léger au plus lourd — +// asrModelFile : chemin d'un des fichiers du modèle, pour les arguments passés +// au serveur. Vide si le modèle est inconnu. +func asrModelFile(id, fichier string) string { + dir := asrModelDirFor(id) + if dir == "" { + return "" + } + return filepath.Join(dir, fichier) +} + +// asrCatalogueTrie : le catalogue pour l'UI, du plus léger au plus lourd — // l'ordre dans lequel se pose la question du compromis. -func whisperCatalogueTrie() []map[string]any { - out := make([]map[string]any, 0, len(whisperCatalogue)) - for id, m := range whisperCatalogue { +func asrCatalogueTrie() []map[string]any { + out := make([]map[string]any, 0, len(asrCatalogue)) + for id, m := range asrCatalogue { out = append(out, map[string]any{ "id": id, "nom": m.Nom, "octets": m.Octets, - "vram_mo": m.VRAMMo, "present": whisperModelPresent(id), + "langues": m.Langues, "present": asrModelPresent(id), }) } sort.Slice(out, func(i, j int) bool { @@ -72,3 +122,101 @@ func whisperCatalogueTrie() []map[string]any { }) return out } + +// asrExtract décompresse une archive .tar.bz2 sous `dest`, en ne gardant que le +// contenu du dossier de premier niveau (l'archive amont l'embarque déjà). +// +// Deux précautions qui ne sont pas du zèle : +// - un chemin d'entrée qui remonte (« ../ ») ou absolu est REFUSÉ : une archive +// téléchargée est une entrée non fiable, et une entrée piégée écrirait +// n'importe où sur le disque du serveur ; +// - on extrait dans un dossier temporaire renommé à la fin, pour qu'une +// extraction interrompue ne laisse jamais un modèle à moitié installé sous +// son nom définitif. +func asrExtract(r io.Reader, dest string) error { + tmp := dest + ".part" + if err := os.RemoveAll(tmp); err != nil { + return err + } + if err := os.MkdirAll(tmp, 0o755); err != nil { + return err + } + tr := tar.NewReader(bzip2.NewReader(r)) + for { + h, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + _ = os.RemoveAll(tmp) + return err + } + rel, err := asrSafeRel(h.Name) + if err != nil { + _ = os.RemoveAll(tmp) + return err + } + if rel == "" { + continue // le dossier racine de l'archive lui-même + } + cible := filepath.Join(tmp, rel) + switch h.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(cible, 0o755); err != nil { + _ = os.RemoveAll(tmp) + return err + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(cible), 0o755); err != nil { + _ = os.RemoveAll(tmp) + return err + } + f, err := os.Create(cible) + if err != nil { + _ = os.RemoveAll(tmp) + return err + } + // #nosec G110 — l'archive vient d'une release GitHub épinglée par son + // nom, pas d'une source arbitraire ; et la borne utile ici est le + // disque, pas un plafond deviné. + if _, err := io.Copy(f, tr); err != nil { + _ = f.Close() + _ = os.RemoveAll(tmp) + return err + } + if err := f.Close(); err != nil { + _ = os.RemoveAll(tmp) + return err + } + default: + // Liens, périphériques, FIFO : une archive de modèle n'en a aucun + // besoin, et les suivre serait un autre moyen d'écrire hors du dossier. + continue + } + } + if err := os.RemoveAll(dest); err != nil { + _ = os.RemoveAll(tmp) + return err + } + return os.Rename(tmp, dest) +} + +// asrSafeRel retire le dossier de premier niveau d'un chemin d'archive et refuse +// tout ce qui sortirait du dossier de destination. Renvoie "" pour l'entrée du +// dossier racine elle-même. +func asrSafeRel(name string) (string, error) { + n := strings.ReplaceAll(strings.TrimSpace(name), `\`, "/") + if n == "" || strings.HasPrefix(n, "/") || filepath.IsAbs(n) { + return "", fmt.Errorf("entrée d'archive au chemin absolu : %q", name) + } + parts := strings.Split(strings.Trim(n, "/"), "/") + for _, p := range parts { + if p == ".." { + return "", fmt.Errorf("entrée d'archive qui remonte hors du dossier : %q", name) + } + } + if len(parts) <= 1 { + return "", nil // le dossier racine de l'archive + } + return filepath.Join(parts[1:]...), nil +} diff --git a/internal/loki/dictate_models_test.go b/internal/loki/dictate_models_test.go index e88dbc5..4dcc8f4 100644 --- a/internal/loki/dictate_models_test.go +++ b/internal/loki/dictate_models_test.go @@ -1,75 +1,158 @@ package loki import ( + "bytes" + "os" "path/filepath" "strings" "testing" ) -func TestCatalogueContientLeDefautEtLAncien(t *testing.T) { - // Le défaut doit exister, sinon dictateCfgSave refuse sa propre valeur par - // défaut et plus aucun réglage n'est enregistrable. - if _, ok := whisperCatalogue["large-v3-turbo-q5_0"]; !ok { - t.Error("le modèle par défaut large-v3-turbo-q5_0 est absent du catalogue") - } - // small-q5_1 est le modèle déjà téléchargé sur les installations - // existantes : le retirer le rendrait insélectionnable alors que son - // fichier est là. - if _, ok := whisperCatalogue["small-q5_1"]; !ok { - t.Error("small-q5_1 est absent : les installations existantes l'ont déjà sur disque") - } -} - -func TestModelPathEtURL(t *testing.T) { - testHome(t) - p := whisperModelPathFor("small-q5_1") - if filepath.Base(p) != "ggml-small-q5_1.bin" { - t.Errorf("chemin = %q, le fichier doit s'appeler ggml-small-q5_1.bin", p) - } - // Le chemin DOIT rester celui d'avant, sinon les installations existantes - // re-téléchargent 190 Mo pour rien. - if filepath.Base(filepath.Dir(p)) != "whisper" { - t.Errorf("chemin = %q, le dossier doit rester /whisper", p) - } - u := whisperModelURLFor("small-q5_1") - if !strings.HasSuffix(u, "/ggml-small-q5_1.bin") { - t.Errorf("URL = %q, doit finir par /ggml-small-q5_1.bin", u) - } - if !strings.HasPrefix(u, "https://") { - t.Errorf("URL = %q, doit être en HTTPS", u) - } -} - -func TestModelPathInconnuVide(t *testing.T) { - testHome(t) - if p := whisperModelPathFor("nawak"); p != "" { - t.Errorf("un modèle hors catalogue doit donner un chemin vide, pas %q", p) - } - if u := whisperModelURLFor("nawak"); u != "" { - t.Errorf("URL d'un modèle inconnu = %q, attendu vide", u) - } - if whisperModelPresent("nawak") { - t.Error("un modèle inconnu ne peut pas être présent") - } -} - -func TestCatalogueTrieParTaille(t *testing.T) { - testHome(t) - l := whisperCatalogueTrie() - if len(l) < 4 { - t.Fatalf("catalogue de %d entrées, au moins 4 attendues", len(l)) - } - var prec int64 - for i, m := range l { - o, _ := m["octets"].(int64) - if i > 0 && o < prec { - t.Errorf("entrée %d : catalogue non trié par taille croissante", i) +// Le catalogue nourrit une liste déroulante ET des chemins de fichiers : une +// entrée bancale ne se verrait qu'au premier clic sur le micro. +func TestCatalogueCoherent(t *testing.T) { + for id, m := range asrCatalogue { + if m.Nom == "" || m.Dossier == "" || m.Langues == "" { + t.Errorf("%s : entrée incomplète %+v", id, m) } - prec = o - for _, clef := range []string{"id", "nom", "octets", "vram_mo", "present"} { - if _, ok := m[clef]; !ok { - t.Errorf("entrée %d : clé %q manquante — l'UI en a besoin", i, clef) + if m.Octets <= 0 { + t.Errorf("%s : taille annoncée %d — la confirmation de téléchargement en a besoin", id, m.Octets) + } + if !strings.HasPrefix(asrModelURLFor(id), "https://") { + t.Errorf("%s : URL non https (%s)", id, asrModelURLFor(id)) + } + } + // Le défaut doit exister, et être celui qui parle français. + def := DictateCfg{}.withDefauts().Model + if _, ok := asrCatalogue[def]; !ok { + t.Fatalf("le modèle par défaut %q n'est pas au catalogue", def) + } + if !strings.Contains(asrCatalogue[def].Langues, "français") { + t.Errorf("le modèle par défaut (%s) ne parle pas français : %q", def, asrCatalogue[def].Langues) + } +} + +// Un identifiant hors catalogue ne doit JAMAIS produire de chemin : il vient +// d'une requête HTTP, et bâtir un chemin dessus ouvrirait le disque. +func TestCheminVideSiInconnu(t *testing.T) { + testHome(t) + if p := asrModelDirFor("../../etc"); p != "" { + t.Errorf("chemin bâti sur un identifiant inconnu : %q", p) + } + if u := asrModelURLFor("nawak"); u != "" { + t.Errorf("URL bâtie sur un identifiant inconnu : %q", u) + } + if f := asrModelFile("nawak", "encoder.int8.onnx"); f != "" { + t.Errorf("chemin de fichier bâti sur un identifiant inconnu : %q", f) + } +} + +// Un modèle n'est « présent » que COMPLET : une extraction coupée laisse un +// dossier bien réel dont sherpa-onnx ne saura rien faire, et l'erreur tomberait +// au premier clic sur le micro plutôt qu'ici. +func TestPresentExigeTousLesFichiers(t *testing.T) { + testHome(t) + const id = "parakeet-tdt-0.6b-v3" + dir := asrModelDirFor(id) + if asrModelPresent(id) { + t.Fatal("modèle annoncé présent alors que rien n'est installé") + } + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + for i, f := range asrFichiers { + if asrModelPresent(id) { + t.Fatalf("modèle annoncé présent avec seulement %d fichier(s) sur %d", i, len(asrFichiers)) + } + if err := os.WriteFile(filepath.Join(dir, f), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + if !asrModelPresent(id) { + t.Error("modèle complet non reconnu") + } + // Un fichier vide (disque plein en fin d'extraction) ne compte pas. + if err := os.WriteFile(filepath.Join(dir, asrFichiers[0]), nil, 0o644); err != nil { + t.Fatal(err) + } + if asrModelPresent(id) { + t.Error("un fichier vide passe pour un modèle installé") + } +} + +func TestCatalogueTriParTaille(t *testing.T) { + testHome(t) + l := asrCatalogueTrie() + if len(l) != len(asrCatalogue) { + t.Fatalf("%d entrées, attendu %d", len(l), len(asrCatalogue)) + } + for i := 1; i < len(l); i++ { + if l[i-1]["octets"].(int64) > l[i]["octets"].(int64) { + t.Error("catalogue non trié du plus léger au plus lourd") + } + } + for _, e := range l { + for _, clef := range []string{"id", "nom", "octets", "langues", "present"} { + if _, ok := e[clef]; !ok { + t.Errorf("clé %q manquante — l'UI en a besoin", clef) } } } } + +// ─── Extraction d'archive ──────────────────────────────────────────────────── + +// asrSafeRel est la seule chose entre une archive téléchargée et le disque du +// serveur. Une entrée qui remonte ou qui est absolue doit être REFUSÉE, pas +// nettoyée en silence. +func TestSafeRelRefuseLesEvasions(t *testing.T) { + for _, mauvais := range []string{ + "/etc/passwd", + "modele/../../etc/passwd", + "../dehors.txt", + `modele\..\..\dehors.txt`, + } { + if _, err := asrSafeRel(mauvais); err == nil { + t.Errorf("entrée d'archive acceptée alors qu'elle sort du dossier : %q", mauvais) + } + } + // Le dossier de premier niveau est retiré, le reste conservé. + got, err := asrSafeRel("sherpa-onnx-nemo-parakeet/encoder.int8.onnx") + if err != nil { + t.Fatal(err) + } + if got != "encoder.int8.onnx" { + t.Errorf("chemin = %q, le dossier racine de l'archive doit être retiré", got) + } + // L'entrée du dossier racine lui-même ne produit rien. + if got, err := asrSafeRel("sherpa-onnx-nemo-parakeet/"); err != nil || got != "" { + t.Errorf("racine de l'archive : (%q, %v), attendu (\"\", nil)", got, err) + } +} + +// Une extraction ratée ne doit jamais laisser un modèle à moitié installé sous +// son nom définitif : asrModelPresent le croirait bon et le serveur mourrait au +// démarrage. Elle ne doit pas non plus détruire un modèle déjà en place — le +// remplacement n'a lieu qu'une fois l'extraction terminée. +func TestExtractNeLaissePasDeDossierPartiel(t *testing.T) { + testHome(t) + dest := filepath.Join(t.TempDir(), "modele") + if err := os.MkdirAll(dest, 0o755); err != nil { + t.Fatal(err) + } + temoin := filepath.Join(dest, "deja-la.txt") + if err := os.WriteFile(temoin, []byte("modèle précédent"), 0o644); err != nil { + t.Fatal(err) + } + + // Un flux qui n'est pas du bzip2 : la lecture échoue dès la première entrée. + if err := asrExtract(bytes.NewReader([]byte("ceci n'est pas une archive bzip2")), dest); err == nil { + t.Fatal("un flux illisible doit produire une erreur") + } + if _, err := os.Stat(dest + ".part"); err == nil { + t.Error("le dossier temporaire n'a pas été nettoyé après l'échec") + } + if _, err := os.Stat(temoin); err != nil { + t.Error("le modèle précédent a été détruit par une extraction qui a échoué") + } +} diff --git a/internal/loki/dictate_routes_test.go b/internal/loki/dictate_routes_test.go index 1c93689..a1b4d10 100644 --- a/internal/loki/dictate_routes_test.go +++ b/internal/loki/dictate_routes_test.go @@ -9,7 +9,7 @@ import ( func TestConfigRouteAllerRetour(t *testing.T) { testHome(t) - corps := strings.NewReader(`{"model":"small-q5_1","lang":"en","device":"1","reactivity":"court"}`) + corps := strings.NewReader(`{"model":"parakeet-tdt-0.6b-v2","reactivity":"court"}`) rec := httptest.NewRecorder() handleDictateConfig(rec, httptest.NewRequest("POST", "/api/dictate/config", corps)) if rec.Code != 200 { @@ -21,7 +21,7 @@ func TestConfigRouteAllerRetour(t *testing.T) { if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatalf("réponse illisible : %v", err) } - if got.Device != "1" || got.Lang != "en" || got.Model != "small-q5_1" || got.Reactivity != "court" { + if got.Model != "parakeet-tdt-0.6b-v2" || got.Reactivity != "court" { t.Errorf("relu %+v, attendu le réglage enregistré", got) } } @@ -30,7 +30,7 @@ func TestConfigRouteAllerRetour(t *testing.T) { // un 200 et croit son réglage pris en compte. func TestConfigRouteRefuseModeleInconnu(t *testing.T) { testHome(t) - corps := strings.NewReader(`{"model":"nawak","lang":"fr","device":"cpu","reactivity":"moyen"}`) + corps := strings.NewReader(`{"model":"nawak","reactivity":"moyen"}`) rec := httptest.NewRecorder() handleDictateConfig(rec, httptest.NewRequest("POST", "/api/dictate/config", corps)) if rec.Code == 200 { @@ -58,12 +58,12 @@ func TestModelsRoute(t *testing.T) { if err := json.Unmarshal(rec.Body.Bytes(), &l); err != nil { t.Fatalf("réponse illisible : %v", err) } - if len(l) < 4 { - t.Errorf("%d modèles renvoyés, au moins 4 attendus", len(l)) + if len(l) < 2 { + t.Errorf("%d modèles renvoyés, au moins 2 attendus", len(l)) } } -// Un identifiant inconnu ne doit pas lancer de téléchargement : whisperDownload +// Un identifiant inconnu ne doit pas lancer de téléchargement : asrDownload // bâtirait une URL vide et le message d'erreur n'arriverait qu'en arrière-plan. func TestDownloadRouteRefuseInconnu(t *testing.T) { testHome(t) @@ -93,7 +93,7 @@ func TestStateRoute(t *testing.T) { } } -// Un audio trop court est rejeté AVANT toute tentative de lancer whisper-server : +// Un audio trop court est rejeté AVANT toute tentative de lancer le serveur : // inutile de charger un modèle pour un clic parasite. func TestTranscribeAudioTropCourt(t *testing.T) { testHome(t) diff --git a/internal/loki/dictate_server.go b/internal/loki/dictate_server.go index d148ada..18a209d 100644 --- a/internal/loki/dictate_server.go +++ b/internal/loki/dictate_server.go @@ -1,21 +1,23 @@ -// Supervision de whisper-server : le modèle est chargé UNE fois et reste en -// mémoire, au lieu d'être relu depuis le disque à chaque phrase dictée. +// Supervision du serveur de transcription : le modèle est chargé UNE fois et +// reste en mémoire, au lieu d'être relu depuis le disque à chaque phrase dictée. // -// L'ancien chemin lançait whisper-cli par requête. Le chargement du modèle -// dominait le temps de réponse (~3 s pour 3,4 s d'audio), et découper la -// dictée en tranches par-dessus ce mécanisme aurait rechargé le modèle toutes -// les quelques secondes. +// Le moteur est sherpa-onnx (Parakeet, NVIDIA), qui a remplacé whisper.cpp. Ce +// qui n'a PAS changé : un processus local supervisé, arrêté après un temps +// d'inactivité, redémarré quand les réglages bougent. Ce qui a changé : le +// dialogue. whisper-server exposait du HTTP multipart ; sherpa-onnx n'expose +// qu'un WebSocket, dont le protocole tient en deux entiers et des flottants +// (voir asrInferSur). package loki import ( "bytes" "context" + "encoding/binary" "encoding/json" + "errors" "fmt" - "io" - "mime/multipart" + "math" "net" - "net/http" "os" "os/exec" "strconv" @@ -23,22 +25,16 @@ import ( "sync" "sync/atomic" "time" + + "github.com/coder/websocket" ) -// whisperServerBin : deux binaires sont livrés, pas un. -// -// L'image runtime peut être bâtie sur la variante CPU de llama.cpp -// (LLAMACPP_IMAGE=ghcr.io/ggml-org/llama.cpp:server). Sur cette base les -// bibliothèques CUDA sont absentes, et un binaire lié à CUDA ne démarre pas du -// tout : l'éditeur de liens échoue avant la première instruction, il n'y a -// donc aucun repli possible depuis l'intérieur du programme. D'où deux -// binaires et un choix fait ici. -func whisperServerBin(gpu bool) string { - env, defaut := "LOKI_WHISPER_SERVER_CPU", "/usr/local/bin/whisper-server-cpu" - if gpu { - env, defaut = "LOKI_WHISPER_SERVER_CUDA", "/usr/local/bin/whisper-server-cuda" - } - for _, p := range []string{os.Getenv(env), defaut} { +// asrServerBin : le binaire du serveur de transcription. Un seul, contrairement +// aux deux binaires whisper d'avant (CPU et CUDA) : le build sherpa-onnx livré +// est le statique CPU, il n'a donc aucune dépendance CUDA à satisfaire et +// démarre sur n'importe quelle base d'image. +func asrServerBin() string { + for _, p := range []string{os.Getenv("LOKI_ASR_SERVER"), "/usr/local/bin/sherpa-onnx-offline-websocket-server"} { if p == "" { continue } @@ -46,36 +42,40 @@ func whisperServerBin(gpu bool) string { return p } } - if gpu { - // Pas de binaire CUDA : l'appelant retombe sur le CPU en le disant. - return "" - } - if p, err := exec.LookPath("whisper-server"); err == nil { + if p, err := exec.LookPath("sherpa-onnx-offline-websocket-server"); err == nil { return p } return "" } -func whisperServerArgs(c DictateCfg, port int) ([]string, error) { - model := whisperModelPathFor(c.Model) - if model == "" { +// asrServerArgs. Les arguments de sherpa-onnx s'écrivent --clef=valeur : la +// forme séparée par une espace n'est PAS reconnue et le serveur démarre alors +// sans modèle. +func asrServerArgs(c DictateCfg, port int) ([]string, error) { + if asrModelDirFor(c.Model) == "" { return nil, fmt.Errorf("modèle de dictée inconnu : %s", c.Model) } return []string{ - "-m", model, - "-l", c.Lang, - // 127.0.0.1 et pas 0.0.0.0 : whisper-server n'a aucune - // authentification. Sur un conteneur au réseau partagé, l'exposer - // offrirait la transcription à tout le réseau. - "--host", "127.0.0.1", - "--port", strconv.Itoa(port), + "--encoder=" + asrModelFile(c.Model, "encoder.int8.onnx"), + "--decoder=" + asrModelFile(c.Model, "decoder.int8.onnx"), + "--joiner=" + asrModelFile(c.Model, "joiner.int8.onnx"), + "--tokens=" + asrModelFile(c.Model, "tokens.txt"), + // nemo_transducer : sans ce type, les sorties du modèle sont mal + // interprétées et la transcription est du bruit, pas une erreur. + "--model-type=nemo_transducer", + "--port=" + strconv.Itoa(port), + // Le serveur n'a AUCUNE authentification. Il n'écoute donc que la boucle + // locale : sur un conteneur au réseau partagé, l'exposer offrirait la + // transcription à tout le réseau. + "--host=127.0.0.1", }, nil } -// whisperServerEnv pose CUDA_VISIBLE_DEVICES en REMPLAÇANT toute valeur déjà -// présente. Une variable dupliquée laisse le gagnant dépendre de la libc, et -// le choix de GPU deviendrait un coup de dé. -func whisperServerEnv(c DictateCfg, base []string) []string { +// asrServerEnv masque tous les GPU, en REMPLAÇANT toute valeur déjà présente. +// Une variable dupliquée laisse le gagnant dépendre de la libc. Le binaire livré +// est CPU de toute façon ; la variable est là pour que ça reste vrai si l'image +// venait à embarquer un build GPU sans qu'on repense à la dictée. +func asrServerEnv(base []string) []string { const clef = "CUDA_VISIBLE_DEVICES=" out := make([]string, 0, len(base)+1) for _, v := range base { @@ -83,15 +83,13 @@ func whisperServerEnv(c DictateCfg, base []string) []string { out = append(out, v) } } - // Valeur vide en mode CPU : masque TOUS les GPU. Retirer la variable - // ferait l'inverse — ggml les verrait tous. - return append(out, clef+c.cudaVisibleDevices()) + return append(out, clef) } // portLibre demande au système un port disponible puis le relâche. Coder un // port en dur casserait sur une machine qui l'occupe déjà ; la fenêtre entre -// la fermeture et la reprise par whisper-server est négligeable devant le -// risque d'un conflit permanent. +// la fermeture et la reprise par le serveur est négligeable devant le risque +// d'un conflit permanent. func portLibre() (int, error) { l, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { @@ -101,11 +99,11 @@ func portLibre() (int, error) { return l.Addr().(*net.TCPAddr).Port, nil } -// whisperMarqueur reconnaît les annotations que whisper produit à la place -// d'un texte : [BLANK_AUDIO], (silence), [SOUND]… Un contenu entièrement -// entouré de crochets ou de parenthèses n'est jamais de la parole dictée, et -// le laisser passer le collerait tel quel dans le champ de saisie. -func whisperMarqueur(s string) bool { +// asrMarqueur reconnaît ce qui n'est pas de la parole dictée : le vide, et les +// annotations entre crochets ou parenthèses. Parakeet en produit beaucoup moins +// que whisper, mais le filtre ne coûte rien et un « (silence) » collé dans le +// champ de saisie se remarque tout de suite. +func asrMarqueur(s string) bool { s = strings.TrimSpace(s) if s == "" { return true @@ -114,67 +112,173 @@ func whisperMarqueur(s string) bool { (strings.HasPrefix(s, "(") && strings.HasSuffix(s, ")")) } -// whisperInferSur prend l'URL de base en paramètre : c'est ce qui rend l'appel -// testable sans lancer de processus. -func whisperInferSur(ctx context.Context, base string, wav []byte) (string, error) { - var corps bytes.Buffer - mw := multipart.NewWriter(&corps) - part, err := mw.CreateFormFile("file", "dictee.wav") +// ─── Protocole ─────────────────────────────────────────────────────────────── + +// asrEnteteOctets : l'en-tête du serveur sherpa-onnx, deux entiers 32 bits en +// petit-boutien — la fréquence d'échantillonnage, puis le NOMBRE D'OCTETS +// d'audio annoncé. Le serveur décode dès qu'il a reçu exactement ce nombre : un +// compte faux, et il attend indéfiniment de quoi finir. +const asrEnteteOctets = 8 + +// asrTrameMax borne la taille d'une trame WebSocket envoyée. Le serveur +// reconstitue l'utterance à partir du compte annoncé, donc le découpage est +// libre — mais pas la TAILLE : une pile WebSocket refuse par défaut les messages +// au-delà de quelques dizaines de kilooctets et ferme la connexion au lieu de +// répondre. 10 Ko est la valeur qu'emploie le client de référence de +// sherpa-onnx ; on s'aligne dessus plutôt que de parier sur une limite. +const asrTrameMax = 10240 + +// asrInferSur prend l'adresse du serveur en paramètre : c'est ce qui rend +// l'appel testable sans lancer de processus. +// +// Le protocole, en entier : on ouvre une connexion, on envoie UNE trame binaire +// contenant l'en-tête suivi des échantillons en float32 petit-boutien, et on lit +// UNE trame texte qui porte le résultat en JSON. +func asrInferSur(ctx context.Context, adresse string, wav []byte) (string, error) { + taux, ech, err := wavVersFloat32(wav) if err != nil { return "", err } - if _, err := part.Write(wav); err != nil { - return "", err + if len(ech) == 0 { + return "", nil // rien à transcrire : ce n'est pas une erreur } - if err := mw.WriteField("response_format", "json"); err != nil { - return "", err - } - if err := mw.Close(); err != nil { - return "", err - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/inference", &corps) + c, _, err := websocket.Dial(ctx, adresse, nil) if err != nil { - return "", err + return "", fmt.Errorf("connexion au serveur de dictée : %w", err) } - req.Header.Set("Content-Type", mw.FormDataContentType()) - resp, err := http.DefaultClient.Do(req) + defer func() { _ = c.CloseNow() }() + // Une transcription peut légitimement dépasser la taille par défaut acceptée + // en lecture ; on ne borne pas ce que le serveur nous renvoie. + c.SetReadLimit(-1) + + corps := make([]byte, asrEnteteOctets+4*len(ech)) + binary.LittleEndian.PutUint32(corps[0:4], uint32(taux)) + binary.LittleEndian.PutUint32(corps[4:8], uint32(4*len(ech))) + for i, v := range ech { + binary.LittleEndian.PutUint32(corps[asrEnteteOctets+4*i:], math.Float32bits(v)) + } + for off := 0; off < len(corps); off += asrTrameMax { + fin := off + asrTrameMax + if fin > len(corps) { + fin = len(corps) + } + if err := c.Write(ctx, websocket.MessageBinary, corps[off:fin]); err != nil { + return "", fmt.Errorf("envoi de l'audio : %w", err) + } + } + _, brut, err := c.Read(ctx) if err != nil { - return "", err - } - defer func() { _ = resp.Body.Close() }() - brut, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) - if resp.StatusCode != 200 { - return "", fmt.Errorf("whisper-server a répondu %d : %s", resp.StatusCode, lastLine(string(brut))) + return "", fmt.Errorf("lecture du résultat : %w", err) } var j struct { Text string `json:"text"` } if err := json.Unmarshal(brut, &j); err != nil { - return "", fmt.Errorf("réponse illisible de whisper-server : %w", err) + return "", fmt.Errorf("réponse illisible du serveur de dictée : %w", err) } - if whisperMarqueur(j.Text) { + if asrMarqueur(j.Text) { return "", nil } return strings.TrimSpace(j.Text), nil } +// wavVersFloat32 lit le WAV produit par le navigateur (16 kHz mono) et rend ses +// échantillons normalisés dans [-1, 1], ce qu'attend sherpa-onnx. +// +// On parcourt les blocs plutôt que de sauter à un offset fixe : un WAV n'a pas +// d'en-tête de taille garantie (« LIST », « fact » et consorts s'intercalent +// avant « data »), et l'offset 44 codé en dur transformerait ces octets en +// craquement au début de chaque phrase. +func wavVersFloat32(wav []byte) (taux int, ech []float32, err error) { + if len(wav) < 12 || string(wav[0:4]) != "RIFF" || string(wav[8:12]) != "WAVE" { + return 0, nil, errors.New("audio illisible : ce n'est pas un WAV") + } + var canaux, bits, format int + pos := 12 + for pos+8 <= len(wav) { + id := string(wav[pos : pos+4]) + taille := int(binary.LittleEndian.Uint32(wav[pos+4 : pos+8])) + corps := pos + 8 + if taille < 0 || corps+taille > len(wav) { + // Bloc annoncé plus grand que le fichier : on prend ce qui reste + // plutôt que d'échouer — un enregistrement coupé net reste utile. + taille = len(wav) - corps + } + switch id { + case "fmt ": + if taille < 16 { + return 0, nil, errors.New("audio illisible : bloc fmt tronqué") + } + format = int(binary.LittleEndian.Uint16(wav[corps : corps+2])) + canaux = int(binary.LittleEndian.Uint16(wav[corps+2 : corps+4])) + taux = int(binary.LittleEndian.Uint32(wav[corps+4 : corps+8])) + bits = int(binary.LittleEndian.Uint16(wav[corps+14 : corps+16])) + case "data": + if canaux <= 0 || taux <= 0 { + return 0, nil, errors.New("audio illisible : bloc data avant fmt") + } + ech, err = pcmVersFloat32(wav[corps:corps+taille], format, bits, canaux) + if err != nil { + return 0, nil, err + } + return taux, ech, nil + } + // Les blocs sont alignés sur un nombre pair d'octets. + pos = corps + taille + taille%2 + } + return 0, nil, errors.New("audio illisible : aucun bloc data") +} + +// pcmVersFloat32 convertit les échantillons bruts. Les canaux sont MOYENNÉS +// plutôt que de garder le premier : un navigateur qui livrerait du stéréo par +// erreur donnerait sinon un canal muet une fois sur deux. +func pcmVersFloat32(data []byte, format, bits, canaux int) ([]float32, error) { + const pcmEntier, pcmFlottant = 1, 3 + switch { + case format == pcmEntier && bits == 16: + n := len(data) / 2 / canaux + out := make([]float32, n) + for i := 0; i < n; i++ { + somme := 0.0 + for c := 0; c < canaux; c++ { + v := int16(binary.LittleEndian.Uint16(data[2*(i*canaux+c):])) + somme += float64(v) / 32768 + } + out[i] = float32(somme / float64(canaux)) + } + return out, nil + case format == pcmFlottant && bits == 32: + n := len(data) / 4 / canaux + out := make([]float32, n) + for i := 0; i < n; i++ { + somme := 0.0 + for c := 0; c < canaux; c++ { + somme += float64(math.Float32frombits(binary.LittleEndian.Uint32(data[4*(i*canaux+c):]))) + } + out[i] = float32(somme / float64(canaux)) + } + return out, nil + } + return nil, fmt.Errorf("format audio non géré (format %d, %d bits) — attendu PCM 16 bits", format, bits) +} + // ─── Supervision ───────────────────────────────────────────────────────────── -// whisperIdle : au-delà, le serveur s'éteint et rend sa VRAM. Une dictée n'est +// asrIdle : au-delà, le serveur s'éteint et rend sa mémoire. Une dictée n'est // pas un service permanent ; garder un modèle chargé toute la journée pour -// quelques phrases prive le moteur de chat de mémoire GPU. -const whisperIdle = 10 * time.Minute +// quelques phrases prive la machine de mémoire vive. +const asrIdle = 10 * time.Minute -// whisperReady : plafond d'attente au démarrage. Un modèle de 1 Go lu depuis un -// disque lent puis poussé sur le GPU prend du temps ; échouer trop tôt -// afficherait une panne là où il n'y a qu'une lenteur. -const whisperReady = 120 * time.Second +// asrReady : plafond d'attente au démarrage. Un modèle de 600 Mo lu depuis un +// disque lent prend du temps ; échouer trop tôt afficherait une panne là où il +// n'y a qu'une lenteur. +const asrReady = 120 * time.Second var wsrvMu sync.Mutex // wsrvDemarrage : le processus en cours de démarrage, visible SANS le verrou. -// whisperStartLocked garde wsrvMu pendant tout le chargement du modèle ; c'est -// la seule poignée qu'un autre appelant (whisperShutdownVite) a sur lui pour +// asrStartLocked garde wsrvMu pendant tout le chargement du modèle ; c'est la +// seule poignée qu'un autre appelant (asrShutdownVite) a sur lui pour // l'interrompre. Nil hors démarrage. var wsrvDemarrage atomic.Pointer[exec.Cmd] @@ -184,114 +288,101 @@ var wsrv struct { cfg DictateCfg log *bytes.Buffer timer *time.Timer - note string // pourquoi on ne tourne pas sur le matériel demandé depuis time.Time } -// whisperEnsure garantit un serveur vivant pour la configuration COURANTE, et +// asrEnsure garantit un serveur vivant pour la configuration COURANTE, et // renvoie son port. Si les réglages ont changé depuis le lancement, l'ancien -// est arrêté : c'est ce qui rend un changement de modèle ou de GPU effectif -// sans redémarrer Loki. -func whisperEnsure() (int, error) { +// est arrêté : c'est ce qui rend un changement de modèle effectif sans +// redémarrer Loki. +func asrEnsure() (int, error) { cfg := dictateCfgLoad() wsrvMu.Lock() defer wsrvMu.Unlock() if wsrv.cmd != nil && wsrv.cfg == cfg && wsrv.cmd.ProcessState == nil { - whisperTouchLocked() + asrTouchLocked() return wsrv.port, nil } - whisperStopLocked() - return whisperStartLocked(cfg) + asrStopLocked() + return asrStartLocked(cfg) } -func whisperStartLocked(cfg DictateCfg) (int, error) { - if !whisperModelPresent(cfg.Model) { +func asrStartLocked(cfg DictateCfg) (int, error) { + if !asrModelPresent(cfg.Model) { return 0, fmt.Errorf("modèle de dictée absent : %s (à télécharger dans Paramètres → Dictée)", cfg.Model) } - note := "" - gpu := cfg.cudaVisibleDevices() != "" - bin := whisperServerBin(gpu) - if bin == "" && gpu { - // Image bâtie sans CUDA : on le DIT, au lieu de laisser croire que le - // GPU choisi est utilisé. - note = "binaire CUDA absent de cette image — dictée sur le processeur" - gpu = false - cfg.Device = "cpu" - bin = whisperServerBin(false) - } + bin := asrServerBin() if bin == "" { - return 0, fmt.Errorf("whisper-server introuvable (image à reconstruire)") + return 0, fmt.Errorf("serveur de dictée introuvable (image à reconstruire)") } port, err := portLibre() if err != nil { - return 0, fmt.Errorf("aucun port libre pour whisper-server : %w", err) + return 0, fmt.Errorf("aucun port libre pour le serveur de dictée : %w", err) } - args, err := whisperServerArgs(cfg, port) + args, err := asrServerArgs(cfg, port) if err != nil { return 0, err } log := &bytes.Buffer{} cmd := exec.Command(bin, args...) - cmd.Env = whisperServerEnv(cfg, os.Environ()) + cmd.Env = asrServerEnv(os.Environ()) cmd.Stdout, cmd.Stderr = log, log if err := cmd.Start(); err != nil { - return 0, fmt.Errorf("démarrage de whisper-server : %w", err) + return 0, fmt.Errorf("démarrage du serveur de dictée : %w", err) } - wsrv.cmd, wsrv.port, wsrv.cfg = cmd, port, dictateCfgLoad() - wsrv.log, wsrv.note, wsrv.depuis = log, note, time.Now() + wsrv.cmd, wsrv.port, wsrv.cfg = cmd, port, cfg + wsrv.log, wsrv.depuis = log, time.Now() wsrvDemarrage.Store(cmd) - err = whisperAttendPret(cmd, port, log) + err = asrAttendPret(cmd, port, log) wsrvDemarrage.Store(nil) if err != nil { - whisperStopLocked() + asrStopLocked() return 0, err } - whisperTouchLocked() + asrTouchLocked() return port, nil } -// whisperAttendPret sonde le serveur jusqu'à ce qu'il réponde. Si le processus -// meurt entre-temps, on rapporte COMMENT il est mort : sur une mort par -// signal, la dernière ligne du journal est celle d'avant le coup fatal — elle -// ressemble à une explication sans en être une (leçon de la PR #18). -func whisperAttendPret(cmd *exec.Cmd, port int, log *bytes.Buffer) error { - fin := time.Now().Add(whisperReady) - url := "http://127.0.0.1:" + strconv.Itoa(port) + "/" +// asrAttendPret sonde le serveur jusqu'à ce qu'il accepte une connexion. Le +// serveur sherpa-onnx construit son moteur AVANT d'ouvrir sa socket : le simple +// fait qu'elle réponde signifie donc que le modèle est chargé — inutile de lui +// envoyer un audio de test. +// +// Si le processus meurt entre-temps, on rapporte COMMENT il est mort : sur une +// mort par signal, la dernière ligne du journal est celle d'avant le coup fatal — +// elle ressemble à une explication sans en être une. +func asrAttendPret(cmd *exec.Cmd, port int, log *bytes.Buffer) error { + fin := time.Now().Add(asrReady) + adr := "127.0.0.1:" + strconv.Itoa(port) mort := make(chan error, 1) go func() { mort <- cmd.Wait() }() for time.Now().Before(fin) { select { case err := <-mort: - msg := whisperExitReason(err) + msg := asrExitReason(err) if l := lastLine(log.String()); l != "" { msg += " — dernière sortie : " + l } - return fmt.Errorf("whisper-server s'est arrêté au démarrage : %s", msg) + return fmt.Errorf("le serveur de dictée s'est arrêté au démarrage : %s", msg) case <-time.After(200 * time.Millisecond): } - req, err := http.NewRequest(http.MethodGet, url, nil) - if err != nil { - return err - } - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - resp, err := http.DefaultClient.Do(req.WithContext(ctx)) - cancel() + conn, err := net.DialTimeout("tcp", adr, 2*time.Second) if err == nil { - _ = resp.Body.Close() + _ = conn.Close() return nil } } - return fmt.Errorf("whisper-server n'a pas répondu en %s", whisperReady) + return fmt.Errorf("le serveur de dictée n'a pas répondu en %s", asrReady) } -func whisperTouchLocked() { +func asrTouchLocked() { if wsrv.timer != nil { wsrv.timer.Stop() } - wsrv.timer = time.AfterFunc(whisperIdle, whisperShutdown) + wsrv.timer = time.AfterFunc(asrIdle, asrShutdown) } -func whisperStopLocked() { +func asrStopLocked() { if wsrv.timer != nil { wsrv.timer.Stop() wsrv.timer = nil @@ -300,32 +391,32 @@ func whisperStopLocked() { _ = wsrv.cmd.Process.Kill() } wsrv.cmd, wsrv.port, wsrv.log = nil, 0, nil - wsrv.cfg, wsrv.note = DictateCfg{}, "" + wsrv.cfg = DictateCfg{} } -// whisperShutdown arrête le serveur. Appelé par la minuterie d'inactivité et -// à chaque changement de réglages — sans ça, le serveur continuerait de -// tourner avec l'ancien modèle et le nouveau réglage semblerait sans effet. -func whisperShutdown() { +// asrShutdown arrête le serveur. Appelé par la minuterie d'inactivité et à +// chaque changement de réglages — sans ça, le serveur continuerait de tourner +// avec l'ancien modèle et le nouveau réglage semblerait sans effet. +func asrShutdown() { wsrvMu.Lock() defer wsrvMu.Unlock() - whisperStopLocked() + asrStopLocked() } -// whisperShutdownVite arrête le serveur sans jamais attendre derrière un -// démarrage. whisperShutdown prend wsrvMu, et whisperEnsure le garde pendant -// tout le chargement du modèle — jusqu'à whisperReady (deux minutes) depuis un -// disque lent. Appelé par un geste de l'interface (libérer la VRAM), ce blocage -// ferait abandonner le navigateur alors que le moteur, lui, est déjà arrêté. +// asrShutdownVite arrête le serveur sans jamais attendre derrière un démarrage. +// asrShutdown prend wsrvMu, et asrEnsure le garde pendant tout le chargement du +// modèle — jusqu'à asrReady (deux minutes) depuis un disque lent. Appelé par un +// geste de l'interface, ce blocage ferait abandonner le navigateur alors que le +// moteur, lui, est déjà arrêté. // -// Si le verrou est pris, on tue le processus en train de démarrer : sa mort -// fait sortir whisperAttendPret, le verrou se libère, et on finit le ménage -// par la voie normale. La dictée qui attendait reçoit une erreur de démarrage — -// c'est l'utilisateur qui vient de demander la carte, pas une panne. -func whisperShutdownVite() { +// Si le verrou est pris, on tue le processus en train de démarrer : sa mort fait +// sortir asrAttendPret, le verrou se libère, et on finit le ménage par la voie +// 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() { for i := 0; i < 20; i++ { if wsrvMu.TryLock() { - whisperStopLocked() + asrStopLocked() wsrvMu.Unlock() return } @@ -336,25 +427,26 @@ func whisperShutdownVite() { } } -// whisperInfer garantit le serveur puis lui soumet le WAV. -func whisperInfer(ctx context.Context, wav []byte) (string, error) { - port, err := whisperEnsure() +// asrInfer garantit le serveur puis lui soumet le WAV. +func asrInfer(ctx context.Context, wav []byte) (string, error) { + port, err := asrEnsure() if err != nil { return "", err } - return whisperInferSur(ctx, "http://127.0.0.1:"+strconv.Itoa(port), wav) + return asrInferSur(ctx, "ws://127.0.0.1:"+strconv.Itoa(port), wav) } -func whisperEtat() map[string]any { +func asrEtat() map[string]any { cfg := dictateCfgLoad() wsrvMu.Lock() defer wsrvMu.Unlock() etat := map[string]any{ "actif": wsrv.cmd != nil, "modele": cfg.Model, - "present": whisperModelPresent(cfg.Model), - "device": cfg.Device, - "note": wsrv.note, + "present": asrModelPresent(cfg.Model), + // Le moteur livré est le build statique CPU (voir asrServerBin) : le dire + // vaut mieux que laisser chercher un réglage de carte qui n'existe pas. + "device": "cpu", } if wsrv.cmd != nil { etat["depuis_s"] = int(time.Since(wsrv.depuis).Seconds()) diff --git a/internal/loki/dictate_server_test.go b/internal/loki/dictate_server_test.go index ea68d8e..b5af885 100644 --- a/internal/loki/dictate_server_test.go +++ b/internal/loki/dictate_server_test.go @@ -2,99 +2,130 @@ package loki import ( "context" + "encoding/binary" + "encoding/json" + "math" "net" "net/http" "net/http/httptest" "strconv" "strings" "testing" + "time" + + "github.com/coder/websocket" ) -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 +// wavTest fabrique un WAV 16 kHz mono PCM 16 bits, comme celui que le +// navigateur envoie. +func wavTest(ech []int16, taux int) []byte { + data := make([]byte, 2*len(ech)) + for i, v := range ech { + binary.LittleEndian.PutUint16(data[2*i:], uint16(v)) + } + b := make([]byte, 0, 44+len(data)) + ajout32 := func(v uint32) { + var x [4]byte + binary.LittleEndian.PutUint32(x[:], v) + b = append(b, x[:]...) + } + ajout16 := func(v uint16) { + var x [2]byte + binary.LittleEndian.PutUint16(x[:], v) + b = append(b, x[:]...) + } + b = append(b, "RIFF"...) + ajout32(uint32(36 + len(data))) + b = append(b, "WAVE"...) + b = append(b, "fmt "...) + ajout32(16) + ajout16(1) // PCM entier + ajout16(1) // mono + ajout32(uint32(taux)) + ajout32(uint32(taux * 2)) + ajout16(2) // alignement de bloc + ajout16(16) // bits par échantillon + b = append(b, "data"...) + ajout32(uint32(len(data))) + return append(b, data...) +} + +func TestServerArgsFormeCleValeur(t *testing.T) { + testHome(t) + args, err := asrServerArgs(DictateCfg{Model: "parakeet-tdt-0.6b-v3"}, 4242) + if err != nil { + t.Fatal(err) + } + joint := strings.Join(args, " ") + // sherpa-onnx ne reconnaît QUE --clef=valeur. La forme séparée par une + // espace le laisse démarrer sans modèle, sans se plaindre. + for _, a := range args { + if !strings.HasPrefix(a, "--") || !strings.Contains(a, "=") { + t.Errorf("argument %q : sherpa-onnx exige la forme --clef=valeur", a) + } + } + for _, attendu := range []string{"--model-type=nemo_transducer", "--host=127.0.0.1", "--port=4242"} { + if !strings.Contains(joint, attendu) { + t.Errorf("argument manquant : %s\nobtenu : %s", attendu, joint) + } + } + // Les quatre fichiers du modèle, sinon le serveur démarre puis meurt. + for _, f := range asrFichiers { + if !strings.Contains(joint, f) { + t.Errorf("le fichier %s du modèle n'est pas passé au serveur", f) } } - return "", false } -func TestServerArgs(t *testing.T) { +func TestServerArgsRefuseModeleInconnu(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) + if _, err := asrServerArgs(DictateCfg{Model: "nawak"}, 1234); err == nil { + t.Error("un modèle hors catalogue doit être refusé avant le lancement") } } -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") +// Le serveur ne doit jamais prendre une carte : le binaire livré est le build +// CPU, et la VRAM appartient au moteur de chat. +func TestServerEnvMasqueLesGPU(t *testing.T) { + got := asrServerEnv([]string{"PATH=/bin", "CUDA_VISIBLE_DEVICES=0,1", "HOME=/root"}) + n := 0 + for _, v := range got { + if strings.HasPrefix(v, "CUDA_VISIBLE_DEVICES=") { + n++ + if v != "CUDA_VISIBLE_DEVICES=" { + t.Errorf("CUDA_VISIBLE_DEVICES = %q, attendu vide", v) + } + } } -} - -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 { + // Une variable en double laisse le gagnant dépendre de la libc. + if n != 1 { t.Errorf("%d occurrences de CUDA_VISIBLE_DEVICES, attendu exactement 1", n) } - if !contientExact(env, "PATH=/usr/bin") { + if !contientTout(got, "PATH=/bin", "HOME=/root") { 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 +func contientTout(l []string, vals ...string) bool { + for _, v := range vals { + trouve := false + for _, x := range l { + if x == v { + trouve = true + break + } + } + if !trouve { + return false } } - return false -} - -func compte(l []string, prefixe string) int { - n := 0 - for _, v := range l { - if strings.HasPrefix(v, prefixe) { - n++ - } - } - return n + return true } func TestPortLibre(t *testing.T) { p, err := portLibre() if err != nil { - t.Fatalf("portLibre : %v", err) + t.Fatal(err) } if p < 1024 || p > 65535 { t.Errorf("port = %d, hors de la plage utilisable", p) @@ -106,40 +137,167 @@ func TestPortLibre(t *testing.T) { _ = 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() +// ─── Conversion WAV → float32 ──────────────────────────────────────────────── - txt, err := whisperInferSur(context.Background(), srv.URL, []byte("RIFFfaux")) +// L'offset 44 codé en dur est le piège classique : un WAV n'a pas d'en-tête de +// taille garantie, et des blocs (LIST, fact…) s'intercalent avant `data`. Les +// prendre pour de l'audio produit un craquement au début de chaque phrase. +func TestWavIgnoreLesBlocsIntercales(t *testing.T) { + base := wavTest([]int16{0, 16384, -16384, 32767}, 16000) + // Insère un bloc « LIST » de 6 octets juste après l'en-tête RIFF/WAVE. + extra := append([]byte("LIST"), 6, 0, 0, 0, 'I', 'N', 'F', 'O', 'x', 'y') + avec := append(append(append([]byte{}, base[:12]...), extra...), base[12:]...) + binary.LittleEndian.PutUint32(avec[4:8], uint32(len(avec)-8)) + + taux, ech, err := wavVersFloat32(avec) if err != nil { - t.Fatalf("whisperInferSur : %v", err) + t.Fatalf("wavVersFloat32 : %v", err) + } + if taux != 16000 { + t.Errorf("taux = %d, attendu 16000", taux) + } + if len(ech) != 4 { + t.Fatalf("%d échantillons, attendu 4 — un bloc intercalé a été pris pour de l'audio", len(ech)) + } + if ech[0] != 0 || math.Abs(float64(ech[1])-0.5) > 0.001 { + t.Errorf("échantillons mal normalisés : %v", ech) + } +} + +func TestWavRefuseCeQuiNEnEstPas(t *testing.T) { + for nom, in := range map[string][]byte{ + "vide": {}, + "pas un WAV": []byte("ceci n'est pas un fichier audio du tout"), + "sans data": append([]byte("RIFF\x00\x00\x00\x00WAVE"), "fmt "...), + } { + if _, _, err := wavVersFloat32(in); err == nil { + t.Errorf("%s : accepté alors qu'il est illisible", nom) + } + } +} + +// Le stéréo doit être MOYENNÉ, pas réduit au premier canal : un navigateur qui +// livrerait deux canaux donnerait sinon un silence une fois sur deux. +func TestPcmStereoMoyenne(t *testing.T) { + data := make([]byte, 8) // deux trames stéréo + binary.LittleEndian.PutUint16(data[0:], uint16(int16(0))) + binary.LittleEndian.PutUint16(data[2:], uint16(int16(32767))) + binary.LittleEndian.PutUint16(data[4:], uint16(0x8000)) // -32768 + binary.LittleEndian.PutUint16(data[6:], uint16(int16(32767))) + ech, err := pcmVersFloat32(data, 1, 16, 2) + if err != nil { + t.Fatal(err) + } + if len(ech) != 2 { + t.Fatalf("%d échantillons, attendu 2", len(ech)) + } + if math.Abs(float64(ech[0])-0.5) > 0.01 { + t.Errorf("premier échantillon = %v, attendu la moyenne des deux canaux (~0,5)", ech[0]) + } + if math.Abs(float64(ech[1])) > 0.01 { + t.Errorf("second échantillon = %v, attendu ~0 (canaux opposés)", ech[1]) + } +} + +func TestPcmFormatNonGere(t *testing.T) { + if _, err := pcmVersFloat32(make([]byte, 8), 1, 24, 1); err == nil { + t.Error("un PCM 24 bits doit être refusé explicitement, pas décodé de travers") + } +} + +// ─── Protocole WebSocket ───────────────────────────────────────────────────── + +// serveurASRFactice rejoue le protocole de sherpa-onnx : il lit l'en-tête et +// les échantillons, puis répond en JSON. Il vérifie au passage que le client +// annonce le BON nombre d'octets — un compte faux et le vrai serveur attend +// indéfiniment de quoi finir, sans jamais répondre. +func serveurASRFactice(t *testing.T, texte string) (adresse string, arret func(), recu *struct { + Taux int + N int +}) { + t.Helper() + recu = &struct { + Taux int + N int + }{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := websocket.Accept(w, r, &websocket.AcceptOptions{OriginPatterns: []string{"*"}}) + if err != nil { + return + } + defer func() { _ = c.CloseNow() }() + // Le vrai serveur reconstitue l'utterance a partir du compte annonce : + // il ne borne pas la taille des trames recues, ce test non plus. + c.SetReadLimit(-1) + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + var buf []byte + for { + _, b, err := c.Read(ctx) + if err != nil { + return + } + buf = append(buf, b...) + if len(buf) < asrEnteteOctets { + continue + } + recu.Taux = int(binary.LittleEndian.Uint32(buf[0:4])) + attendu := int(binary.LittleEndian.Uint32(buf[4:8])) + if len(buf)-asrEnteteOctets < attendu { + continue + } + recu.N = attendu / 4 + _ = c.Write(ctx, websocket.MessageText, []byte(`{"text":"`+texte+`"}`)) + return + } + })) + return "ws" + strings.TrimPrefix(srv.URL, "http"), srv.Close, recu +} + +func TestInferEnvoieLeBonEnTete(t *testing.T) { + adresse, arret, recu := serveurASRFactice(t, " bonjour ceci est un test ") + defer arret() + + wav := wavTest(make([]int16, 800), 16000) + txt, err := asrInferSur(context.Background(), adresse, wav) + if err != nil { + t.Fatalf("asrInferSur : %v", err) } if txt != "bonjour ceci est un test" { t.Errorf("texte = %q, les espaces de bord doivent être retirés", txt) } + if recu.Taux != 16000 { + t.Errorf("taux annoncé = %d, attendu 16000", recu.Taux) + } + if recu.N != 800 { + t.Errorf("%d échantillons annoncés, attendu 800 — un compte faux fige le serveur", recu.N) + } } -// 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]. +// Un audio plus gros qu'une trame doit arriver ENTIER : le serveur recolle les +// morceaux à partir du compte annoncé, encore faut-il tout envoyer. +func TestInferDecoupeLesGrosEnvois(t *testing.T) { + adresse, arret, recu := serveurASRFactice(t, "ok") + defer arret() + + n := asrTrameMax // > une trame une fois converti en float32 + if _, err := asrInferSur(context.Background(), adresse, wavTest(make([]int16, n), 16000)); err != nil { + t.Fatalf("asrInferSur : %v", err) + } + if recu.N != n { + t.Errorf("%d échantillons reçus, attendu %d", recu.N, n) + } +} + +// Sur du silence, le moteur peut rendre une annotation plutôt qu'un texte. La +// laisser passer la collerait telle quelle dans le champ de saisie. 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() + adresse, arret, _ := serveurASRFactice(t, marqueur) + txt, err := asrInferSur(context.Background(), adresse, wavTest(make([]int16, 100), 16000)) + arret() if err != nil { - t.Fatalf("whisperInferSur(%q) : %v", marqueur, err) + t.Fatalf("asrInferSur(%q) : %v", marqueur, err) } if txt != "" { t.Errorf("marqueur %q rendu comme texte %q, attendu vide", marqueur, txt) @@ -147,25 +305,65 @@ func TestInferFiltreLesMarqueurs(t *testing.T) { } } -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") +func TestInferServeurInjoignable(t *testing.T) { + p, err := portLibre() + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _, err = asrInferSur(ctx, "ws://127.0.0.1:"+strconv.Itoa(p), wavTest(make([]int16, 100), 16000)) + if err == nil { + t.Error("un serveur injoignable 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 TestInferReponseIllisible(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := websocket.Accept(w, r, &websocket.AcceptOptions{OriginPatterns: []string{"*"}}) + if err != nil { + return + } + defer func() { _ = c.CloseNow() }() + // Le vrai serveur reconstitue l'utterance a partir du compte annonce : + // il ne borne pas la taille des trames recues, ce test non plus. + c.SetReadLimit(-1) + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + if _, _, err := c.Read(ctx); err != nil { + return + } + _ = c.Write(ctx, websocket.MessageText, []byte("ceci n'est pas du JSON")) + })) + defer srv.Close() + _, err := asrInferSur(context.Background(), "ws"+strings.TrimPrefix(srv.URL, "http"), wavTest(make([]int16, 100), 16000)) + if err == nil { + t.Error("une réponse illisible doit remonter une erreur") + } +} + +// Un audio vide n'est pas une panne : on rend une transcription vide sans même +// ouvrir de connexion. +func TestInferAudioVide(t *testing.T) { + txt, err := asrInferSur(context.Background(), "ws://127.0.0.1:1", wavTest(nil, 16000)) + if err != nil { + t.Fatalf("un WAV sans échantillon ne doit pas être une erreur : %v", err) + } + if txt != "" { + t.Errorf("texte = %q, attendu vide", txt) + } +} + +// ─── Supervision ───────────────────────────────────────────────────────────── + +// Un modèle absent doit être annoncé comme tel, pas produire un lancement du +// serveur 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 { + if err := dictateCfgSave(DictateCfg{Model: "parakeet-tdt-0.6b-v3", Reactivity: "moyen"}); err != nil { t.Fatal(err) } - _, err := whisperEnsure() + _, err := asrEnsure() if err == nil { t.Fatal("un modèle absent doit produire une erreur") } @@ -176,13 +374,24 @@ func TestEnsureModeleAbsent(t *testing.T) { func TestEtatSansServeur(t *testing.T) { testHome(t) - e := whisperEtat() + e := asrEtat() if e["actif"] != false { t.Errorf("actif = %v, attendu false sans serveur lancé", e["actif"]) } - for _, clef := range []string{"modele", "present", "device", "note"} { + for _, clef := range []string{"modele", "present", "device"} { if _, ok := e[clef]; !ok { t.Errorf("clé %q manquante — l'UI en a besoin", clef) } } + // Le binaire livré est CPU : l'état ne doit pas laisser croire à une carte. + if e["device"] != "cpu" { + t.Errorf("device = %v, attendu \"cpu\"", e["device"]) + } +} + +func TestEtatSerialisable(t *testing.T) { + testHome(t) + if _, err := json.Marshal(asrEtat()); err != nil { + t.Fatalf("l'état doit être sérialisable pour /api/dictate/state : %v", err) + } } diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 02f0209..a01b35c 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -1662,6 +1662,16 @@ html[data-files="1"] #files-btn{color:var(--accent)} /* --- Discussions ----------------------------------------------------- */ /* La liste EST la barre latérale : titre à gauche, heure à droite, une ligne. Le reste (réglages) descend sous un unique repli. */ +/* Ligne du projet actif : posée au-dessus des discussions, qu'elle contient. + Le sélecteur prend toute la largeur restante — un nom de projet doit être + lisible en entier, c'est le repère qui dit dans quel chantier on écrit. */ +.projrow{display:flex;align-items:center;gap:6px;padding:10px 12px 8px} +.projrow>select{flex:1;min-width:0;font:inherit;font-size:12.5px;color:var(--text); + background:var(--panel);border:1px solid var(--border);border-radius:4px;padding:6px 9px} +.projrow>select:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-bg)} +/* Le « + » des sections est masqué par défaut et révélé par details[open] : hors + d'un
, il faut le rendre visible explicitement. */ +.projrow>.preset-add{display:inline-block;margin:0} #conv-search{width:calc(100% - 24px);font:inherit;font-size:12.5px;color:var(--text); background:var(--panel);border:1px solid var(--border);border-radius:4px; padding:7px 11px;margin:0 12px 8px} @@ -2198,6 +2208,14 @@ html[data-files="1"] #files-btn{color:var(--accent)} …
+ +
+ + +
@@ -2426,6 +2445,19 @@ html[data-files="1"] #files-btn{color:var(--accent)}
+ +
+
Projets
+
Chaque projet a sa mémoire, ses discussions et ses trackers, séparés des autres. La description est donnée à l'IA au début de chaque discussion du projet : elle sait sur quoi elle travaille sans qu'on le lui réexplique.
+
+
+
+ +
+
+
Config active
…
@@ -2463,6 +2495,16 @@ html[data-files="1"] #files-btn{color:var(--accent)}
+ +
+ Trackers ?Données datées qui s'accumulent : compteurs, relevés, journaux d'événements (poids, abonnés, relevés de compteur…). L'IA y ajoute des points et les consulte par niveaux — jamais en entier, quelle que soit l'ancienneté. La dernière valeur de chaque tracker lui est donnée d'emblée : elle répond sans appeler d'outil. + +
+
@@ -2617,18 +2659,12 @@ html[data-files="1"] #files-btn{color:var(--accent)}
-
Dictée
-
Matériel?whisper tourne dans un processus séparé, allumé au premier clic sur le micro et éteint après dix minutes sans dictée — il ne garde pas la VRAM occupée pour rien. Le GPU choisi est transmis par CUDA_VISIBLE_DEVICES.
-
- -
-
Modèle?Les modèles ne sont pas dans l'image : ils sont téléchargés à la demande dans /data/whisper/ et survivent aux recréations du conteneur. Plus le modèle est gros, mieux il comprend — et plus il occupe de mémoire.
+
Modèle?Transcription par Parakeet (NVIDIA), servie par sherpa-onnx. Le modèle n'est pas dans l'image : il est téléchargé à la demande dans /data/asr/ et survit aux recréations du conteneur. Il tourne dans un processus séparé, allumé au premier clic sur le micro et éteint après dix minutes sans dictée. Sur le processeur : la carte reste au moteur de chat, et un modèle de cette taille n'en a pas besoin sur des tranches de quelques secondes.
-
Reconnaissance
+
Reconnaissance?Il n'y a pas de réglage de langue : Parakeet la détecte lui-même, phrase par phrase. Le modèle v3 couvre 25 langues européennes dont le français ; le v2 ne comprend que l'anglais.
-
+ +
+ Projet visé + +
@@ -4145,9 +4201,18 @@ function renderMemList(){ const span=document.createElement('span'); const b=document.createElement('b'); b.style.color='var(--text)'; b.textContent=x.name; span.appendChild(b); if(x.desc){ const d=document.createElement('span'); d.className='muted'; d.textContent=' — '+x.desc; span.appendChild(d); } + const acts=document.createElement('span'); acts.style.cssText='display:flex;gap:4px;flex:none'; const btn=document.createElement('button'); btn.textContent='edit'; btn.style.cssText='margin:0;padding:2px 8px;font-size:11px'; btn.onclick=e=>{ e.stopPropagation(); openMem(x.name); }; - row.appendChild(span); row.appendChild(btn); list.appendChild(row); + acts.appendChild(btn); + // Déplacer vers un autre projet : proposé seulement s'il y en a un autre, et + // jamais sur l'index MEMORY.md, qui appartient au projet et ne se déplace pas. + if(typeof PROJECTS!=='undefined' && PROJECTS.length>1 && x.name.toUpperCase()!=='MEMORY.MD'){ + const mv=document.createElement('button'); mv.textContent='déplacer'; mv.style.cssText='margin:0;padding:2px 8px;font-size:11px'; + mv.onclick=e=>{ e.stopPropagation(); moveMemPageUI(x.name); }; + acts.appendChild(mv); + } + row.appendChild(span); row.appendChild(acts); list.appendChild(row); }); if(matches.length > shown.length){ const more=document.createElement('div'); more.className='mem-more'; @@ -4447,7 +4512,7 @@ async function loadAll(){ // MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute // suppression de module doit donc retirer son appel de cette ligne, et le test // navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard. - await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadModelSwitchFiles(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort(),loadTasks()]); + await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadModelSwitchFiles(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort(),loadTasks(),loadProjects()]); releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois } async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); } @@ -5973,6 +6038,10 @@ function renderToolMsg(el, tu){ git_status: {lbl:'git', head:'git status'}, git_diff: {lbl:'git', head:'git diff'}, git_clone: {lbl:'git', head:'git clone'}, + // Mémoire longue de la conversation : blocs archivés au compactage. Étiquette + // distincte de « mémoire » (les pages MEMORY), ce n'est pas la même chose. + recall: {lbl:'archive', head:'rappel d’un bloc archivé'}, + recall_search: {lbl:'archive', head:'recherche dans l’archive'}, }; // Outils MCP (nom mcp____) : en-tête = nom du serveur, libellé lisible, // pas le fallback mémoire. On extrait serveur et outil du nom namespacé. @@ -8928,6 +8997,7 @@ let tasksList = []; let taskEditing = null; // id en cours d'édition ('' = nouvelle, null = fermé) let TASK_RUNNING = ''; // id de la tâche en cours d'exécution ('' = aucune) let TASK_PRESETS = []; // presets disponibles (pour le sélecteur de la modale) +let TASK_PROJECTS = []; // projets disponibles (sélecteur « projet visé ») let TASK_MEM_ON = true; // état mémoire global (défaut d'une nouvelle tâche) let TASK_WEB_ON = true; // état web global (défaut d'une nouvelle tâche) let tasksPollTimer = null; @@ -8950,6 +9020,7 @@ function renderTasks(r){ const paused = !!(r && r.paused); const agentOn = !!(r && r.agent); TASK_PRESETS = (r && r.presets) || []; + TASK_PROJECTS = (r && r.projects) || []; TASK_MEM_ON = !r || r.mem_on !== false; TASK_WEB_ON = !r || r.web_on !== false; TASK_RUNNING = (r && r.running_id) || ''; @@ -9110,6 +9181,9 @@ function openTask(id){ document.getElementById('task-mem').checked = t ? !t.no_mem : TASK_MEM_ON; document.getElementById('task-web').checked = t ? !t.no_web : TASK_WEB_ON; fillPresetSelect(t ? (t.preset||'') : ''); + // Nouvelle tâche : le projet ACTIF est proposé — on crée presque toujours une + // tâche pour le chantier qu'on a sous les yeux. + fillTaskProjectSelect(t ? (t.project||'') : (typeof PROJ_ACTIVE!=='undefined' ? PROJ_ACTIVE : '')); // Décompose le schedule en intervalle (par défaut) ou cron. Forme intervalle : // « @every N(m|h|d)[@HH:MM] » (l'heure n'existe que pour les jours). @@ -9182,6 +9256,20 @@ function fillPresetSelect(selected){ sel.value = selected || ''; } +// fillTaskProjectSelect peuple le sélecteur « projet visé ». Pas d'option vide : +// une tâche appartient toujours à un projet — le serveur retombe sur le projet +// actif si rien n'est envoyé, autant le montrer explicitement. +function fillTaskProjectSelect(selected){ + const sel = document.getElementById('task-project'); + if(!sel) return; + sel.textContent = ''; + TASK_PROJECTS.forEach(p=>{ + const o = document.createElement('option'); o.value = p.slug; o.textContent = p.name; + sel.appendChild(o); + }); + if(selected && [...sel.options].some(o=>o.value===selected)) sel.value = selected; +} + function setTaskFreqMode(mode){ const r = document.querySelector('input[name="task-freq-mode"][value="'+mode+'"]'); if(r) r.checked = true; @@ -9221,13 +9309,14 @@ async function saveTask(){ const schedule = buildSchedule(); const enabled = document.getElementById('task-enabled').checked; const preset = document.getElementById('task-preset').value; + const project = (document.getElementById('task-project')||{}).value || ''; const tz = (Intl.DateTimeFormat().resolvedOptions().timeZone) || ''; const no_mem = !document.getElementById('task-mem').checked; const no_web = !document.getElementById('task-web').checked; const st = document.getElementById('task-modal-status'); if(!name || !prompt){ st.textContent = 'nom et consigne obligatoires'; st.style.color = 'var(--err)'; return; } if(!schedule){ st.textContent = 'fréquence invalide'; st.style.color = 'var(--err)'; return; } - const r = await jpost('/api/tasks/save', {id: taskEditing||'', name, prompt, schedule, enabled, preset, tz, no_mem, no_web}); + const r = await jpost('/api/tasks/save', {id: taskEditing||'', name, prompt, schedule, enabled, preset, project, tz, no_mem, no_web}); if(!r || !r.ok){ st.textContent = (r && r.error) || 'échec'; st.style.color = 'var(--err)'; return; } closeTask(); await loadTasks(); @@ -9277,6 +9366,14 @@ let SET_LAST = 'config'; const SET_HOOKS = { 'engine-log': () => { loadSvcLog(); showPaths(); }, 'dictee': () => { loadDictateSettings(); }, + // Les compteurs d'un projet (discussions, pages, trackers) bougent en + // permanence : on les relit à l'ouverture plutôt que d'afficher ceux du + // chargement de la page. + 'projets': () => { loadProjects(); }, + // Le prompt système appartient au PRESET actif : changer de modèle change le + // texte à afficher. On le relit à l'ouverture du panneau plutôt que de garder + // celui du chargement de la page, qui serait alors celui d'un autre preset. + 'sysprompt': () => { loadSys(); }, }; function openSettings(pane){ showModal('settings-modal'); @@ -9301,9 +9398,10 @@ document.addEventListener('keydown', e => { // ─── Dictée vocale ─────────────────────────────────────────────────────────── // Bouton micro de la carte de saisie : un clic enregistre, un second arrête et // transcrit. La capture est encodée en WAV 16 kHz mono ICI, côté navigateur — -// whisper.cpp ne lit que du PCM, et encoder au bon format évite d'embarquer -// ffmpeg dans l'image. La transcription est locale (POST /api/transcribe → -// whisper-cli). Le texte s'ajoute au champ sans écraser ce qui s'y trouve. +// le moteur de transcription ne lit que du PCM, et encoder au bon format évite +// d'embarquer ffmpeg dans l'image. La transcription est locale (POST +// /api/transcribe → Parakeet côté serveur). Le texte s'ajoute au champ sans +// écraser ce qui s'y trouve. // // ⚠️ Le micro n'est autorisé par le NAVIGATEUR qu'en contexte sécurisé (HTTPS // ou localhost) : servi en http://IP:8090, getUserMedia n'existe pas — on @@ -9367,7 +9465,7 @@ async function dictateStop(){ } // Ré-échantillonne le PCM capturé (44,1/48 kHz selon la machine) vers du WAV -// 16 kHz mono 16 bits — le format d'entrée de whisper.cpp. Décimation simple : +// 16 kHz mono 16 bits — le format d’entrée du moteur de dictée. Décimation simple : // pour de la voix ré-échantillonnée VERS LE BAS, largement suffisant. function encodeWav16k(chunks, fromRate){ const toRate = 16000; @@ -9391,43 +9489,35 @@ function encodeWav16k(chunks, fromRate){ return buf; } // ─── Réglages de la dictée ─────────────────────────────────────────────────── -// Panneau Paramètres → Dictée : matériel, modèle, langue, réactivité, plus un -// test du micro. Ce sont des réglages de SERVEUR (le modèle chargé et le GPU -// occupé appartiennent à la machine), donc rien n'est gardé en localStorage : -// tout passe par /api/dictate/*. +// Panneau Paramètres → Dictée : modèle, réactivité, plus un test du micro. Ce +// sont des réglages de SERVEUR (le modèle chargé appartient à la machine), donc +// rien n'est gardé en localStorage : tout passe par /api/dictate/*. +// +// Ni langue ni matériel ici, et le moteur l'impose : Parakeet détecte la langue +// lui-même, et le binaire livré est le build CPU. Afficher des sélecteurs qu'on +// ne peut pas honorer serait pire que de ne rien afficher. let DIC_CFG = null; async function loadDictateSettings(){ - const [cfg, modeles, gpus] = await Promise.all([ + const [cfg, modeles] = await Promise.all([ jfetch('/api/dictate/config').then(r => r.json()).catch(()=>({})), jfetch('/api/dictate/models').then(r => r.json()).catch(()=>[]), - jfetch('/api/vram').then(r => r.json()).catch(()=>[]), ]); DIC_CFG = cfg || {}; - // Matériel : « CPU seul » d'abord — c'est le choix qui marche partout, y - // compris sur une image bâtie sans CUDA. - const dev = document.getElementById('dic-device'); - dev.innerHTML = ''; - dev.appendChild(new Option('processeur seul', 'cpu')); - (gpus||[]).forEach((g, i) => { - const libre = Math.max(0, (g.total|0) - (g.used|0)); - dev.appendChild(new Option(`GPU ${i} — ${g.name} (${libre} Mo libres sur ${g.total|0})`, String(i))); - }); - dev.value = cfg.device || 'cpu'; - const mod = document.getElementById('dic-model'); mod.innerHTML = ''; (modeles||[]).forEach(m => { const mo = Math.round(m.octets / 1e6); // Dire ce qui est déjà là : sans ça, choisir un modèle absent ressemble à - // un réglage instantané alors que c'est un téléchargement de 600 Mo. + // un réglage instantané alors que c'est un téléchargement de ~500 Mo. + // Et dire ce qu'il COMPREND : c'est le vrai critère de choix entre les deux + // (v3 multilingue, v2 anglais seul), pas leur taille, quasi identique. const etat = m.present ? 'déjà téléchargé' : mo + ' Mo à télécharger'; - mod.appendChild(new Option(`${m.nom} — ${etat}, ~${m.vram_mo} Mo de mémoire`, m.id)); + mod.appendChild(new Option(`${m.nom} — ${m.langues} · ${etat}`, m.id)); }); mod.value = cfg.model || ''; - document.getElementById('dic-lang').value = cfg.lang || 'fr'; document.getElementById('dic-react').value = cfg.reactivity || 'moyen'; refreshDictateState(); } @@ -9435,8 +9525,6 @@ async function loadDictateSettings(){ async function dictateSave(){ const cfg = { model: document.getElementById('dic-model').value, - lang: document.getElementById('dic-lang').value, - device: document.getElementById('dic-device').value, reactivity: document.getElementById('dic-react').value, }; const el = document.getElementById('dic-save-state'); @@ -9487,10 +9575,9 @@ async function refreshDictateState(){ const bouts = []; bouts.push(j.actif ? `moteur allumé depuis ${j.depuis_s||0} s` : 'moteur éteint (il démarre au premier clic sur le micro)'); bouts.push(j.present ? `modèle ${j.modele} présent` : `modèle ${j.modele} ABSENT — à télécharger`); - // note : le serveur nous dit quand il n'a pas pu respecter le matériel - // demandé (image sans CUDA, par exemple). Le taire ferait croire que le GPU - // choisi est utilisé. - if(j.note) bouts.push('⚠ ' + j.note); + // Le dire explicitement : sans ça, on cherche un réglage de carte qui + // n'existe pas, ou on croit à une panne devant un GPU inactif. + if(j.device === 'cpu') bouts.push('sur le processeur'); if(j.dl && j.dl.running) bouts.push(`téléchargement ${j.dl.id} ${j.dl.pct||0} %`); el.textContent = bouts.join(' · '); } @@ -9544,4 +9631,414 @@ async function dictateTestMic(){ btn.disabled = false; refreshDictateState(); } +// Projets — un chantier = une mémoire + ses discussions + ses trackers. +// +// Deux surfaces, volontairement asymétriques : +// - le SÉLECTEUR de la barre latérale, qu'on utilise dix fois par jour : il ne +// fait qu'une chose, changer de projet ; +// - le PANNEAU de réglages, qu'on ouvre rarement : créer, renommer, décrire, +// supprimer. +// +// Changer de projet change ce que voit l'IA (mémoire, trackers) ET la liste des +// discussions : le serveur ouvre une discussion du projet d'arrivée, et le flux +// SSE redessine le chat tout seul — comme une bascule de discussion. + +let PROJECTS = [], PROJ_ACTIVE = ''; + +async function loadProjects(){ + let r; + try{ r = await jget('/api/projects'); }catch(_){ return; } + if(!r || !r.ok) return; + PROJECTS = r.projects || []; + PROJ_ACTIVE = r.active || ''; + renderProjectPicker(); + renderProjectList(); +} + +// Le sélecteur de la barre latérale. Reconstruit à chaque chargement : la liste +// est courte, et un rendu complet évite de synchroniser des options une par une. +function renderProjectPicker(){ + const sel = document.getElementById('proj-switch'); + if(!sel) return; + sel.textContent = ''; + PROJECTS.forEach(p => { + const o = document.createElement('option'); + o.value = p.slug; + o.textContent = p.name; + sel.appendChild(o); + }); + sel.value = PROJ_ACTIVE; +} + +async function onProjectSwitch(sel){ + const slug = sel.value; + if(!slug || slug === PROJ_ACTIVE) return; + const r = await jpost('/api/projects', {action:'switch', slug}); + if(!r.ok){ + // Refusé (génération en cours) : on remet le sélecteur sur le projet réel, + // sinon il affiche un projet où l'on n'est pas. + sel.value = PROJ_ACTIVE; + toast('erreur : ' + (r.error||'')); + return; + } + PROJECTS = r.projects || PROJECTS; + PROJ_ACTIVE = r.active || PROJ_ACTIVE; + renderProjectPicker(); + renderProjectList(); + // La mémoire, les trackers et les discussions appartiennent au projet : tout + // ce qui est à l'écran parle du précédent. + if(typeof loadConversations === 'function') loadConversations(); + if(typeof loadAgent === 'function') loadAgent(); + if(typeof loadTrackers === 'function') loadTrackers(); +} + +// Le panneau de gestion. Une ligne par projet, avec ce qu'il contient — c'est ce +// qui permet de décider quoi supprimer sans ouvrir chaque projet. +function renderProjectList(){ + const list = document.getElementById('proj-list'); + if(!list) return; + list.textContent = ''; + PROJECTS.forEach(p => { + const row = document.createElement('div'); + row.className = 'preset'; + row.style.fontSize = '12px'; + + const span = document.createElement('span'); + const b = document.createElement('b'); + b.style.color = 'var(--text)'; + b.textContent = p.name; + span.appendChild(b); + if(p.active){ + const a = document.createElement('span'); + a.className = 'sbadge on'; + a.textContent = 'actif'; + a.style.marginLeft = '6px'; + span.appendChild(a); + } + const d = document.createElement('span'); + d.className = 'muted'; + d.textContent = ' — ' + p.convs + ' discussion' + (p.convs > 1 ? 's' : '') + + ' · ' + p.pages + ' page' + (p.pages > 1 ? 's' : '') + + ' · ' + p.trackers + ' tracker' + (p.trackers > 1 ? 's' : ''); + span.appendChild(d); + if(p.desc){ + const desc = document.createElement('div'); + desc.className = 'muted'; + desc.style.cssText = 'font-size:11px;margin-top:2px'; + desc.textContent = p.desc; + span.appendChild(desc); + } + + const acts = document.createElement('span'); + acts.style.cssText = 'display:flex;gap:4px;flex:none'; + acts.appendChild(projBtn('décrire', () => editProjectDesc(p))); + acts.appendChild(projBtn('renommer', () => renameProjectUI(p))); + // Le dernier projet n'est pas supprimable côté serveur : ne pas proposer le + // bouton évite une erreur qu'on ne peut pas résoudre. + if(PROJECTS.length > 1) acts.appendChild(projBtn('supprimer', () => deleteProjectUI(p))); + + row.appendChild(span); + row.appendChild(acts); + list.appendChild(row); + }); +} + +function projBtn(label, fn){ + const b = document.createElement('button'); + b.textContent = label; + b.style.cssText = 'margin:0;padding:2px 8px;font-size:11px'; + b.onclick = e => { e.stopPropagation(); fn(); }; + return b; +} + +// Applique une action et rafraîchit tout ce qui dépend des projets. +async function projectAction(payload, okMsg){ + const r = await jpost('/api/projects', payload); + if(!r.ok){ toast('erreur : ' + (r.error||'')); return false; } + PROJECTS = r.projects || PROJECTS; + PROJ_ACTIVE = r.active || PROJ_ACTIVE; + renderProjectPicker(); + renderProjectList(); + if(okMsg) toast(okMsg); + return true; +} + +async function newProject(){ + const name = await askPrompt('Nom du projet (ex. « Serveur NAS », « Recettes ») :', {title:'Nouveau projet', okText:'Créer'}); + if(!name || !name.trim()) return; + await projectAction({action:'create', name:name.trim()}, 'projet créé'); +} + +async function renameProjectUI(p){ + const name = await askPrompt('Nouveau nom :', {title:'Renommer le projet', default:p.name, okText:'Renommer'}); + if(!name || !name.trim() || name.trim() === p.name) return; + // Seul le libellé change : les chemins disque, les discussions et les trackers + // restent accrochés au slug d'origine. + await projectAction({action:'rename', slug:p.slug, name:name.trim()}, 'renommé'); +} + +async function editProjectDesc(p){ + const desc = await askPrompt( + 'À quoi sert ce projet ? Ce texte est donné à l\'IA au début de chaque discussion du projet.', + {title:'Description du projet', default:p.desc||'', placeholder:'ex. administration du NAS Unraid, docker, sauvegardes', okText:'Enregistrer'}); + if(desc === null) return; + await projectAction({action:'desc', slug:p.slug, desc}, 'description enregistrée'); +} + +async function deleteProjectUI(p){ + const ok = await askConfirm( + 'Supprimer « ' + p.name + ' » ?\n\nSes ' + p.pages + ' page(s) mémoire, ses ' + + p.convs + ' discussion(s) et ses ' + p.trackers + ' tracker(s) seront effacés. Irréversible.', + {title:'Supprimer le projet', okText:'Supprimer', danger:true}); + if(!ok) return; + await projectAction({action:'delete', slug:p.slug}, 'projet supprimé'); + if(typeof loadConversations === 'function') loadConversations(); + if(typeof loadAgent === 'function') loadAgent(); + if(typeof loadTrackers === 'function') loadTrackers(); +} + +// Déplacer une page mémoire vers un autre projet — appelé depuis la liste des +// pages (06-settings.js). Une page rangée dans le mauvais chantier n'a pas à +// être recopiée à la main pour en changer. +async function moveMemPageUI(name){ + const others = PROJECTS.filter(p => p.slug !== PROJ_ACTIVE); + if(!others.length){ toast('aucun autre projet où déplacer'); return; } + const choice = await askPrompt( + 'Déplacer « ' + name + ' » vers quel projet ?\n' + others.map(p => '· ' + p.name).join('\n'), + {title:'Déplacer la page', default:others[0].name, okText:'Déplacer'}); + if(!choice) return; + const target = others.find(p => p.name.toLowerCase() === choice.trim().toLowerCase()); + if(!target){ toast('projet inconnu : ' + choice); return; } + const r = await jpost('/api/mem/move', {name, to:target.slug}); + if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } + toast('déplacée vers ' + target.name); + if(typeof loadAgent === 'function') loadAgent(); + loadProjects(); +} +// Trackers — le 3ᵉ type de mémoire : les données DATÉES qui s'accumulent +// (compteurs, relevés, journaux d'événements). +// +// Une page mémoire répond à « qu'est-ce que je sais » ; un tracker répond à +// « comment ça évolue ». D'où deux affichages distincts : la liste montre la +// DERNIÈRE valeur de chaque tracker (c'est presque toujours la question posée), +// le détail montre les points, du plus récent au plus ancien. +// +// La liste ne charge jamais les points : un tracker peut compter des milliers +// d'entrées, et le panneau doit s'ouvrir instantanément. + +let TRACKERS = [], TRACK_OPEN = null; + +async function loadTrackers(){ + let r; + try{ r = await jget('/api/tracker'); }catch(_){ return; } + if(!r || !r.ok) return; + TRACKERS = r.trackers || []; + renderTrackers(); +} + +function toggleTrackers(){ + const body = document.getElementById('track-body'); + if(!body) return; + body.hidden = !body.hidden; + if(!body.hidden) loadTrackers(); +} + +// Date lisible : on n'affiche l'heure que si le point en a une côté serveur — +// un relevé saisi « le 12 mars » ne doit pas s'afficher « 00:00 ». +function trackWhen(ev){ + const d = new Date(ev.ts); + const day = d.toLocaleDateString(); + return ev.date_only ? day : day + ' ' + d.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'}); +} + +function renderTrackers(){ + const list = document.getElementById('track-list'); + const count = document.getElementById('track-count'); + if(!list) return; + if(count) count.textContent = TRACKERS.length ? '(' + TRACKERS.length + ')' : ''; + list.textContent = ''; + if(!TRACKERS.length){ + const e = document.createElement('div'); + e.className = 'muted'; + e.style.fontSize = '12px'; + e.textContent = '(aucun tracker — ex. « poids », « abonnés », « relevés compteur »)'; + list.appendChild(e); + return; + } + TRACKERS.forEach(t => { + const row = document.createElement('div'); + row.className = 'preset'; + row.style.fontSize = '12px'; + row.onclick = () => openTracker(t.slug); + + const span = document.createElement('span'); + const b = document.createElement('b'); + b.style.color = 'var(--text)'; + b.textContent = t.name; + span.appendChild(b); + const d = document.createElement('span'); + d.className = 'muted'; + // La dernière valeur EST l'information : elle passe avant le compte. + d.textContent = t.last_text ? ' — ' + t.last_text : ' — (vide)'; + span.appendChild(d); + const sub = document.createElement('div'); + sub.className = 'muted'; + sub.style.cssText = 'font-size:11px;margin-top:2px'; + sub.textContent = t.count + ' point' + (t.count > 1 ? 's' : '') + + (t.last_ts ? ' · dernier le ' + new Date(t.last_ts).toLocaleDateString() : ''); + span.appendChild(sub); + + row.appendChild(span); + list.appendChild(row); + }); +} + +// --- détail d'un tracker ----------------------------------------------------- + +async function openTracker(slug){ + const r = await jget('/api/tracker?slug=' + encodeURIComponent(slug)); + if(!r || !r.ok){ toast('erreur : ' + ((r&&r.error)||'')); return; } + TRACK_OPEN = r.tracker; + document.getElementById('track-modal-title').textContent = TRACK_OPEN.name; + renderTrackerEvents(); + showModal('track-modal'); +} + +function closeTracker(){ hideModal('track-modal'); TRACK_OPEN = null; } + +function renderTrackerEvents(){ + const body = document.getElementById('track-events'); + if(!body || !TRACK_OPEN) return; + body.textContent = ''; + const evs = (TRACK_OPEN.events || []).slice().reverse(); // plus récent d'abord + if(!evs.length){ + const e = document.createElement('div'); + e.className = 'muted'; + e.style.fontSize = '12px'; + e.textContent = '(aucun point)'; + body.appendChild(e); + return; + } + evs.forEach(ev => { + const row = document.createElement('div'); + row.className = 'preset'; + row.style.fontSize = '12px'; + const span = document.createElement('span'); + const w = document.createElement('b'); + w.style.color = 'var(--text)'; + w.textContent = trackWhen(ev); + span.appendChild(w); + const t = document.createElement('span'); + t.className = 'muted'; + t.textContent = ' — ' + ev.text; + span.appendChild(t); + + const acts = document.createElement('span'); + acts.style.cssText = 'display:flex;gap:4px;flex:none'; + acts.appendChild(trackBtn('modifier', () => editTrackerPoint(ev))); + acts.appendChild(trackBtn('supprimer', () => deleteTrackerPoint(ev))); + + row.appendChild(span); + row.appendChild(acts); + body.appendChild(row); + }); +} + +function trackBtn(label, fn){ + const b = document.createElement('button'); + b.textContent = label; + b.style.cssText = 'margin:0;padding:2px 8px;font-size:11px'; + b.onclick = e => { e.stopPropagation(); fn(); }; + return b; +} + +// Applique une action sur le tracker ouvert, puis recharge son détail ET la +// liste : les deux affichent la même donnée, ils ne doivent pas diverger. +async function trackerAction(payload, okMsg){ + const r = await jpost('/api/tracker', payload); + if(!r.ok){ toast('erreur : ' + (r.error||'')); return false; } + TRACKERS = r.trackers || TRACKERS; + renderTrackers(); + if(okMsg) toast(okMsg); + return true; +} + +// La date est saisie en texte libre à précision variable (« 2026-07-15 », +// « 2026-07-15 14:30 », vide = maintenant) : c'est le serveur qui la comprend +// (parseWhen), pour que l'outil du modèle et l'interface acceptent exactement +// la même chose. +const TRACK_WHEN_HINT = 'Date (vide = maintenant) — AAAA-MM-JJ ou AAAA-MM-JJ HH:MM'; + +async function addTrackerPoint(){ + if(!TRACK_OPEN) return; + const text = await askPrompt('Valeur ou note du point :', {title:'Ajouter un point', placeholder:'ex. 78,2 kg', okText:'Suivant'}); + if(!text || !text.trim()) return; + const when = await askPrompt(TRACK_WHEN_HINT, {title:'Quand ?', placeholder:'vide = maintenant', okText:'Ajouter'}); + if(when === null) return; + if(!await trackerAction({action:'add', name:TRACK_OPEN.name, when, text}, 'point ajouté')) return; + openTracker(TRACK_OPEN.slug); +} + +async function newTracker(){ + const name = await askPrompt('Nom du tracker (ex. « poids », « abonnés ») :', {title:'Nouveau tracker', okText:'Suivant'}); + if(!name || !name.trim()) return; + const text = await askPrompt('Premier point — valeur ou note :', {title:'Premier point', placeholder:'ex. 78,2 kg', okText:'Suivant'}); + if(!text || !text.trim()) return; + const when = await askPrompt(TRACK_WHEN_HINT, {title:'Quand ?', placeholder:'vide = maintenant', okText:'Créer'}); + if(when === null) return; + // Un tracker naît de son premier point : pas de coquille vide à remplir plus tard. + await trackerAction({action:'add', name:name.trim(), when, text}, 'tracker créé'); +} + +async function editTrackerPoint(ev){ + if(!TRACK_OPEN) return; + const text = await askPrompt('Valeur ou note :', {title:'Modifier le point', default:ev.text, okText:'Suivant'}); + if(text === null) return; + const when = await askPrompt(TRACK_WHEN_HINT, {title:'Quand ?', default:'', placeholder:'vide = date inchangée', okText:'Enregistrer'}); + if(when === null) return; + if(!await trackerAction({action:'edit', slug:TRACK_OPEN.slug, id:ev.id, text, when}, 'point modifié')) return; + openTracker(TRACK_OPEN.slug); +} + +async function deleteTrackerPoint(ev){ + if(!TRACK_OPEN) return; + if(!await askConfirm('Supprimer le point du ' + trackWhen(ev) + ' ?', {title:'Supprimer', okText:'Supprimer', danger:true})) return; + if(!await trackerAction({action:'delete', slug:TRACK_OPEN.slug, id:ev.id}, 'point supprimé')) return; + openTracker(TRACK_OPEN.slug); +} + +async function renameTracker(){ + if(!TRACK_OPEN) return; + const name = await askPrompt('Nouveau nom :', {title:'Renommer le tracker', default:TRACK_OPEN.name, okText:'Renommer'}); + if(!name || !name.trim() || name.trim() === TRACK_OPEN.name) return; + // Le slug dérive du nom : le serveur re-clé le tracker si nécessaire, sinon un + // point ajouté ensuite créerait un tracker parallèle sous l'ancien nom. + if(!await trackerAction({action:'rename', slug:TRACK_OPEN.slug, name:name.trim()}, 'renommé')) return; + closeTracker(); +} + +async function moveTracker(){ + if(!TRACK_OPEN) return; + const others = (typeof PROJECTS !== 'undefined' ? PROJECTS : []).filter(p => p.slug !== PROJ_ACTIVE); + if(!others.length){ toast('aucun autre projet où déplacer'); return; } + const choice = await askPrompt( + 'Déplacer « ' + TRACK_OPEN.name + ' » vers quel projet ?\n' + others.map(p => '· ' + p.name).join('\n'), + {title:'Déplacer le tracker', default:others[0].name, okText:'Déplacer'}); + if(!choice) return; + const target = others.find(p => p.name.toLowerCase() === choice.trim().toLowerCase()); + if(!target){ toast('projet inconnu : ' + choice); return; } + if(!await trackerAction({action:'move', slug:TRACK_OPEN.slug, to:target.slug}, 'déplacé vers ' + target.name)) return; + closeTracker(); + if(typeof loadProjects === 'function') loadProjects(); +} + +async function deleteTracker(){ + if(!TRACK_OPEN) return; + const ok = await askConfirm( + 'Supprimer le tracker « ' + TRACK_OPEN.name + ' » et ses ' + TRACK_OPEN.count + ' point(s) ? Irréversible.', + {title:'Supprimer le tracker', okText:'Supprimer', danger:true}); + if(!ok) return; + if(!await trackerAction({action:'delete_tracker', slug:TRACK_OPEN.slug}, 'tracker supprimé')) return; + closeTracker(); +} diff --git a/internal/loki/ui/src/index.tmpl.html b/internal/loki/ui/src/index.tmpl.html index 4bf32fc..c8272d5 100644 --- a/internal/loki/ui/src/index.tmpl.html +++ b/internal/loki/ui/src/index.tmpl.html @@ -51,6 +51,14 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid …
+ +
+ + +
@@ -279,6 +288,19 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
+ +
+
Projets
+
Chaque projet a sa mémoire, ses discussions et ses trackers, séparés des autres. La description est donnée à l'IA au début de chaque discussion du projet : elle sait sur quoi elle travaille sans qu'on le lui réexplique.
+
+
+
+ +
+
+
Config active
…
@@ -316,6 +338,16 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
+ +
+ Trackers ?Données datées qui s'accumulent : compteurs, relevés, journaux d'événements (poids, abonnés, relevés de compteur…). L'IA y ajoute des points et les consulte par niveaux — jamais en entier, quelle que soit l'ancienneté. La dernière valeur de chaque tracker lui est donnée d'emblée : elle répond sans appeler d'outil. + +
+
@@ -470,18 +502,12 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
-
Dictée
-
Matériel?whisper tourne dans un processus séparé, allumé au premier clic sur le micro et éteint après dix minutes sans dictée — il ne garde pas la VRAM occupée pour rien. Le GPU choisi est transmis par CUDA_VISIBLE_DEVICES.
-
- -
-
Modèle?Les modèles ne sont pas dans l'image : ils sont téléchargés à la demande dans /data/whisper/ et survivent aux recréations du conteneur. Plus le modèle est gros, mieux il comprend — et plus il occupe de mémoire.
+
Modèle?Transcription par Parakeet (NVIDIA), servie par sherpa-onnx. Le modèle n'est pas dans l'image : il est téléchargé à la demande dans /data/asr/ et survit aux recréations du conteneur. Il tourne dans un processus séparé, allumé au premier clic sur le micro et éteint après dix minutes sans dictée. Sur le processeur : la carte reste au moteur de chat, et un modèle de cette taille n'en a pas besoin sur des tranches de quelques secondes.
-
Reconnaissance
+
Reconnaissance?Il n'y a pas de réglage de langue : Parakeet la détecte lui-même, phrase par phrase. Le modèle v3 couvre 25 langues européennes dont le français ; le v2 ne comprend que l'anglais.
-
+ +
+ Projet visé + +
diff --git a/internal/loki/ui/src/js/23-dictate.js b/internal/loki/ui/src/js/23-dictate.js index 24f359f..eda33ad 100644 --- a/internal/loki/ui/src/js/23-dictate.js +++ b/internal/loki/ui/src/js/23-dictate.js @@ -1,9 +1,10 @@ // ─── Dictée vocale ─────────────────────────────────────────────────────────── // Bouton micro de la carte de saisie : un clic enregistre, un second arrête et // transcrit. La capture est encodée en WAV 16 kHz mono ICI, côté navigateur — -// whisper.cpp ne lit que du PCM, et encoder au bon format évite d'embarquer -// ffmpeg dans l'image. La transcription est locale (POST /api/transcribe → -// whisper-cli). Le texte s'ajoute au champ sans écraser ce qui s'y trouve. +// le moteur de transcription ne lit que du PCM, et encoder au bon format évite +// d'embarquer ffmpeg dans l'image. La transcription est locale (POST +// /api/transcribe → Parakeet côté serveur). Le texte s'ajoute au champ sans +// écraser ce qui s'y trouve. // // ⚠️ Le micro n'est autorisé par le NAVIGATEUR qu'en contexte sécurisé (HTTPS // ou localhost) : servi en http://IP:8090, getUserMedia n'existe pas — on @@ -67,7 +68,7 @@ async function dictateStop(){ } // Ré-échantillonne le PCM capturé (44,1/48 kHz selon la machine) vers du WAV -// 16 kHz mono 16 bits — le format d'entrée de whisper.cpp. Décimation simple : +// 16 kHz mono 16 bits — le format d’entrée du moteur de dictée. Décimation simple : // pour de la voix ré-échantillonnée VERS LE BAS, largement suffisant. function encodeWav16k(chunks, fromRate){ const toRate = 16000; diff --git a/internal/loki/ui/src/js/24-dictate-settings.js b/internal/loki/ui/src/js/24-dictate-settings.js index 65a33db..99c5e9d 100644 --- a/internal/loki/ui/src/js/24-dictate-settings.js +++ b/internal/loki/ui/src/js/24-dictate-settings.js @@ -1,41 +1,33 @@ // ─── Réglages de la dictée ─────────────────────────────────────────────────── -// Panneau Paramètres → Dictée : matériel, modèle, langue, réactivité, plus un -// test du micro. Ce sont des réglages de SERVEUR (le modèle chargé et le GPU -// occupé appartiennent à la machine), donc rien n'est gardé en localStorage : -// tout passe par /api/dictate/*. +// Panneau Paramètres → Dictée : modèle, réactivité, plus un test du micro. Ce +// sont des réglages de SERVEUR (le modèle chargé appartient à la machine), donc +// rien n'est gardé en localStorage : tout passe par /api/dictate/*. +// +// Ni langue ni matériel ici, et le moteur l'impose : Parakeet détecte la langue +// lui-même, et le binaire livré est le build CPU. Afficher des sélecteurs qu'on +// ne peut pas honorer serait pire que de ne rien afficher. let DIC_CFG = null; async function loadDictateSettings(){ - const [cfg, modeles, gpus] = await Promise.all([ + const [cfg, modeles] = await Promise.all([ jfetch('/api/dictate/config').then(r => r.json()).catch(()=>({})), jfetch('/api/dictate/models').then(r => r.json()).catch(()=>[]), - jfetch('/api/vram').then(r => r.json()).catch(()=>[]), ]); DIC_CFG = cfg || {}; - // Matériel : « CPU seul » d'abord — c'est le choix qui marche partout, y - // compris sur une image bâtie sans CUDA. - const dev = document.getElementById('dic-device'); - dev.innerHTML = ''; - dev.appendChild(new Option('processeur seul', 'cpu')); - (gpus||[]).forEach((g, i) => { - const libre = Math.max(0, (g.total|0) - (g.used|0)); - dev.appendChild(new Option(`GPU ${i} — ${g.name} (${libre} Mo libres sur ${g.total|0})`, String(i))); - }); - dev.value = cfg.device || 'cpu'; - const mod = document.getElementById('dic-model'); mod.innerHTML = ''; (modeles||[]).forEach(m => { const mo = Math.round(m.octets / 1e6); // Dire ce qui est déjà là : sans ça, choisir un modèle absent ressemble à - // un réglage instantané alors que c'est un téléchargement de 600 Mo. + // un réglage instantané alors que c'est un téléchargement de ~500 Mo. + // Et dire ce qu'il COMPREND : c'est le vrai critère de choix entre les deux + // (v3 multilingue, v2 anglais seul), pas leur taille, quasi identique. const etat = m.present ? 'déjà téléchargé' : mo + ' Mo à télécharger'; - mod.appendChild(new Option(`${m.nom} — ${etat}, ~${m.vram_mo} Mo de mémoire`, m.id)); + mod.appendChild(new Option(`${m.nom} — ${m.langues} · ${etat}`, m.id)); }); mod.value = cfg.model || ''; - document.getElementById('dic-lang').value = cfg.lang || 'fr'; document.getElementById('dic-react').value = cfg.reactivity || 'moyen'; refreshDictateState(); } @@ -43,8 +35,6 @@ async function loadDictateSettings(){ async function dictateSave(){ const cfg = { model: document.getElementById('dic-model').value, - lang: document.getElementById('dic-lang').value, - device: document.getElementById('dic-device').value, reactivity: document.getElementById('dic-react').value, }; const el = document.getElementById('dic-save-state'); @@ -95,10 +85,9 @@ async function refreshDictateState(){ const bouts = []; bouts.push(j.actif ? `moteur allumé depuis ${j.depuis_s||0} s` : 'moteur éteint (il démarre au premier clic sur le micro)'); bouts.push(j.present ? `modèle ${j.modele} présent` : `modèle ${j.modele} ABSENT — à télécharger`); - // note : le serveur nous dit quand il n'a pas pu respecter le matériel - // demandé (image sans CUDA, par exemple). Le taire ferait croire que le GPU - // choisi est utilisé. - if(j.note) bouts.push('⚠ ' + j.note); + // Le dire explicitement : sans ça, on cherche un réglage de carte qui + // n'existe pas, ou on croit à une panne devant un GPU inactif. + if(j.device === 'cpu') bouts.push('sur le processeur'); if(j.dl && j.dl.running) bouts.push(`téléchargement ${j.dl.id} ${j.dl.pct||0} %`); el.textContent = bouts.join(' · '); } diff --git a/internal/loki/ui/src/styles.css b/internal/loki/ui/src/styles.css index 6a9f2a7..b7e14ca 100644 --- a/internal/loki/ui/src/styles.css +++ b/internal/loki/ui/src/styles.css @@ -1631,6 +1631,16 @@ html[data-files="1"] #files-btn{color:var(--accent)} /* --- Discussions ----------------------------------------------------- */ /* La liste EST la barre latérale : titre à gauche, heure à droite, une ligne. Le reste (réglages) descend sous un unique repli. */ +/* Ligne du projet actif : posée au-dessus des discussions, qu'elle contient. + Le sélecteur prend toute la largeur restante — un nom de projet doit être + lisible en entier, c'est le repère qui dit dans quel chantier on écrit. */ +.projrow{display:flex;align-items:center;gap:6px;padding:10px 12px 8px} +.projrow>select{flex:1;min-width:0;font:inherit;font-size:12.5px;color:var(--text); + background:var(--panel);border:1px solid var(--border);border-radius:4px;padding:6px 9px} +.projrow>select:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-bg)} +/* Le « + » des sections est masqué par défaut et révélé par details[open] : hors + d'un
, il faut le rendre visible explicitement. */ +.projrow>.preset-add{display:inline-block;margin:0} #conv-search{width:calc(100% - 24px);font:inherit;font-size:12.5px;color:var(--text); background:var(--panel);border:1px solid var(--border);border-radius:4px; padding:7px 11px;margin:0 12px 8px} diff --git a/internal/loki/web_transcribe.go b/internal/loki/web_transcribe.go index 847f1c3..9ee0bc5 100644 --- a/internal/loki/web_transcribe.go +++ b/internal/loki/web_transcribe.go @@ -1,12 +1,11 @@ // Dictée vocale — POST /api/transcribe : un WAV (16 kHz mono, encodé côté // navigateur) entre, le texte transcrit sort. La transcription est 100 % locale, -// par whisper.cpp. Le modèle est téléchargé au premier usage dans -// LOKI_HOME/whisper/ — comme le moteur, il ne gonfle pas l'image et survit aux -// recréations du conteneur via /data. +// par Parakeet (NVIDIA) servi par sherpa-onnx. Le modèle est téléchargé au +// premier usage dans LOKI_HOME/asr/ — comme le moteur, il ne gonfle pas l'image +// et survit aux recréations du conteneur via /data. // -// La transcription elle-même passe par whisper-server (dictate_server.go), qui -// garde le modèle chargé. L'ancien chemin relançait whisper-cli à chaque -// requête, donc rechargeait le modèle à chaque phrase dictée. +// La transcription elle-même passe par le serveur de dictée (dictate_server.go), +// qui garde le modèle chargé entre deux phrases. package loki import ( @@ -35,19 +34,19 @@ var wspDl struct { err string } -func whisperStartDownload(id string) { +func asrStartDownload(id string) { wspMu.Lock() defer wspMu.Unlock() if wspDl.running { return } - if whisperModelURLFor(id) == "" { + if asrModelURLFor(id) == "" { wspDl.err = "modèle de dictée inconnu : " + id return } wspDl.id, wspDl.running, wspDl.pct, wspDl.err = id, true, 0, "" go func() { - err := whisperDownload(id) + err := asrDownload(id) wspMu.Lock() wspDl.running = false if err != nil { @@ -57,65 +56,57 @@ func whisperStartDownload(id string) { }() } -func whisperDownload(id string) error { - dst := whisperModelPathFor(id) +// asrDownload télécharge et DÉCOMPRESSE un modèle. Un modèle Parakeet est une +// archive .tar.bz2 qui contient un dossier de quatre fichiers, là où whisper.cpp +// livrait un .bin unique : on décompresse donc au fil du téléchargement plutôt +// que de garder l'archive sur le disque (près de 500 Mo pour rien). +// +// La progression est mesurée sur les octets REÇUS, pas sur les octets écrits : +// c'est le téléchargement qui prend le temps, la décompression suit. +func asrDownload(id string) error { + dst := asrModelDirFor(id) if dst == "" { return fmt.Errorf("modèle de dictée inconnu : %s", id) } if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return err } - resp, err := http.Get(whisperModelURLFor(id)) + resp, err := http.Get(asrModelURLFor(id)) if err != nil { return err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != 200 { - return fmt.Errorf("téléchargement du modèle whisper : HTTP %d", resp.StatusCode) + return fmt.Errorf("téléchargement du modèle de dictée : HTTP %d", resp.StatusCode) } - // Écriture dans un .part renommé à la fin : un téléchargement interrompu - // ne doit pas laisser un .bin tronqué que whisperModelPresent croirait bon. - tmp := dst + ".part" - f, err := os.Create(tmp) - if err != nil { - return err - } - total := resp.ContentLength - var done int64 - buf := make([]byte, 1<<20) - for { - n, rerr := resp.Body.Read(buf) - if n > 0 { - if _, werr := f.Write(buf[:n]); werr != nil { - _ = f.Close() - _ = os.Remove(tmp) - return werr - } - done += int64(n) - if total > 0 { - wspMu.Lock() - wspDl.pct = int(done * 100 / total) - wspMu.Unlock() - } - } - if rerr == io.EOF { - break - } - if rerr != nil { - _ = f.Close() - _ = os.Remove(tmp) - return rerr - } - } - if err := f.Close(); err != nil { - _ = os.Remove(tmp) - return err - } - return os.Rename(tmp, dst) + // asrExtract écrit dans un dossier .part renommé à la fin : une extraction + // interrompue ne laisse jamais un modèle à moitié installé sous son nom + // définitif, que asrModelPresent croirait bon. + return asrExtract(&asrProgress{r: resp.Body, total: resp.ContentLength}, dst) } -// whisperDlEtat : instantané de la progression, pour l'UI et pour les 503. -func whisperDlEtat() (id string, running bool, pct int, errMsg string) { +// asrProgress compte les octets lus et publie l'avancement. Enveloppe le corps +// de la réponse pour que la barre bouge pendant la décompression, qui consomme +// le flux à son rythme. +type asrProgress struct { + r io.Reader + total int64 + lus int64 +} + +func (p *asrProgress) Read(b []byte) (int, error) { + n, err := p.r.Read(b) + if n > 0 && p.total > 0 { + p.lus += int64(n) + wspMu.Lock() + wspDl.pct = int(p.lus * 100 / p.total) + wspMu.Unlock() + } + return n, err +} + +// asrDlEtat : instantané de la progression, pour l'UI et pour les 503. +func asrDlEtat() (id string, running bool, pct int, errMsg string) { wspMu.Lock() defer wspMu.Unlock() return wspDl.id, wspDl.running, wspDl.pct, wspDl.err @@ -128,18 +119,18 @@ func handleTranscribe(w http.ResponseWriter, r *http.Request) { return } cfg := dictateCfgLoad() - if !whisperModelPresent(cfg.Model) { - _, _, pct, derr := whisperDlEtat() + if !asrModelPresent(cfg.Model) { + _, _, pct, derr := asrDlEtat() if derr != "" { // Échec précédent : on le dit ET on relance — un réseau revenu suffit. wspMu.Lock() wspDl.err = "" wspMu.Unlock() - whisperStartDownload(cfg.Model) + asrStartDownload(cfg.Model) sendJSON(w, 503, map[string]any{"downloading": true, "pct": 0, "error": derr}) return } - whisperStartDownload(cfg.Model) + asrStartDownload(cfg.Model) sendJSON(w, 503, map[string]any{"downloading": true, "pct": pct}) return } @@ -147,7 +138,7 @@ func handleTranscribe(w http.ResponseWriter, r *http.Request) { // transcription elle-même ne l'est pas. ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) defer cancel() - txt, err := whisperInfer(ctx, audio) + txt, err := asrInfer(ctx, audio) if err != nil { sendJSON(w, 500, map[string]any{"error": err.Error()}) return @@ -170,7 +161,7 @@ func handleDictateConfig(w http.ResponseWriter, r *http.Request) { } // Sans ça, le serveur continuerait de tourner avec l'ancien modèle et // le nouveau réglage semblerait sans effet. - whisperShutdown() + asrShutdown() sendJSON(w, 200, map[string]any{"ok": true}) return } @@ -178,7 +169,7 @@ func handleDictateConfig(w http.ResponseWriter, r *http.Request) { } func handleDictateModels(w http.ResponseWriter, r *http.Request) { - sendJSON(w, 200, whisperCatalogueTrie()) + sendJSON(w, 200, asrCatalogueTrie()) } func handleDictateDownload(w http.ResponseWriter, r *http.Request) { @@ -191,28 +182,28 @@ func handleDictateDownload(w http.ResponseWriter, r *http.Request) { return } id := strings.TrimSpace(req.ID) - if whisperModelURLFor(id) == "" { + if asrModelURLFor(id) == "" { sendJSON(w, 400, map[string]any{"error": "modèle de dictée inconnu : " + id}) return } - whisperStartDownload(id) + asrStartDownload(id) } - id, running, pct, errMsg := whisperDlEtat() + id, running, pct, errMsg := asrDlEtat() sendJSON(w, 200, map[string]any{"id": id, "running": running, "pct": pct, "error": errMsg}) } func handleDictateState(w http.ResponseWriter, r *http.Request) { - etat := whisperEtat() - id, running, pct, errMsg := whisperDlEtat() + etat := asrEtat() + id, running, pct, errMsg := asrDlEtat() etat["dl"] = map[string]any{"id": id, "running": running, "pct": pct, "error": errMsg} sendJSON(w, 200, etat) } -// whisperExitReason traduit la fin du processus en une phrase utilisable. +// asrExitReason 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 { +func asrExitReason(err error) string { var ee *exec.ExitError if !errors.As(err, &ee) { if err == nil { @@ -227,7 +218,7 @@ func whisperExitReason(err error) string { return st } -// lastLine : la dernière ligne non vide. whisper bavarde beaucoup avant de +// lastLine : la dernière ligne non vide. Le serveur de dictée bavarde 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") diff --git a/internal/loki/web_transcribe_test.go b/internal/loki/web_transcribe_test.go index 78b2942..a829d24 100644 --- a/internal/loki/web_transcribe_test.go +++ b/internal/loki/web_transcribe_test.go @@ -3,189 +3,105 @@ package loki import ( "os" "regexp" - "strconv" "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 { +func dockerfileSrc(t *testing.T) 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 + return string(b) } -// 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) +// Le binaire de dictée est TÉLÉCHARGÉ, plus compilé. Ce qui remplace les +// garde-fous de compilation d'avant (-march=native, SIGILL en production) est la +// vérification d'empreinte : sans elle, une release remplacée en amont ferait +// tourner un binaire inconnu sur la machine de l'utilisateur, en silence. +func TestDockerfileASRVerifieLEmpreinte(t *testing.T) { + src := dockerfileSrc(t) + if !strings.Contains(src, "sha256sum -c -") { + t.Error("l'archive sherpa-onnx est extraite sans vérification d'empreinte") } - _, apres, ok := strings.Cut(string(b), "ARG WHISPER_CMAKE_FLAGS=") - if !ok { - return "" + m := regexp.MustCompile(`(?m)^ARG SHERPA_ONNX_SHA256=([0-9a-f]{64})\s*$`).FindStringSubmatch(src) + if m == nil { + t.Fatal("ARG SHERPA_ONNX_SHA256 absent ou pas un sha256 de 64 caractères") } - 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) - } - // « -j » nu autorise un parallélisme ILLIMITÉ chez Make. Sur l'étape - // CUDA (~200 nvcc à 1-2 Go pièce), le runner GitHub était tué par - // l'OOM sans écrire une ligne d'erreur. L'étape CPU y survivait, ce - // qui rendait le piège invisible. - for _, ligne := range strings.Split(txt, "\n") { - if !strings.Contains(ligne, "cmake --build") { - continue - } - if regexp.MustCompile(`-j(\s|$|\\)`).MatchString(ligne) { - t.Errorf("étape %s : « cmake --build -j » sans nombre — parallélisme illimité, le runner sera tué par l'OOM. Utiliser -j\"$(nproc)\".", nom) - } - } + if !strings.Contains(src, "${SHERPA_ONNX_SHA256}") { + t.Error("l'empreinte est déclarée mais jamais utilisée") } } -// 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) +// La version doit être ÉPINGLÉE. « latest » ferait changer le binaire de dictée +// sous les pieds de l'utilisateur au prochain build, sans qu'une seule ligne du +// dépôt ait bougé — et l'empreinte figée ci-dessus casserait le build. +func TestDockerfileASRVersionEpinglee(t *testing.T) { + src := dockerfileSrc(t) + m := regexp.MustCompile(`(?m)^ARG SHERPA_ONNX_VERSION=(\S+)\s*$`).FindStringSubmatch(src) + if m == nil { + t.Fatal("ARG SHERPA_ONNX_VERSION absent") } - 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) - } + if !regexp.MustCompile(`^v\d+\.\d+\.\d+$`).MatchString(m[1]) { + t.Errorf("version de sherp-onnx non épinglée : %q", m[1]) } } -// L'étape CUDA doit être bâtie avec un nvcc qui CONNAÎT les GPU visés. CUDA -// 12.4 ignore Blackwell (RTX 50xx, sm_120) : il refuse l'architecture, et le -// binaire ne tournerait au mieux que par recompilation PTX au chargement. -// 12.8 est le plancher, et c'est aussi la version avec laquelle llama.cpp bâtit -// l'image amont qui fournit libcudart. -func TestDockerfileCudaAssezRecent(t *testing.T) { - const majeurMin, mineurMin = 12, 8 - trouve := false - for nom, txt := range etapesWhisperbuild(t) { - m := regexp.MustCompile(`nvidia/cuda:(\d+)\.(\d+)`).FindStringSubmatch(txt) - if m == nil { - continue - } - trouve = true - maj, _ := strconv.Atoi(m[1]) - min, _ := strconv.Atoi(m[2]) - if maj < majeurMin || (maj == majeurMin && min < mineurMin) { - t.Errorf("étape %s : CUDA %s.%s — trop ancien pour Blackwell (sm_120), il faut au moins %d.%d", - nom, m[1], m[2], majeurMin, mineurMin) - } - // Sans borne d'architectures, ggml compile de Maxwell à Blackwell : - // chaque architecture multiplie le temps de compilation, et le build - // dépassait quarante minutes. - if !strings.Contains(txt, "CMAKE_CUDA_ARCHITECTURES") { - t.Errorf("étape %s : aucune borne CMAKE_CUDA_ARCHITECTURES — la compilation vise toutes les architectures connues", nom) - } - } - if !trouve { - t.Error("aucune étape whisperbuild ne part d'une image nvidia/cuda") - } -} - -// L'ARG qui porte les drapeaux doit être déclaré AVANT le premier FROM. Un ARG -// posé entre deux étapes appartient à l'étape où il apparaît ; les « ARG » nus -// des étapes whisperbuild-* héritent alors du scope global — vide. cmake a -// tourné sans aucun drapeau : ggml en bibliothèques partagées (échec de lien), -// et -march=native — le SIGILL de la PR #18 revenu en silence. 51 minutes de -// build pour le découvrir. +// Régression (héritée de whisper) : un ARG déclaré APRÈS le premier FROM +// n'appartient qu'à l'étape où il apparaît. Les « ARG SHERPA_* » nus de l'étape +// asrfetch hériteraient alors d'une valeur vide — URL tronquée, build cassé, et +// la cause invisible dans le journal. func TestDockerfileArgFlagsGlobal(t *testing.T) { - b, err := os.ReadFile("../../Dockerfile") - if err != nil { - t.Fatalf("lecture du Dockerfile : %v", err) - } - src := string(b) + src := dockerfileSrc(t) + fromLoc := regexp.MustCompile(`(?m)^FROM `).FindStringIndex(src) // Ancré en début de ligne : une occurrence dans un commentaire ne compte // pas — c'est précisément ainsi qu'une première version de ce test s'est // laissée berner par sa propre contre-épreuve. - argLoc := regexp.MustCompile(`(?m)^ARG WHISPER_CMAKE_FLAGS=`).FindStringIndex(src) - fromLoc := regexp.MustCompile(`(?m)^FROM `).FindStringIndex(src) - if argLoc == nil { - t.Fatal("ARG WHISPER_CMAKE_FLAGS= introuvable dans le Dockerfile") - } - if fromLoc != nil && argLoc[0] > fromLoc[0] { - t.Error("ARG WHISPER_CMAKE_FLAGS= est déclaré APRÈS un FROM : les étapes whisperbuild-* hériteront d'une valeur vide et cmake tournera sans aucun drapeau") + for _, arg := range []string{"SHERPA_ONNX_VERSION", "SHERPA_ONNX_SHA256"} { + loc := regexp.MustCompile(`(?m)^ARG ` + arg + `=`).FindStringIndex(src) + if loc == nil { + t.Errorf("ARG %s= introuvable dans le Dockerfile", arg) + continue + } + if fromLoc != nil && loc[0] > fromLoc[0] { + t.Errorf("ARG %s= est déclaré APRÈS un FROM : l'étape asrfetch héritera d'une valeur vide", arg) + } } } -func clefs(m map[string]string) []string { - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) +// Le chemin du binaire est lu depuis LOKI_ASR_SERVER (asrServerBin) : l'image +// doit poser la variable ET copier le binaire à cette adresse, sinon la dictée +// échoue au premier clic sur le micro avec « serveur de dictée introuvable ». +func TestDockerfileASRBinaireEtVariableConcordent(t *testing.T) { + src := dockerfileSrc(t) + const chemin = "/usr/local/bin/sherpa-onnx-offline-websocket-server" + if !strings.Contains(src, "COPY --from=asrfetch /out/sherpa-onnx-offline-websocket-server "+chemin) { + t.Errorf("le binaire de dictée n'est pas copié vers %s", chemin) + } + if !strings.Contains(src, "LOKI_ASR_SERVER="+chemin) { + t.Errorf("LOKI_ASR_SERVER ne pointe pas sur %s", chemin) + } +} + +// whisper.cpp est parti : plus aucune étape ne doit le compiler, et aucune +// variable ne doit prétendre le trouver. Un reste ferait rebâtir 35 minutes de +// CUDA pour un binaire que plus personne n'exécute. +func TestDockerfileSansWhisper(t *testing.T) { + src := dockerfileSrc(t) + for _, motif := range []string{"whisperbuild", "whisper-server", "LOKI_WHISPER_SERVER", "WHISPER_CMAKE_FLAGS"} { + // Les commentaires ont le droit de mentionner l'histoire ; les + // instructions, non. On ne regarde donc que les lignes actives. + for _, ligne := range strings.Split(src, "\n") { + l := strings.TrimSpace(ligne) + if l == "" || strings.HasPrefix(l, "#") { + continue + } + if strings.Contains(l, motif) { + t.Errorf("reste de whisper dans une instruction du Dockerfile (%s) : %s", motif, l) + } + } } - return out } func TestLastLine(t *testing.T) { @@ -193,7 +109,7 @@ func TestLastLine(t *testing.T) { {"", ""}, {" \n\n ", ""}, {"une seule ligne", "une seule ligne"}, - {"AMX is not ready to be used!\nread_audio_data: ...\n", "read_audio_data: ..."}, + {"erreur fatale\nderniere ligne utile\n", "derniere ligne utile"}, {"fin utile\n\n \n", "fin utile"}, } for _, c := range cas { diff --git a/internal/loki/web_vram.go b/internal/loki/web_vram.go index 4f76d4c..2455faa 100644 --- a/internal/loki/web_vram.go +++ b/internal/loki/web_vram.go @@ -198,7 +198,7 @@ func handleVramUnload(w http.ResponseWriter, r *http.Request) { // « vite » : ne jamais attendre derrière un démarrage de la dictée, qui // peut tenir le verrou deux minutes — le navigateur aurait abandonné bien // avant, moteur pourtant déjà arrêté. - whisperShutdownVite() + asrShutdownVite() after := before if gpus != nil {