Compare commits

..
Author SHA1 Message Date
Claude 895aa67366 Presets : le bouton « + » ne s'affichait jamais
.preset-add est en display:none par défaut, et n'était révélé que par
« details[open]>summary>.preset-add ». Les panneaux de réglages ont
migré vers <section class="set-pane"><div class="set-pane-h">, donc ce
sélecteur ne matchait plus : le « + » des Presets restait invisible et
il n'y avait plus aucun moyen d'en créer un depuis l'interface.

Le même bouton dans « Discussions » est resté visible, lui, parce qu'il
vit encore dans un <details open><summary> — d'où un défaut qui ne
touchait que les presets.

Règle ajoutée pour le nouveau conteneur, puis UI réassemblée
(tools/assemble-ui).

Vérifié dans l'application lancée : display passe de none à block, et le
« + » apparaît dans l'en-tête du volet Presets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVay7z3y7q2gEe54ByAE6N
2026-08-23 12:56:17 +00:00
LogiFlow 08eaf10a34 Merge pull request #23 from R0m1k3/fix/arg-scope-dockerfile
Image : l'ARG des drapeaux whisper était hors de portée — cmake tournait à nu
2026-08-22 04:16:09 +02:00
MichaelandClaude Fable 5 e177d54e39 Image : l'ARG des drapeaux whisper était hors de portée — cmake tournait à nu
Le build de 51 minutes a échoué au lien final : « libcuda.so.1 not found »,
références cuMem* non résolues, et une libggml-cuda.so PARTAGÉE alors que
BUILD_SHARED_LIBS=OFF est censé être passé. Ce dernier détail était le vrai
indice : les drapeaux n'atteignaient pas cmake du tout.

ARG WHISPER_CMAKE_FLAGS="…" était déclaré entre deux étapes. Règle Docker :
un ARG posé après un FROM appartient à l'étape où il apparaît ; les « ARG »
nus des étapes whisperbuild-* héritent, eux, du scope GLOBAL — où rien
n'était défini. Valeur vide, cmake sans aucun drapeau : ggml en bibliothèques
partagées (échec de lien contre les stubs du pilote), et surtout
-march=native — le SIGILL de la PR #18 revenu en silence sur les deux
binaires. Les tests du Dockerfile n'y voyaient rien : ils vérifiaient le
texte, pas les règles de portée de Docker.

- L'ARG remonte avant le premier FROM, à côté de LLAMACPP_IMAGE.
- Chaque étape vérifie désormais SON binaire : un ldd qui montre libggml ou
  libwhisper en dynamique fait échouer le build sur-le-champ, au lieu de
  laisser partir un binaire qui ne trouvera pas ses .so dans l'image finale.
- TestDockerfileArgFlagsGlobal verrouille la position de l'ARG, ancré en
  début de ligne — une première version se laissait berner par une
  occurrence en commentaire, sa contre-épreuve l'a montré.

libcuda.so.1 reste une dépendance dynamique normale du binaire CUDA : c'est
le pilote, injecté à l'exécution par le NVIDIA Container Toolkit, et
l'édition de liens la résout via les stubs du toolkit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-22 04:12:52 +02:00
LogiFlow 66b53c8255 Merge pull request #22 from R0m1k3/fix/workflow-yaml-double-cle
Workflow : clé YAML dupliquée, plus aucun build ne partait
2026-08-21 23:07:17 +02:00
MichaelandClaude Fable 5 3991e68fc9 Workflow : clé YAML dupliquée, plus aucun build ne partait
Depuis la fusion de la PR #21, chaque push sur main échouait en moins d'une
minute avec zéro job lancé : le YAML du workflow était devenu invalide.

La PR #21 ajoutait cache-from/cache-to après build-args, alors que le bloc
with: les portait DÉJÀ en fin de liste. Une clé dupliquée dans un mapping
YAML fait rejeter le workflow avant même son démarrage — d'où des échecs
immédiats, sans le moindre journal.

Rectification au passage : le cache n'était pas absent, il était déjà actif.
Les trente-sept minutes venaient de la nouveauté de l'étape CUDA (cache
froid) et de l'absence de borne d'architectures, corrigée elle pour de bon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-21 22:36:37 +02:00
LogiFlow cc218ccfd7 Merge pull request #21 from R0m1k3/fix/cuda-blackwell-et-cache
Image : CUDA 12.8 pour Blackwell, architectures bornées, cache des couches
2026-08-21 22:33:06 +02:00
MichaelandClaude Opus 5 d8ed86bbba Image : CUDA 12.8 pour Blackwell, architectures bornées, cache des couches
Le build ne mourait plus, mais tournait encore après trente-sept minutes. Et
il aurait produit un binaire inutilisable sur la moitié du matériel visé.

Les GPU de la machine cible sont une RTX 5060 Ti (Blackwell, sm_120) et une
RTX 3060 (Ampere, sm_86). L'étape de compilation était épinglée sur
nvidia/cuda:12.4.1 : ce nvcc-là ne CONNAÎT pas Blackwell. Il refuse
l'architecture, et le binaire ne tournerait au mieux que par recompilation PTX
au chargement. 12.8.1 sur Ubuntu 24.04 est désormais utilisé — exactement ce
avec quoi llama.cpp bâtit l'image amont qui fournit libcudart.

Pour la durée, deux causes distinctes :

- Sans borne, ggml compile pour TOUTES les architectures qu'il connaît, de
  Maxwell à Blackwell. CMAKE_CUDA_ARCHITECTURES ramène le travail à quatre
  (Turing → Blackwell), et l'ARG CUDA_ARCHS permet d'élargir sans toucher au
  fichier pour un GPU plus ancien.
- Surtout : ce binaire ne dépend d'AUCUN fichier du dépôt, et il était pourtant
  recompilé à chaque push. Le cache de couches GitHub Actions le réutilise tant
  que ses lignes du Dockerfile ne bougent pas ; seuls le code Go et
  l'assemblage de l'image se refont.

Un test vérifie le plancher CUDA et la présence de la borne d'architectures :
ces deux fautes ne se voient pas à la lecture et coûtent quarante minutes à
découvrir.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-21 22:25:53 +02:00
LogiFlow 748328db25 Merge pull request #20 from R0m1k3/fix/build-cuda-oom
Image : le build CUDA était tué par l'OOM, pas en échec de compilation
2026-08-21 21:44:47 +02:00
MichaelandClaude Opus 5 8e88a73d91 Image : le build CUDA était tué par l'OOM, pas en échec de compilation
Le premier build de l'image avec whisper CUDA a échoué après huit minutes
sans écrire une seule ligne d'erreur : le journal s'arrête à 92 % de la
compilation, puis « Complete job ». Un compilateur qui échoue dit pourquoi ;
un compilateur tué ne dit rien.

Ces 92 % étaient atteints en TREIZE secondes. Ce ne sont pas des compilations
terminées, ce sont des nvcc lancés simultanément.

« cmake --build -j » sans nombre autorise chez Make un parallélisme illimité.
ggml-cuda compte environ 200 fichiers d'instanciation de gabarits et chaque
nvcc réclame 1 à 2 Go : le runner (16 Go) mourait d'un OOM. L'étape CPU y
survivait — peu de fichiers, compilation légère — ce qui a rendu le piège
invisible jusqu'à l'arrivée de CUDA.

- -j"$(nproc)" sur les deux étapes, et un test le vérifie désormais : cette
  faute est indétectable à la lecture et ne se manifeste qu'en CI.
- Le runner libère dotnet, android et ghc avant de bâtir. L'empilement
  nvidia/cuda-devel + runtime CUDA + Chromium approche les 14 Go disponibles ;
  cette part-là reste une hypothèse, mais elle coûte une minute à écarter.
- whisper-server CUDA est strippé : les symboles de débogage d'un binaire
  ggml-cuda pèsent plusieurs centaines de mégaoctets dans l'image finale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-21 21:35:42 +02:00
LogiFlow f4969de9f7 Merge pull request #19 from R0m1k3/feat/dictee-temps-reel
Dictée : whisper-server supervisé, modèle et GPU au choix
2026-08-21 21:22:18 +02:00
MichaelandClaude Opus 5 5d391c4f73 Dictée : panneau de réglages — GPU, modèle, langue, test du micro
Le choix du matériel et du modèle n'existait nulle part : la dictée prenait
le seul modèle codé en dur et le seul chemin possible. Sur une machine à
plusieurs GPU, rien ne permettait de dire lequel prêter à whisper.

Le panneau liste les GPU avec leur VRAM libre (/api/vram donne déjà tout), et
chaque modèle avec sa taille de téléchargement et sa mémoire. Un modèle absent
le dit dans sa propre étiquette : le choisir n'est pas un réglage instantané
mais un transfert de plusieurs centaines de mégaoctets, et le taire ferait
passer l'attente pour une panne.

Le test du micro capte trois secondes, affiche le niveau mesuré puis la
transcription, sans toucher au champ de saisie. Vu de l'extérieur, un micro
muet, un niveau trop faible et un moteur en panne se ressemblent tous — il
fallait bricoler ce diagnostic à la main pour les distinguer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-21 21:11:21 +02:00
MichaelandClaude Opus 5 3cd4facb0e Dictée : whisper-server supervisé, modèle chargé une seule fois
Chaque dictée relançait whisper-cli, qui relisait le modèle depuis le disque
avant de transcrire. Ce chargement dominait le temps de réponse — 3,2 s de
calcul pour 3,4 s d'audio — et aucun réglage ne pouvait le rattraper.

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-21 21:08:12 +02:00
MichaelandClaude Opus 5 3624194b39 Plan : moteur de dictée supervisé et configurable
Sept tâches, de la persistance des réglages au panneau de l'interface. Le
temps réel (WebSocket + découpeur) fait l'objet d'un second plan : ce plan-ci
livre déjà un logiciel utilisable — bon modèle, GPU au choix, lenteur
supprimée — sans le direct.

Contraintes relevées dans le dépôt et inscrites en tête du plan : la CI
compile pour Windows (donc pas de Setsid), staticcheck doit rester à zéro, et
index.html est généré par tools/assemble-ui, jamais édité à la main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-21 21:00:43 +02:00
MichaelandClaude Opus 5 2fa05fdaf7 Conception : dictée en temps réel, configurable selon le matériel
La dictée marche depuis la PR #18 mais comprend mal et n'écrit qu'à l'arrêt du
micro. La lenteur est structurelle : chaque requête relance whisper-cli, qui
recharge le modèle depuis le disque. Découper en tranches par-dessus ce
mécanisme reviendrait à recharger le modèle toutes les quelques secondes.

La conception retient whisper-server (modèle chargé une fois, supervisé par
Loki comme llama-server), un découpage en tranches calé sur les silences et
piloté côté Go, et un panneau Dictée pour choisir GPU, modèle, langue et
réactivité.

Deux points tranchés en cours de route :

- Deux binaires whisper-server, CPU et CUDA, pas un seul. LLAMACPP_IMAGE
  accepte la variante CPU de l'image amont ; sur cette base les .so CUDA sont
  absentes et un binaire lié à CUDA n'a même pas de quoi démarrer. Un binaire
  unique condamnerait cette variante.
- Le seuil de silence est adaptatif. Un seuil fixe ne coupe jamais dans une
  pièce calme avec un micro discret, et coupe sans arrêt près d'un ventilateur.

Les drapeaux GGML_NATIVE=OFF restent exigés sur les deux cibles : le SIGILL de
la PR #18 ne doit pas revenir par la porte du build CUDA.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-21 17:18:49 +02:00
LogiFlow e0ed0a32af Merge pull request #18 from R0m1k3/fix/whisper-binaire-non-portable
Dictée : whisper-cli compilé pour la machine de build, pas pour la tienne
2026-08-21 16:30:48 +02:00
MichaelandClaude Opus 5 a19b490b6a Dictée : whisper-cli compilé pour la machine de build, pas pour la tienne
La dictée n'a jamais transcrit une seule phrase. Le micro enregistrait bien —
contexte HTTPS, autorisation accordée, bouton rouge — mais le serveur répondait
500 sur chaque envoi :

    whisper-cli : AMX is not ready to be used!
    read_audio_data: reading audio data from '/tmp/loki-dictee-...wav' ...
    read_audio_data: trying to decode with miniaudio

Deux indices dans ces trois lignes. « AMX is not ready to be used » ne sort que
d'un binaire COMPILÉ avec les instructions AMX, celles d'un Xeon récent — le
runner GitHub. Et le journal s'arrête net : ni « failed to decode », ni texte
transcrit. Le processus ne renvoie pas une erreur, il meurt.

ggml compile en -march=native par défaut. Le binaire partait donc taillé pour
le processeur qui l'avait construit, et rencontrait une instruction illégale
dès qu'on le posait ailleurs.

- GGML_NATIVE=OFF à l'étape whisperbuild, AVX-512 et AMX explicitement coupés.
  Reste la ligne de base AVX2/FMA/F16C de ggml, présente sur tout x86-64 depuis
  2013. Un test lit le Dockerfile et garde ces drapeaux : rien à l'exécution ne
  rappellerait leur raison d'être au prochain qui touchera cette étape.
- Le handler ne renvoyait que la dernière ligne de stderr. Sur une mort par
  signal, cette ligne est celle d'AVANT le coup fatal : elle ressemble à une
  explication sans en être une, et envoie chercher du côté de l'audio. Il dit
  maintenant comment le processus a fini — « signal: illegal instruction » vaut
  diagnostic à lui seul.

L'image doit être reconstruite : whisper-cli y est compilé.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-21 16:27:46 +02:00
LogiFlow 393986faa6 Merge pull request #17 from R0m1k3/fix/ngl-sentinelle-999-auto
NGL=999 : la sentinelle « toutes les couches » devient -ngl auto
2026-08-21 10:45:26 +02:00
MichaelandClaude Opus 5 2585d2c2e1 NGL=999 : la sentinelle « toutes les couches » devient -ngl auto
Le correctif précédent ne pouvait pas s'appliquer. Il ne posait « -ngl auto »
que si la clé NGL était ABSENTE de config.env — or defaultConfig() y sème
NGL=999 sur chaque installation neuve. La quasi-totalité des configurations la
portent donc sans que personne ne l'ait choisie, le code la lisait comme un
choix délibéré, et l'abandon revenait intact :

  W common_fit_params: failed to fit params to free device memory:
    n_gpu_layers already set by user to 999, abort

999 n'a jamais été un nombre de couches : c'est la sentinelle historique
« toutes ». Sur un moteur qui sait mesurer la VRAM libre, elle devient donc
« -ngl auto », et Loki le dit sur stderr plutôt que de le faire en douce. Tout
autre nombre reste intouché — c'est un vrai choix. NGL=all force l'ancien
comportement, NGL=auto n'envoie toujours aucun drapeau, et un moteur ancien
reçoit toujours 999 (l'omettre le ferait tourner 100 % CPU).

- La décision sort dans nglArgs(), fonction pure : la seule question qui
  demande le moteur arrive déjà tranchée, donc elle se teste sans lancer
  llama-server. 10 cas couverts.
- binFitsLayersItself() double la lecture fine de l'aide par la présence de
  --load-mode. Les deux sont arrivés dans la même vague ; une description
  reformulée ou une colonne plus large ne doit pas faire conclure « moteur
  incapable » et réimposer 999.
- L'aide de la clé et le sous-titre du champ disent la nouvelle règle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 09:47:32 +02:00
LogiFlow fedbaf32a0 Merge pull request #16 from R0m1k3/fix/llamacpp-deprecated-flags-dictee
Moteur : drapeaux de chargement à jour, -ngl auto ; dictée qui dit po…
2026-08-21 09:26:46 +02:00
23 changed files with 3348 additions and 136 deletions

No files matched your search

+14
View File
@@ -16,6 +16,17 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
# 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 /
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc
df -h /
- name: Setup Buildx
uses: docker/setup-buildx-action@v3
@@ -45,5 +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.
cache-from: type=gha
cache-to: type=gha,mode=max
+86 -13
View File
@@ -14,6 +14,18 @@
# Déclaré avant le premier FROM : requis pour être utilisable dans un FROM.
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"
# ── Étape 1 : binaire Go loki ───────────────────────────────────────────
FROM golang:1.25 AS gobuild
WORKDIR /src
@@ -31,19 +43,77 @@ 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-cli (dictée vocale) ───────────────────────────
# whisper.cpp compilé CPU seul, en statique : un binaire unique, aucune .so à
# trimballer, et pas de compilation CUDA (des minutes de build pour un gain
# nul sur des dictées de quelques secondes). Ubuntu 22.04 : glibc plus
# ancienne que l'image runtime, donc compatible quoi qu'elle embarque.
FROM ubuntu:22.04 AS whisperbuild
# ── É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).
#
# 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.
#
# ⚠️ 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
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential cmake git ca-certificates \
&& 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 -DCMAKE_BUILD_TYPE=Release \
-DBUILD_SHARED_LIBS=OFF -DWHISPER_BUILD_TESTS=OFF \
&& cmake --build /w/build -j --target whisper-cli
&& 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" )
# ── Étape 2 : runtime sur l'image serveur CUDA officielle ───────────────
FROM ${LLAMACPP_IMAGE} AS runtime
@@ -98,9 +168,11 @@ 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). Le modèle (~190 Mo) n'est PAS dans
# l'image : téléchargé au premier usage dans /data/whisper/.
COPY --from=whisperbuild /w/build/bin/whisper-cli /usr/local/bin/whisper-cli
# 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
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh && mkdir -p /data /models
@@ -109,7 +181,8 @@ ENV LOKI_CONTAINER=1 \
LOKI_HOME=/data \
LOKI_MODEL_DIRS=/models \
LOKI_ENGINE_BIN=/app/llama-server \
LOKI_WHISPER_BIN=/usr/local/bin/whisper-cli \
LOKI_WHISPER_SERVER_CPU=/usr/local/bin/whisper-server-cpu \
LOKI_WHISPER_SERVER_CUDA=/usr/local/bin/whisper-server-cuda \
LD_LIBRARY_PATH=/app \
NVIDIA_VISIBLE_DEVICES=all \
NVIDIA_DRIVER_CAPABILITIES=compute,utility
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,187 @@
# Dictée en temps réel, configurable selon le matériel
*Conception validée le 21 août 2026.*
## Le problème
La dictée fonctionne depuis la correction du binaire non portable (PR #18), mais deux
défauts la rendent pénible :
1. **Elle comprend mal.** Le modèle `small-q5_1` est le plus petit modèle multilingue
utilisable, et `-l auto` lui fait redeviner la langue à chaque appel.
2. **Elle est lente et muette pendant l'enregistrement.** Le texte n'arrive qu'à
l'arrêt du micro, après ~3 s de calcul pour 3,4 s d'audio.
La lenteur a une cause structurelle : chaque `POST /api/transcribe` lance un processus
`whisper-cli` qui **recharge le modèle depuis le disque**, transcrit, puis meurt. Le
chargement domine le temps de réponse. Aucun réglage ne rattrape ça, et le découpage en
tranches — indispensable pour écrire pendant qu'on parle — le rendrait catastrophique :
un rechargement complet du modèle toutes les quelques secondes.
## Ce qu'on construit
Trois choses, dans cet ordre de dépendance :
1. `whisper-server` supervisé par Loki, remplaçant les lancements de `whisper-cli`
2. Un découpage en tranches piloté par le serveur, alimenté par une WebSocket
3. Un panneau **Dictée** dans les réglages : GPU, modèle, langue, réactivité, test du micro
## 1. Le moteur
### whisper-server plutôt que whisper-cli
`whisper.cpp` fournit `whisper-server` : modèle chargé **une fois**, requêtes en HTTP.
Loki le supervise comme il supervise déjà `llama-server`.
**Démarrage paresseux, extinction sur inactivité.** Le serveur démarre au premier clic
sur le micro et s'arrête après ~10 min sans dictée. La machine cible a deux GPU dont on
veut préserver la VRAM : whisper n'a aucune raison d'occuper plusieurs centaines de Mo
pendant qu'on ne dicte pas. Le premier clic après extinction coûte le chargement du
modèle ; les suivants sont immédiats.
### Choix du GPU
`CUDA_VISIBLE_DEVICES` posé sur le processus `whisper-server`. Pas de drapeau à inventer,
pas de code à maintenir dans ggml :
- `CUDA_VISIBLE_DEVICES=1` → whisper ne voit que le second GPU
- valeur vide → CPU seul
Changer le GPU, le modèle ou la langue redémarre le processus. Rien d'autre ne bouge.
### Compilation : deux binaires, pas un
L'étape `whisperbuild` produit **deux** binaires :
- `whisper-server-cpu`, bâti comme aujourd'hui (`ubuntu:22.04`)
- `whisper-server-cuda`, bâti sur une image `nvidia/cuda:*-devel` avec `-DGGML_CUDA=ON`
La raison est dans le Dockerfile lui-même : `LLAMACPP_IMAGE` accepte la variante CPU
(`ghcr.io/ggml-org/llama.cpp:server`, ligne 11). Sur cette base, les bibliothèques CUDA
sont absentes et un binaire lié à CUDA **ne démarre pas du tout** — pas de repli
possible, l'éditeur de liens échoue avant le premier octet de code. Un unique binaire
CUDA condamnerait donc la variante CPU de l'image.
Loki choisit à l'exécution : binaire CUDA si un GPU est demandé *et* que le binaire
démarre, binaire CPU sinon. Un échec de démarrage du binaire CUDA bascule sur le CPU en
le disant, plutôt que de laisser la dictée morte.
**Les drapeaux de portabilité restent sur les deux cibles** : `GGML_NATIVE=OFF`,
`GGML_AVX512=OFF`, `GGML_AMX_*=OFF`. Le bug corrigé en PR #18 — binaire compilé pour le
processeur du runner, SIGILL ailleurs — ne doit pas revenir par la porte du build CUDA.
## 2. Le découpage
### On coupe dans les silences, jamais à heure fixe
Une tranche toutes les N secondes tombe statistiquement au milieu d'un mot une fois sur
deux. Whisper reçoit alors une demi-syllabe sans contexte, l'entrée même qui lui fait
produire du charabia. Couper au mauvais endroit dégraderait la reconnaissance au lieu de
l'améliorer.
Le **seuil de bruit est adaptatif**, pas une constante. Un seuil fixe se trompe des deux
côtés : trop haut dans une pièce silencieuse avec un micro discret, il ne coupe jamais ;
trop bas près d'un ventilateur, il coupe en permanence. Le découpeur mesure donc le
niveau des premières centaines de millisecondes comme plancher de bruit ambiant, le
réévalue en continu sur les passages calmes, et considère « silence » tout ce qui reste
proche de ce plancher. Le cas dégénéré — un plancher qui monte jusqu'à avaler la parole —
est borné par la coupure forcée.
Règle : on ferme la tranche dès qu'on observe **~400 ms de silence** *et*
qu'au moins **1,5 s de parole** est en réserve. Filet de sécurité : au-delà de **~8 s**
sans silence exploitable, on coupe quand même — quelqu'un qui parle sans respirer ne doit
pas voir le texte se figer.
Le réglage « réactivité » pilote ces bornes **ensemble** plutôt que d'exposer des curseurs
dont personne ne connaît l'interaction :
| Réglage | Réserve minimale | Coupure forcée |
|---|---|---|
| Court | 1 s | 5 s |
| Moyen (défaut) | 1,5 s | 8 s |
| Long | 3 s | 15 s |
### La logique vit en Go
Le navigateur ouvre une WebSocket et pousse le PCM au fil de l'eau **sans rien décider**.
Go accumule, détecte les silences, appelle `whisper-server`, renvoie chaque tranche.
Deux raisons. Il n'existe aucun harnais de test JavaScript dans le dépôt, alors que la CI
fait tourner `go test` — et le découpeur est précisément la pièce qui mérite des tests.
Et le navigateur redevient bête : capter, envoyer, afficher.
### Contrat de la socket
- **Client → serveur** : trames binaires, PCM 16 kHz mono 16 bits
- **Serveur → client** : JSON, `{"texte": "..."}` par tranche définitive, `{"erreur": "..."}` sinon
Le champ de saisie **ajoute à la suite, sans jamais réécrire**. Aucune révision d'un texte
déjà affiché : c'est le comportement retenu, contre l'affichage mot à mot qui se corrige
sous les yeux.
### Langue forcée par défaut
Le réglage part sur **français** plutôt que `-l auto`. Sur des tranches de quelques
secondes, la détection automatique se trompe régulièrement — c'est le mécanisme le plus
probable derrière les `ლლლ` géorgiens observés. Le mode auto reste disponible.
## 3. Le panneau Dictée
Nouveau `data-pane="dictee"` dans la modale des réglages, entre « Moteur llama.cpp » et
« Identité ». Suit le gabarit des panneaux existants.
| Réglage | Contenu |
|---|---|
| **Matériel** | GPU détectés avec VRAM libre (`/api/vram`, `/api/backends/devices`), plus « CPU seul » |
| **Modèle** | `large-v3-turbo` q5_0 (574 Mo) par défaut, `medium` q5_0 (539 Mo), `small` q5_1 (190 Mo), `large-v3` q5_0 (1,1 Go) |
| **Langue** | Français par défaut, auto et autres langues disponibles |
| **Réactivité** | Court / Moyen / Long — les bornes ci-dessus |
| **État** | whisper-server allumé ou éteint, modèle chargé, dernière erreur |
| **Test du micro** | Capte 3 s, affiche le niveau réel et la transcription, sans toucher au champ de saisie |
Les modèles restent **hors de l'image**, dans `/data/whisper/` : ils survivent aux
recréations du conteneur et ne gonflent pas le tirage. Téléchargement à la demande avec
progression et suppression, en généralisant le mécanisme mono-modèle existant.
Persistance des réglages : `putStr(bkState, …)`, le magasin clé/valeur déjà utilisé pour
le jeton Hugging Face.
## Les pannes, nommées
Chaque échec dit ce qui a échoué et ce qu'on peut y faire :
- **VRAM insuffisante au démarrage** → on le dit et on propose le repli CPU, plutôt que
d'échouer sec
- **Modèle absent** → téléchargement avec progression, pas un micro qui ne répond pas
- **Socket coupée en pleine dictée** → le texte déjà obtenu reste dans le champ,
l'utilisateur est prévenu
- **whisper-server qui meurt** → on dit **comment** il est mort (code de sortie ou signal),
pas la dernière ligne de son journal. La leçon de la PR #18 : sur une mort par signal,
cette dernière ligne ressemble à une explication sans en être une, et envoie chercher
dans la mauvaise direction.
`POST /api/transcribe` **survit** et passe désormais par `whisper-server`. C'est le chemin
de repli si la WebSocket ne s'établit pas, et ce qui rend le test du micro trivial.
## Tests
En Go, sur ce qui peut réellement casser :
- **le découpeur** — silence franc, parole ininterrompue jusqu'à la coupure forcée,
tranche trop courte rejetée, silence total, bruit de fond continu au-dessus du seuil
- **l'environnement de whisper-server** — indice GPU → `CUDA_VISIBLE_DEVICES`, chemin du
modèle, langue
- **l'aller-retour des réglages** dans `bkState`
- **l'extinction sur inactivité**
- **le garde-fou du Dockerfile**, étendu : `GGML_NATIVE=OFF` conservé sur les deux cibles,
présence des cibles `whisper-server` CPU et CUDA, `GGML_CUDA=ON` sur la seconde
## Ce que les tests ne prouveront pas
**La qualité de reconnaissance ne peut pas être vérifiée automatiquement ici** : aucun
échantillon de voix n'est disponible côté développement. Les tests établissent que le
découpage et la supervision sont corrects ; que `large-v3-turbo` comprenne mieux que
`small` reste une constatation d'usage.
C'est la raison d'être du sélecteur de modèle et du test du micro : rendre le compromis
ajustable par celui qui l'entend, au lieu de le figer dans le code sur une intuition.
+53 -17
View File
@@ -84,6 +84,41 @@ func binSupportsNGLAuto(bin string) bool {
return strings.Contains(h[i:end], "'auto'")
}
// binFitsLayersItself : ce moteur sait-il répartir les couches tout seul ?
//
// La lecture fine de l'aide (binSupportsNGLAuto) reste la source de vérité, mais
// elle dépend de la mise en page d'un texte d'aide — une description reformulée
// ou une colonne plus large, et on conclut « non » sur un moteur parfaitement
// capable, donc on lui réimpose 999 et l'abandon revient. --load-mode est arrivé
// dans la même vague que « -ngl auto » : sa présence sert de second témoin, plus
// grossier mais insensible à la mise en page.
func binFitsLayersItself(bin string) bool {
return binSupportsNGLAuto(bin) || binSupportsLoadMode(bin)
}
// nglArgs traduit la valeur NGL du preset en arguments, et renvoie au passage la
// note à afficher quand Loki a substitué quelque chose. Fonction pure : la seule
// question qui demande le moteur (« sait-il répartir les couches tout seul ? »)
// arrive déjà tranchée, ce qui la rend testable sans lancer llama-server.
func nglArgs(ngl string, fitsItself bool) ([]string, string) {
ngl = strings.TrimSpace(ngl)
switch {
case strings.EqualFold(ngl, "auto"):
return nil, "" // rien du tout : le moteur décide seul
case ngl == "999" && fitsItself:
return []string{"-ngl", "auto"},
"NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la " +
"VRAM libre. NGL=all pour forcer l'ancien comportement."
case ngl != "":
return []string{"-ngl", ngl}, ""
case fitsItself:
return []string{"-ngl", "auto"}, ""
default:
// Moteur ancien : omettre -ngl le ferait tourner 100 % CPU.
return []string{"-ngl", "999"}, ""
}
}
// hasAnyFlag dit si l'utilisateur a déjà posé l'un de ces drapeaux dans
// EXTRA_ARGS. Loki ajoute plusieurs valeurs par défaut (--parallel, -ngl) : les
// ajouter EN PLUS de celles de l'utilisateur fait râler le moteur (« argument
@@ -240,31 +275,32 @@ func cmdServe(args []string) error {
if !hasAnyFlag(extra, "--parallel", "-np") {
llmArgs = append(llmArgs, "--parallel", get("PARALLEL", "1"))
}
// Couches GPU. Trois cas, dans cet ordre :
// Couches GPU. Quatre cas, dans cet ordre :
//
// NGL=<nombre> → -ngl <nombre> (l'utilisateur tranche)
// NGL=auto → rien du tout (compat : anciens presets)
// NGL=auto → rien du tout (le moteur décide seul)
// NGL=999 → -ngl auto sur un moteur récent (voir plus bas)
// NGL=<nombre> → -ngl <nombre> (l'utilisateur tranche pour de vrai)
// clé absente → -ngl auto si le moteur le propose, sinon -ngl 999
//
// Imposer un nombre désarme common_fit_params, qui mesure la VRAM libre et
// Imposer un NOMBRE désarme common_fit_params, qui mesure la VRAM libre et
// choisit combien de couches y tiennent : « n_gpu_layers already set by user
// to 999, abort ». Le moteur pousse alors tout sur le GPU, échoue en
// cudaMalloc ou se replie à moitié sur le CPU — débit effondré, GPU à 100 %.
// D'où « auto » par défaut dès que le moteur sait le faire. Sur un moteur
// plus ancien, qui ne connaît pas la valeur, on garde 999 : omettre -ngl le
// ferait tourner 100 % CPU.
//
// 999 est traité à part parce que ce n'a JAMAIS été un nombre de couches :
// c'est la sentinelle historique « toutes », semée dans config.env par
// defaultConfig() sur chaque installation neuve. La quasi-totalité des
// configurations la portent donc sans que personne ne l'ait choisie — la
// traiter comme un choix délibéré, c'est condamner tout le monde à l'abandon
// ci-dessus. Sur un moteur qui connaît « auto », 999 devient donc auto, et on
// le DIT sur stderr plutôt que de le faire en douce. Qui veut réellement
// forcer tout sur le GPU écrit NGL=all (ou un nombre qui n'est pas 999).
if !hasAnyFlag(extra, "-ngl", "--n-gpu-layers", "--gpu-layers") {
ngl := get("NGL", "")
switch {
case strings.EqualFold(ngl, "auto"):
// rien : le moteur décide seul
case ngl != "":
llmArgs = append(llmArgs, "-ngl", ngl)
case binSupportsNGLAuto(bin):
llmArgs = append(llmArgs, "-ngl", "auto")
default:
llmArgs = append(llmArgs, "-ngl", "999")
args, note := nglArgs(get("NGL", ""), binFitsLayersItself(bin))
if note != "" {
fmt.Fprintln(os.Stderr, "[loki serve] "+note)
}
llmArgs = append(llmArgs, args...)
}
if ktv != "" {
llmArgs = append(llmArgs, "-ctk", ktv)
+35
View File
@@ -56,3 +56,38 @@ func TestNormalizeLoadFlags(t *testing.T) {
})
}
}
func TestNGLArgs(t *testing.T) {
cases := []struct {
name string
ngl string
fitsItself bool
want []string
wantNote bool
}{
// Le cas qui motive tout : defaultConfig() sème NGL=999 sur chaque
// installation neuve. Personne ne l'a choisi, et le moteur récent
// abandonne son calcul de VRAM dès qu'on lui impose un nombre.
{"sentinelle 999, moteur récent", "999", true, []string{"-ngl", "auto"}, true},
{"sentinelle 999, moteur ancien", "999", false, []string{"-ngl", "999"}, false},
{"nombre choisi, jamais touché", "28", true, []string{"-ngl", "28"}, false},
{"nombre choisi, moteur ancien", "28", false, []string{"-ngl", "28"}, false},
{"all reste all", "all", true, []string{"-ngl", "all"}, false},
{"auto : aucun drapeau", "auto", true, nil, false},
{"auto insensible à la casse", "AUTO", true, nil, false},
{"espaces parasites autour de la sentinelle", " 999 ", true, []string{"-ngl", "auto"}, true},
{"clé absente, moteur récent", "", true, []string{"-ngl", "auto"}, false},
{"clé absente, moteur ancien", "", false, []string{"-ngl", "999"}, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, note := nglArgs(c.ngl, c.fitsItself)
if !reflect.DeepEqual(got, c.want) {
t.Fatalf("args = %q, want %q", got, c.want)
}
if (note != "") != c.wantNote {
t.Fatalf("note = %q, en voulait-on une ? %v", note, c.wantNote)
}
})
}
}
+90
View File
@@ -0,0 +1,90 @@
// Réglages de la dictée vocale — modèle, langue, matériel, 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.
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.
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.
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 c.Reactivity == "" {
c.Reactivity = "moyen"
}
return c
}
func dictateCfgLoad() DictateCfg {
var c DictateCfg
getJSON(bkState, dictateCfgKey, &c)
return c.withDefauts()
}
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 {
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)
}
// chunkBounds : réserve minimale avant de couper, et coupure forcée quand
// aucun silence n'arrive. Consommé par le découpeur du plan « temps réel ».
func (c DictateCfg) chunkBounds() (time.Duration, time.Duration) {
switch c.Reactivity {
case "court":
return 1000 * time.Millisecond, 5 * time.Second
case "long":
return 3 * time.Second, 15 * time.Second
default:
return 1500 * time.Millisecond, 8 * time.Second
}
}
+87
View File
@@ -0,0 +1,87 @@
package loki
import (
"testing"
"time"
)
func TestDictateCfgDefauts(t *testing.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 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)
}
}
func TestCudaVisibleDevices(t *testing.T) {
cas := []struct{ device, want string }{
{"cpu", ""},
{"0", "0"},
{"1", "1"},
{"", ""},
{"bidon", ""},
{"-1", ""},
}
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)
}
}
}
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},
}
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)
}
}
}
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)
}
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)
}
}
+74
View File
@@ -0,0 +1,74 @@
// 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 <LOKI_HOME>/whisper/, donc dans le
// volume /data — ils survivent aux recréations du conteneur.
package loki
import (
"os"
"path/filepath"
"sort"
)
// Les modèles ggml officiels de whisper.cpp.
const whisperModelBase = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/"
type whisperModel 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
}
// 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},
}
// whisperModelPathFor : chemin local. 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]
if !ok {
return ""
}
return filepath.Join(LokiHome(), "whisper", m.Fichier)
}
func whisperModelURLFor(id string) string {
m, ok := whisperCatalogue[id]
if !ok {
return ""
}
return whisperModelBase + m.Fichier
}
func whisperModelPresent(id string) bool {
p := whisperModelPathFor(id)
if p == "" {
return false
}
st, err := os.Stat(p)
return err == nil && st.Size() > 0
}
// whisperCatalogueTrie : 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 {
out = append(out, map[string]any{
"id": id, "nom": m.Nom, "octets": m.Octets,
"vram_mo": m.VRAMMo, "present": whisperModelPresent(id),
})
}
sort.Slice(out, func(i, j int) bool {
return out[i]["octets"].(int64) < out[j]["octets"].(int64)
})
return out
}
+75
View File
@@ -0,0 +1,75 @@
package loki
import (
"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 <LOKI_HOME>/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)
}
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)
}
}
}
}
+105
View File
@@ -0,0 +1,105 @@
package loki
import (
"encoding/json"
"net/http/httptest"
"strings"
"testing"
)
func TestConfigRouteAllerRetour(t *testing.T) {
testHome(t)
corps := strings.NewReader(`{"model":"small-q5_1","lang":"en","device":"1","reactivity":"court"}`)
rec := httptest.NewRecorder()
handleDictateConfig(rec, httptest.NewRequest("POST", "/api/dictate/config", corps))
if rec.Code != 200 {
t.Fatalf("POST = %d, corps %s", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
handleDictateConfig(rec, httptest.NewRequest("GET", "/api/dictate/config", nil))
var got DictateCfg
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" {
t.Errorf("relu %+v, attendu le réglage enregistré", got)
}
}
// Le refus doit venir de la ROUTE, pas seulement du magasin : sinon l'UI reçoit
// 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"}`)
rec := httptest.NewRecorder()
handleDictateConfig(rec, httptest.NewRequest("POST", "/api/dictate/config", corps))
if rec.Code == 200 {
t.Errorf("modèle hors catalogue accepté (%d) : %s", rec.Code, rec.Body.String())
}
}
func TestConfigRouteCorpsIllisible(t *testing.T) {
testHome(t)
rec := httptest.NewRecorder()
handleDictateConfig(rec, httptest.NewRequest("POST", "/api/dictate/config", strings.NewReader("pas du json")))
if rec.Code != 400 {
t.Errorf("code = %d, attendu 400", rec.Code)
}
}
func TestModelsRoute(t *testing.T) {
testHome(t)
rec := httptest.NewRecorder()
handleDictateModels(rec, httptest.NewRequest("GET", "/api/dictate/models", nil))
if rec.Code != 200 {
t.Fatalf("GET = %d", rec.Code)
}
var l []map[string]any
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))
}
}
// Un identifiant inconnu ne doit pas lancer de téléchargement : whisperDownload
// bâtirait une URL vide et le message d'erreur n'arriverait qu'en arrière-plan.
func TestDownloadRouteRefuseInconnu(t *testing.T) {
testHome(t)
rec := httptest.NewRecorder()
corps := strings.NewReader(`{"id":"nawak"}`)
handleDictateDownload(rec, httptest.NewRequest("POST", "/api/dictate/models/download", corps))
if rec.Code != 400 {
t.Errorf("code = %d, attendu 400 pour un modèle inconnu", rec.Code)
}
}
func TestStateRoute(t *testing.T) {
testHome(t)
rec := httptest.NewRecorder()
handleDictateState(rec, httptest.NewRequest("GET", "/api/dictate/state", nil))
if rec.Code != 200 {
t.Fatalf("GET = %d", rec.Code)
}
var e map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &e); err != nil {
t.Fatalf("réponse illisible : %v", err)
}
for _, clef := range []string{"actif", "modele", "present", "device", "dl"} {
if _, ok := e[clef]; !ok {
t.Errorf("clé %q manquante dans l'état", clef)
}
}
}
// Un audio trop court est rejeté AVANT toute tentative de lancer whisper-server :
// inutile de charger un modèle pour un clic parasite.
func TestTranscribeAudioTropCourt(t *testing.T) {
testHome(t)
rec := httptest.NewRecorder()
handleTranscribe(rec, httptest.NewRequest("POST", "/api/transcribe", strings.NewReader("court")))
if rec.Code != 400 {
t.Errorf("code = %d, attendu 400", rec.Code)
}
}
+328
View File
@@ -0,0 +1,328 @@
// 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.
//
// 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.
package loki
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net"
"net/http"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"time"
)
// 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} {
if p == "" {
continue
}
if _, err := os.Stat(p); err == nil {
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 {
return p
}
return ""
}
func whisperServerArgs(c DictateCfg, port int) ([]string, error) {
model := whisperModelPathFor(c.Model)
if 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),
}, 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 {
const clef = "CUDA_VISIBLE_DEVICES="
out := make([]string, 0, len(base)+1)
for _, v := range base {
if !strings.HasPrefix(v, clef) {
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())
}
// 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.
func portLibre() (int, error) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, err
}
defer func() { _ = l.Close() }()
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 {
s = strings.TrimSpace(s)
if s == "" {
return true
}
return (strings.HasPrefix(s, "[") && strings.HasSuffix(s, "]")) ||
(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")
if err != nil {
return "", err
}
if _, err := part.Write(wav); err != nil {
return "", err
}
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)
if err != nil {
return "", err
}
req.Header.Set("Content-Type", mw.FormDataContentType())
resp, err := http.DefaultClient.Do(req)
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)))
}
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)
}
if whisperMarqueur(j.Text) {
return "", nil
}
return strings.TrimSpace(j.Text), nil
}
// ─── Supervision ─────────────────────────────────────────────────────────────
// whisperIdle : au-delà, le serveur s'éteint et rend sa VRAM. 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
// 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
var wsrvMu sync.Mutex
var wsrv struct {
cmd *exec.Cmd
port int
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
// 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) {
cfg := dictateCfgLoad()
wsrvMu.Lock()
defer wsrvMu.Unlock()
if wsrv.cmd != nil && wsrv.cfg == cfg && wsrv.cmd.ProcessState == nil {
whisperTouchLocked()
return wsrv.port, nil
}
whisperStopLocked()
return whisperStartLocked(cfg)
}
func whisperStartLocked(cfg DictateCfg) (int, error) {
if !whisperModelPresent(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)
}
if bin == "" {
return 0, fmt.Errorf("whisper-server introuvable (image à reconstruire)")
}
port, err := portLibre()
if err != nil {
return 0, fmt.Errorf("aucun port libre pour whisper-server : %w", err)
}
args, err := whisperServerArgs(cfg, port)
if err != nil {
return 0, err
}
log := &bytes.Buffer{}
cmd := exec.Command(bin, args...)
cmd.Env = whisperServerEnv(cfg, 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)
}
wsrv.cmd, wsrv.port, wsrv.cfg = cmd, port, dictateCfgLoad()
wsrv.log, wsrv.note, wsrv.depuis = log, note, time.Now()
if err := whisperAttendPret(cmd, port, log); err != nil {
whisperStopLocked()
return 0, err
}
whisperTouchLocked()
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) + "/"
mort := make(chan error, 1)
go func() { mort <- cmd.Wait() }()
for time.Now().Before(fin) {
select {
case err := <-mort:
msg := whisperExitReason(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)
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()
if err == nil {
_ = resp.Body.Close()
return nil
}
}
return fmt.Errorf("whisper-server n'a pas répondu en %s", whisperReady)
}
func whisperTouchLocked() {
if wsrv.timer != nil {
wsrv.timer.Stop()
}
wsrv.timer = time.AfterFunc(whisperIdle, whisperShutdown)
}
func whisperStopLocked() {
if wsrv.timer != nil {
wsrv.timer.Stop()
wsrv.timer = nil
}
if wsrv.cmd != nil && wsrv.cmd.Process != nil {
_ = wsrv.cmd.Process.Kill()
}
wsrv.cmd, wsrv.port, wsrv.log = nil, 0, nil
wsrv.cfg, wsrv.note = 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() {
wsrvMu.Lock()
defer wsrvMu.Unlock()
whisperStopLocked()
}
// whisperInfer garantit le serveur puis lui soumet le WAV.
func whisperInfer(ctx context.Context, wav []byte) (string, error) {
port, err := whisperEnsure()
if err != nil {
return "", err
}
return whisperInferSur(ctx, "http://127.0.0.1:"+strconv.Itoa(port), wav)
}
func whisperEtat() 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,
}
if wsrv.cmd != nil {
etat["depuis_s"] = int(time.Since(wsrv.depuis).Seconds())
}
return etat
}
+188
View File
@@ -0,0 +1,188 @@
package loki
import (
"context"
"net"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
)
func argVal(args []string, flag string) (string, bool) {
for i, a := range args {
if a == flag && i+1 < len(args) {
return args[i+1], true
}
}
return "", false
}
func TestServerArgs(t *testing.T) {
testHome(t)
c := DictateCfg{Model: "small-q5_1", Lang: "fr", Device: "cpu"}.withDefauts()
args, err := whisperServerArgs(c, 8091)
if err != nil {
t.Fatalf("construction des arguments : %v", err)
}
if v, ok := argVal(args, "-m"); !ok || !strings.HasSuffix(v, "ggml-small-q5_1.bin") {
t.Errorf("-m = %q, doit pointer sur le .bin du modèle choisi", v)
}
if v, ok := argVal(args, "-l"); !ok || v != "fr" {
t.Errorf("-l = %q, attendu fr", v)
}
if v, ok := argVal(args, "--port"); !ok || v != "8091" {
t.Errorf("--port = %q, attendu 8091", v)
}
// whisper-server n'a aucune authentification : l'exposer sur 0.0.0.0
// ouvrirait la transcription à tout le réseau.
if v, ok := argVal(args, "--host"); !ok || v != "127.0.0.1" {
t.Errorf("--host = %q, attendu 127.0.0.1", v)
}
}
func TestServerArgsModeleInconnu(t *testing.T) {
testHome(t)
if _, err := whisperServerArgs(DictateCfg{Model: "nawak", Lang: "fr", Device: "cpu"}, 8091); err == nil {
t.Error("un modèle hors catalogue doit produire une erreur, pas une commande avec -m vide")
}
}
func TestServerEnvGPU(t *testing.T) {
base := []string{"PATH=/usr/bin", "CUDA_VISIBLE_DEVICES=7", "HOME=/root"}
env := whisperServerEnv(DictateCfg{Device: "1"}, base)
if !contientExact(env, "CUDA_VISIBLE_DEVICES=1") {
t.Errorf("env = %v, doit contenir CUDA_VISIBLE_DEVICES=1", env)
}
// L'ancienne valeur doit être REMPLACÉE, pas doublée : avec deux
// occurrences, le gagnant dépend de l'implémentation — le choix de
// l'utilisateur ne doit pas dépendre de ça.
if n := compte(env, "CUDA_VISIBLE_DEVICES="); n != 1 {
t.Errorf("%d occurrences de CUDA_VISIBLE_DEVICES, attendu exactement 1", n)
}
if !contientExact(env, "PATH=/usr/bin") {
t.Error("le reste de l'environnement doit être conservé")
}
env = whisperServerEnv(DictateCfg{Device: "cpu"}, base)
if !contientExact(env, "CUDA_VISIBLE_DEVICES=") {
t.Errorf("env = %v, en mode CPU la variable doit être posée VIDE (et non retirée) pour masquer tous les GPU", env)
}
}
func contientExact(l []string, s string) bool {
for _, v := range l {
if v == s {
return true
}
}
return false
}
func compte(l []string, prefixe string) int {
n := 0
for _, v := range l {
if strings.HasPrefix(v, prefixe) {
n++
}
}
return n
}
func TestPortLibre(t *testing.T) {
p, err := portLibre()
if err != nil {
t.Fatalf("portLibre : %v", err)
}
if p < 1024 || p > 65535 {
t.Errorf("port = %d, hors de la plage utilisable", p)
}
l, err := net.Listen("tcp", "127.0.0.1:"+strconv.Itoa(p))
if err != nil {
t.Fatalf("port %d annoncé libre mais inutilisable : %v", p, err)
}
_ = l.Close()
}
func TestInferParseLaReponse(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/inference" {
t.Errorf("chemin appelé = %q, attendu /inference", r.URL.Path)
}
if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "multipart/form-data") {
t.Errorf("Content-Type = %q, whisper-server attend du multipart", ct)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"text":" bonjour ceci est un test "}`))
}))
defer srv.Close()
txt, err := whisperInferSur(context.Background(), srv.URL, []byte("RIFFfaux"))
if err != nil {
t.Fatalf("whisperInferSur : %v", err)
}
if txt != "bonjour ceci est un test" {
t.Errorf("texte = %q, les espaces de bord doivent être retirés", txt)
}
}
// Sur du silence, whisper renvoie ses marqueurs internes. Les laisser passer
// les collerait tels quels dans le champ de saisie — c'est ce qui est arrivé
// avec [BLANK_AUDIO].
func TestInferFiltreLesMarqueurs(t *testing.T) {
for _, marqueur := range []string{"[BLANK_AUDIO]", "(silence)", "[SOUND]", " [ Silence ] ", " "} {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"text":"` + marqueur + `"}`))
}))
txt, err := whisperInferSur(context.Background(), srv.URL, []byte("RIFFfaux"))
srv.Close()
if err != nil {
t.Fatalf("whisperInferSur(%q) : %v", marqueur, err)
}
if txt != "" {
t.Errorf("marqueur %q rendu comme texte %q, attendu vide", marqueur, txt)
}
}
}
func TestInferErreurHTTP(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(500)
_, _ = w.Write([]byte("boom"))
}))
defer srv.Close()
if _, err := whisperInferSur(context.Background(), srv.URL, []byte("RIFFfaux")); err == nil {
t.Error("un 500 de whisper-server doit remonter une erreur")
}
}
// Un modèle absent doit être annoncé comme tel, pas produire un lancement de
// whisper-server voué à mourir sur un fichier introuvable.
func TestEnsureModeleAbsent(t *testing.T) {
testHome(t)
if err := dictateCfgSave(DictateCfg{Model: "small-q5_1", Lang: "fr", Device: "cpu", Reactivity: "moyen"}); err != nil {
t.Fatal(err)
}
_, err := whisperEnsure()
if err == nil {
t.Fatal("un modèle absent doit produire une erreur")
}
if !strings.Contains(err.Error(), "absent") {
t.Errorf("erreur = %q, elle doit dire que le modèle est absent", err)
}
}
func TestEtatSansServeur(t *testing.T) {
testHome(t)
e := whisperEtat()
if e["actif"] != false {
t.Errorf("actif = %v, attendu false sans serveur lancé", e["actif"])
}
for _, clef := range []string{"modele", "present", "device", "note"} {
if _, ok := e[clef]; !ok {
t.Errorf("clé %q manquante — l'UI en a besoin", clef)
}
}
}
+1 -1
View File
@@ -67,7 +67,7 @@ var configTemplate = []struct{ key, help string }{
{"HOST", "adresse d'écoute du moteur (défaut 0.0.0.0)"},
{"PORT", "port du moteur (défaut 8080)"},
{"CTX", "taille du contexte (défaut 32768)"},
{"NGL", "couches déportées sur le GPU (défaut 999 = tout ; auto = ce qui tient en VRAM)"},
{"NGL", "couches déportées sur le GPU (999 ou auto = ce qui tient en VRAM ; all = tout, quitte à saturer)"},
{"BATCH", "batch (défaut 2048)"},
{"UBATCH", "micro-batch (défaut 512)"},
{"THREADS", "threads CPU, 0 = auto"},
+217 -2
View File
@@ -311,6 +311,10 @@ details>summary::-webkit-details-marker{display:none}
.preset-add{display:none;font-size:14px;padding:0 8px;margin:0 8px 0 auto;line-height:1.4;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:3px;cursor:pointer}
.preset-add:hover{border-color:var(--accent);color:var(--accent)}
details[open]>summary>.preset-add{display:inline-block}
/* Les panneaux de réglages ne sont plus des <details>/<summary> mais des
<section class="set-pane"> : sans cette règle, le « + » des Presets restait
en display:none et il n'y avait plus aucun moyen d'en créer un. */
.set-pane-h>.preset-add{display:inline-block}
.tag{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;background:var(--panel);border:1px solid var(--border)}
.tag.ok{color:var(--ok);border-color:var(--ok)}
.tag.err{color:var(--err);border-color:var(--err)}
@@ -2390,6 +2394,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<div class="set-nav-h">Moteur</div>
<button data-pane="engine" onclick="settingsShow('engine')">Moteur llama.cpp</button>
<button data-pane="engine-log" onclick="settingsShow('engine-log')">Journal du moteur</button>
<button data-pane="dictee" onclick="settingsShow('dictee')">Dictée</button>
<div class="set-nav-h">Application</div>
<button data-pane="identity" onclick="settingsShow('identity')">Identité</button>
<button data-pane="appearance" onclick="settingsShow('appearance')">Apparence</button>
@@ -2598,6 +2603,61 @@ html[data-files="1"] #files-btn{color:var(--accent)}
</div>
</div></div>
</section>
<!-- Dictée vocale. Réglages de SERVEUR : le modèle chargé et le GPU occupé
sont des propriétés de la machine, identiques pour tous les onglets.
Voir js/24-dictate-settings.js et internal/loki/dictate_*.go. -->
<section class="set-pane" data-pane="dictee">
<div class="set-pane-h"><span>Dictée</span></div>
<div class="subhead">Matériel<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">whisper tourne dans un processus séparé, allumé au premier clic sur le micro et éteint après <b>dix minutes</b> sans dictée — il ne garde pas la VRAM occupée pour rien. Le GPU choisi est transmis par <code>CUDA_VISIBLE_DEVICES</code>.</span></span></div>
<div class="slist">
<label class="switchrow"><span>processeur ou GPU</span>
<select id="dic-device" class="sctl"></select>
</label>
</div>
<div class="subhead">Modèle<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Les modèles ne sont <b>pas</b> dans l'image : ils sont téléchargés à la demande dans <code>/data/whisper/</code> et survivent aux recréations du conteneur. Plus le modèle est gros, mieux il comprend — et plus il occupe de mémoire.</span></span></div>
<div class="slist">
<label class="switchrow"><span>modèle</span>
<select id="dic-model" class="sctl"></select>
</label>
<div class="rowbody">
<button id="dic-dl" class="btn" onclick="dictateDownload()">Télécharger ce modèle</button>
<span id="dic-dl-state" class="dim"></span>
</div>
</div>
<div class="subhead">Reconnaissance</div>
<div class="slist">
<label class="switchrow"><span>langue</span>
<select id="dic-lang" class="sctl">
<option value="fr">français</option>
<option value="en">anglais</option>
<option value="de">allemand</option>
<option value="es">espagnol</option>
<option value="it">italien</option>
<option value="auto">détection automatique</option>
</select>
</label>
<label class="switchrow"><span>réactivité<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Longueur des tranches transcrites au fil de la dictée. Court = le texte arrive plus tôt, avec moins de contexte donc plus d'erreurs. Long = mieux reconnu, mais plus d'attente.</span></span></span>
<select id="dic-react" class="sctl">
<option value="court">court</option>
<option value="moyen">moyen</option>
<option value="long">long</option>
</select>
</label>
<div class="rowbody">
<button class="btn primary" onclick="dictateSave()">Enregistrer</button>
<span id="dic-save-state" class="dim"></span>
</div>
</div>
<div class="subhead">Vérifier</div>
<div class="slist">
<div class="rowbody">
<button id="dic-test" class="btn" onclick="dictateTestMic()">Tester le micro (3 s)</button>
</div>
<div id="dic-test-out" class="dim"></div>
<div id="dic-state" class="dim"></div>
</div>
</section>
<!-- ── PARAMÈTRES DE L'APPLICATION ────────────────────────────────────
Séparés des réglages d'IA (Machine, Presets, Mode agent, System
prompt, Moteur), qui restent au-dessus : ce qui suit ne concerne que
@@ -3121,7 +3181,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<span class="pe-row-c"><input id="s-ctx" class="pe-val" type="number" min="0" step="1024" placeholder="32768" oninput="cfgWriteKey('CTX', this.value)"><span class="pe-unit">tok</span></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Couches sur GPU<span class="pe-sub">999 = tout sur le GPU · auto = ce qui tient en VRAM</span></span>
<span class="pe-row-l">Couches sur GPU<span class="pe-sub">999 ou auto = ce qui tient en VRAM · all = tout, quitte à saturer</span></span>
<span class="pe-row-c"><input id="s-ngl" class="pe-val" type="text" inputmode="numeric" placeholder="999" oninput="cfgWriteKey('NGL', this.value)"></span>
</div>
<div class="pe-row">
@@ -9170,6 +9230,7 @@ let SET_LAST = 'config';
// l'ancien ontoggle des <details>.
const SET_HOOKS = {
'engine-log': () => { loadSvcLog(); showPaths(); },
'dictee': () => { loadDictateSettings(); },
};
function openSettings(pane){
showModal('settings-modal');
@@ -9246,7 +9307,7 @@ async function dictateStop(){
// renvoie la raison. La cacher derrière « 0 % » laissait l'utilisateur
// cliquer indéfiniment sur un micro qui n'écrira jamais rien.
toast(j.error ? 'modèle de dictée : échec du téléchargement — ' + j.error
: 'modèle de dictée en téléchargement (' + (j.pct||0) + ' %) — réessaie dans un instant');
: 'modèle de dictée en téléchargement (' + (j.pct||0) + ' %) — suivi dans Paramètres → Dictée');
return;
}
if(!r.ok){ toast(j.error || 'échec de la transcription'); return; }
@@ -9283,4 +9344,158 @@ function encodeWav16k(chunks, fromRate){
new Int16Array(buf, 44).set(pcm);
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/*.
let DIC_CFG = null;
async function loadDictateSettings(){
const [cfg, modeles, gpus] = 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.
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.value = cfg.model || '';
document.getElementById('dic-lang').value = cfg.lang || 'fr';
document.getElementById('dic-react').value = cfg.reactivity || 'moyen';
refreshDictateState();
}
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');
el.textContent = 'enregistrement…';
const r = await jfetch('/api/dictate/config', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(cfg)});
const j = await r.json().catch(()=>({}));
if(!r.ok){ el.textContent = j.error || 'échec'; return; }
DIC_CFG = cfg;
// Le serveur de dictée a été arrêté côté Loki : le prochain clic sur le
// micro le relancera avec ces réglages. On le dit, sinon la première dictée
// suivante paraît anormalement lente sans raison visible.
el.textContent = 'enregistré — la prochaine dictée rechargera le modèle';
refreshDictateState();
}
async function dictateDownload(){
const id = document.getElementById('dic-model').value;
const el = document.getElementById('dic-dl-state');
el.textContent = 'démarrage…';
const r = await jfetch('/api/dictate/models/download', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({id})});
const j = await r.json().catch(()=>({}));
if(!r.ok){ el.textContent = j.error || 'échec'; return; }
suivreTelechargement();
}
// Sondage tant que le téléchargement tourne. Un modèle de 1 Go prend plusieurs
// minutes : sans progression affichée, l'utilisateur ne sait pas s'il se passe
// quelque chose.
async function suivreTelechargement(){
const el = document.getElementById('dic-dl-state');
const r = await jfetch('/api/dictate/models/download');
const j = await r.json().catch(()=>({}));
if(j.error){ el.textContent = 'échec : ' + j.error; return; }
if(j.running){
el.textContent = `téléchargement de ${j.id} — ${j.pct||0} %`;
setTimeout(suivreTelechargement, 1000);
return;
}
el.textContent = 'téléchargé';
loadDictateSettings();
}
async function refreshDictateState(){
const el = document.getElementById('dic-state');
if(!el) return;
const j = await jfetch('/api/dictate/state').then(r => r.json()).catch(()=>null);
if(!j){ el.textContent = ''; return; }
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);
if(j.dl && j.dl.running) bouts.push(`téléchargement ${j.dl.id} ${j.dl.pct||0} %`);
el.textContent = bouts.join(' · ');
}
// Test du micro : capte 3 s, mesure le niveau réel et transcrit, SANS toucher
// au champ de saisie. C'est le diagnostic qu'il fallait bricoler à la main
// quand la dictée ne rendait rien — niveau nul, micro muet, serveur en panne
// se ressemblent tous de l'extérieur.
async function dictateTestMic(){
const out = document.getElementById('dic-test-out');
const btn = document.getElementById('dic-test');
if(!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)){
out.textContent = 'micro bloqué par le navigateur : il faut HTTPS (ou localhost)';
return;
}
btn.disabled = true;
out.textContent = 'écoute… parle maintenant (3 s)';
let stream;
try{
stream = await navigator.mediaDevices.getUserMedia({audio:{channelCount:1, echoCancellation:true, noiseSuppression:true}});
}catch(e){ out.textContent = 'accès au micro refusé'; btn.disabled = false; return; }
const ctx = new (window.AudioContext||window.webkitAudioContext)();
const src = ctx.createMediaStreamSource(stream);
const node = ctx.createScriptProcessor(4096, 1, 1);
const chunks = [];
node.onaudioprocess = e => chunks.push(new Float32Array(e.inputBuffer.getChannelData(0)));
src.connect(node); node.connect(ctx.destination);
await new Promise(r => setTimeout(r, 3000));
try{ src.disconnect(); node.disconnect(); }catch(_){}
stream.getTracks().forEach(t => t.stop());
const rate = ctx.sampleRate;
try{ ctx.close(); }catch(_){}
let n = 0; for(const c of chunks) n += c.length;
let somme = 0, crete = 0;
for(const c of chunks) for(let i = 0; i < c.length; i++){
const v = c[i]; somme += v*v;
const a = Math.abs(v); if(a > crete) crete = a;
}
const rms = Math.sqrt(somme / (n||1));
const niveau = rms < 0.002 ? 'QUASI MUET' : rms < 0.01 ? 'faible' : 'correct';
out.textContent = `niveau ${niveau} (rms ${rms.toFixed(4)}, crête ${crete.toFixed(3)}) — transcription…`;
const wav = encodeWav16k(chunks, rate);
const r = await jfetch('/api/transcribe', {method:'POST', headers:{'Content-Type':'audio/wav'}, body: wav});
const j = await r.json().catch(()=>({}));
const tete = `niveau ${niveau} (rms ${rms.toFixed(4)}) — `;
if(!r.ok) out.textContent = tete + (j.error || 'échec de la transcription');
else if(!(j.text||'').trim()) out.textContent = tete + 'rien compris' + (rms < 0.002 ? ' (le micro n’a rien capté : vérifie l’entrée choisie par le système)' : '');
else out.textContent = tete + '« ' + j.text.trim() + ' »';
btn.disabled = false;
refreshDictateState();
}
</script></body></html>
+57 -1
View File
@@ -253,6 +253,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<div class="set-nav-h">Moteur</div>
<button data-pane="engine" onclick="settingsShow('engine')">Moteur llama.cpp</button>
<button data-pane="engine-log" onclick="settingsShow('engine-log')">Journal du moteur</button>
<button data-pane="dictee" onclick="settingsShow('dictee')">Dictée</button>
<div class="set-nav-h">Application</div>
<button data-pane="identity" onclick="settingsShow('identity')">Identité</button>
<button data-pane="appearance" onclick="settingsShow('appearance')">Apparence</button>
@@ -461,6 +462,61 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
</div>
</div></div>
</section>
<!-- Dictée vocale. Réglages de SERVEUR : le modèle chargé et le GPU occupé
sont des propriétés de la machine, identiques pour tous les onglets.
Voir js/24-dictate-settings.js et internal/loki/dictate_*.go. -->
<section class="set-pane" data-pane="dictee">
<div class="set-pane-h"><span>Dictée</span></div>
<div class="subhead">Matériel<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">whisper tourne dans un processus séparé, allumé au premier clic sur le micro et éteint après <b>dix minutes</b> sans dictée — il ne garde pas la VRAM occupée pour rien. Le GPU choisi est transmis par <code>CUDA_VISIBLE_DEVICES</code>.</span></span></div>
<div class="slist">
<label class="switchrow"><span>processeur ou GPU</span>
<select id="dic-device" class="sctl"></select>
</label>
</div>
<div class="subhead">Modèle<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Les modèles ne sont <b>pas</b> dans l'image : ils sont téléchargés à la demande dans <code>/data/whisper/</code> et survivent aux recréations du conteneur. Plus le modèle est gros, mieux il comprend — et plus il occupe de mémoire.</span></span></div>
<div class="slist">
<label class="switchrow"><span>modèle</span>
<select id="dic-model" class="sctl"></select>
</label>
<div class="rowbody">
<button id="dic-dl" class="btn" onclick="dictateDownload()">Télécharger ce modèle</button>
<span id="dic-dl-state" class="dim"></span>
</div>
</div>
<div class="subhead">Reconnaissance</div>
<div class="slist">
<label class="switchrow"><span>langue</span>
<select id="dic-lang" class="sctl">
<option value="fr">français</option>
<option value="en">anglais</option>
<option value="de">allemand</option>
<option value="es">espagnol</option>
<option value="it">italien</option>
<option value="auto">détection automatique</option>
</select>
</label>
<label class="switchrow"><span>réactivité<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Longueur des tranches transcrites au fil de la dictée. Court = le texte arrive plus tôt, avec moins de contexte donc plus d'erreurs. Long = mieux reconnu, mais plus d'attente.</span></span></span>
<select id="dic-react" class="sctl">
<option value="court">court</option>
<option value="moyen">moyen</option>
<option value="long">long</option>
</select>
</label>
<div class="rowbody">
<button class="btn primary" onclick="dictateSave()">Enregistrer</button>
<span id="dic-save-state" class="dim"></span>
</div>
</div>
<div class="subhead">Vérifier</div>
<div class="slist">
<div class="rowbody">
<button id="dic-test" class="btn" onclick="dictateTestMic()">Tester le micro (3 s)</button>
</div>
<div id="dic-test-out" class="dim"></div>
<div id="dic-state" class="dim"></div>
</div>
</section>
<!-- ── PARAMÈTRES DE L'APPLICATION ────────────────────────────────────
Séparés des réglages d'IA (Machine, Presets, Mode agent, System
prompt, Moteur), qui restent au-dessus : ce qui suit ne concerne que
@@ -984,7 +1040,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<span class="pe-row-c"><input id="s-ctx" class="pe-val" type="number" min="0" step="1024" placeholder="32768" oninput="cfgWriteKey('CTX', this.value)"><span class="pe-unit">tok</span></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Couches sur GPU<span class="pe-sub">999 = tout sur le GPU · auto = ce qui tient en VRAM</span></span>
<span class="pe-row-l">Couches sur GPU<span class="pe-sub">999 ou auto = ce qui tient en VRAM · all = tout, quitte à saturer</span></span>
<span class="pe-row-c"><input id="s-ngl" class="pe-val" type="text" inputmode="numeric" placeholder="999" oninput="cfgWriteKey('NGL', this.value)"></span>
</div>
<div class="pe-row">
@@ -14,6 +14,7 @@ let SET_LAST = 'config';
// l'ancien ontoggle des <details>.
const SET_HOOKS = {
'engine-log': () => { loadSvcLog(); showPaths(); },
'dictee': () => { loadDictateSettings(); },
};
function openSettings(pane){
showModal('settings-modal');
+1 -1
View File
@@ -53,7 +53,7 @@ async function dictateStop(){
// renvoie la raison. La cacher derrière « 0 % » laissait l'utilisateur
// cliquer indéfiniment sur un micro qui n'écrira jamais rien.
toast(j.error ? 'modèle de dictée : échec du téléchargement — ' + j.error
: 'modèle de dictée en téléchargement (' + (j.pct||0) + ' %) — réessaie dans un instant');
: 'modèle de dictée en téléchargement (' + (j.pct||0) + ' %) — suivi dans Paramètres → Dictée');
return;
}
if(!r.ok){ toast(j.error || 'échec de la transcription'); return; }
@@ -0,0 +1,154 @@
// ─── 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/*.
let DIC_CFG = null;
async function loadDictateSettings(){
const [cfg, modeles, gpus] = 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.
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.value = cfg.model || '';
document.getElementById('dic-lang').value = cfg.lang || 'fr';
document.getElementById('dic-react').value = cfg.reactivity || 'moyen';
refreshDictateState();
}
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');
el.textContent = 'enregistrement…';
const r = await jfetch('/api/dictate/config', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(cfg)});
const j = await r.json().catch(()=>({}));
if(!r.ok){ el.textContent = j.error || 'échec'; return; }
DIC_CFG = cfg;
// Le serveur de dictée a été arrêté côté Loki : le prochain clic sur le
// micro le relancera avec ces réglages. On le dit, sinon la première dictée
// suivante paraît anormalement lente sans raison visible.
el.textContent = 'enregistré — la prochaine dictée rechargera le modèle';
refreshDictateState();
}
async function dictateDownload(){
const id = document.getElementById('dic-model').value;
const el = document.getElementById('dic-dl-state');
el.textContent = 'démarrage…';
const r = await jfetch('/api/dictate/models/download', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({id})});
const j = await r.json().catch(()=>({}));
if(!r.ok){ el.textContent = j.error || 'échec'; return; }
suivreTelechargement();
}
// Sondage tant que le téléchargement tourne. Un modèle de 1 Go prend plusieurs
// minutes : sans progression affichée, l'utilisateur ne sait pas s'il se passe
// quelque chose.
async function suivreTelechargement(){
const el = document.getElementById('dic-dl-state');
const r = await jfetch('/api/dictate/models/download');
const j = await r.json().catch(()=>({}));
if(j.error){ el.textContent = 'échec : ' + j.error; return; }
if(j.running){
el.textContent = `téléchargement de ${j.id} — ${j.pct||0} %`;
setTimeout(suivreTelechargement, 1000);
return;
}
el.textContent = 'téléchargé';
loadDictateSettings();
}
async function refreshDictateState(){
const el = document.getElementById('dic-state');
if(!el) return;
const j = await jfetch('/api/dictate/state').then(r => r.json()).catch(()=>null);
if(!j){ el.textContent = ''; return; }
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);
if(j.dl && j.dl.running) bouts.push(`téléchargement ${j.dl.id} ${j.dl.pct||0} %`);
el.textContent = bouts.join(' · ');
}
// Test du micro : capte 3 s, mesure le niveau réel et transcrit, SANS toucher
// au champ de saisie. C'est le diagnostic qu'il fallait bricoler à la main
// quand la dictée ne rendait rien — niveau nul, micro muet, serveur en panne
// se ressemblent tous de l'extérieur.
async function dictateTestMic(){
const out = document.getElementById('dic-test-out');
const btn = document.getElementById('dic-test');
if(!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)){
out.textContent = 'micro bloqué par le navigateur : il faut HTTPS (ou localhost)';
return;
}
btn.disabled = true;
out.textContent = 'écoute… parle maintenant (3 s)';
let stream;
try{
stream = await navigator.mediaDevices.getUserMedia({audio:{channelCount:1, echoCancellation:true, noiseSuppression:true}});
}catch(e){ out.textContent = 'accès au micro refusé'; btn.disabled = false; return; }
const ctx = new (window.AudioContext||window.webkitAudioContext)();
const src = ctx.createMediaStreamSource(stream);
const node = ctx.createScriptProcessor(4096, 1, 1);
const chunks = [];
node.onaudioprocess = e => chunks.push(new Float32Array(e.inputBuffer.getChannelData(0)));
src.connect(node); node.connect(ctx.destination);
await new Promise(r => setTimeout(r, 3000));
try{ src.disconnect(); node.disconnect(); }catch(_){}
stream.getTracks().forEach(t => t.stop());
const rate = ctx.sampleRate;
try{ ctx.close(); }catch(_){}
let n = 0; for(const c of chunks) n += c.length;
let somme = 0, crete = 0;
for(const c of chunks) for(let i = 0; i < c.length; i++){
const v = c[i]; somme += v*v;
const a = Math.abs(v); if(a > crete) crete = a;
}
const rms = Math.sqrt(somme / (n||1));
const niveau = rms < 0.002 ? 'QUASI MUET' : rms < 0.01 ? 'faible' : 'correct';
out.textContent = `niveau ${niveau} (rms ${rms.toFixed(4)}, crête ${crete.toFixed(3)}) — transcription…`;
const wav = encodeWav16k(chunks, rate);
const r = await jfetch('/api/transcribe', {method:'POST', headers:{'Content-Type':'audio/wav'}, body: wav});
const j = await r.json().catch(()=>({}));
const tete = `niveau ${niveau} (rms ${rms.toFixed(4)}) — `;
if(!r.ok) out.textContent = tete + (j.error || 'échec de la transcription');
else if(!(j.text||'').trim()) out.textContent = tete + 'rien compris' + (rms < 0.002 ? ' (le micro n’a rien capté : vérifie l’entrée choisie par le système)' : '');
else out.textContent = tete + '« ' + j.text.trim() + ' »';
btn.disabled = false;
refreshDictateState();
}
+4
View File
@@ -280,6 +280,10 @@ details>summary::-webkit-details-marker{display:none}
.preset-add{display:none;font-size:14px;padding:0 8px;margin:0 8px 0 auto;line-height:1.4;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:3px;cursor:pointer}
.preset-add:hover{border-color:var(--accent);color:var(--accent)}
details[open]>summary>.preset-add{display:inline-block}
/* Les panneaux de réglages ne sont plus des <details>/<summary> mais des
<section class="set-pane"> : sans cette règle, le « + » des Presets restait
en display:none et il n'y avait plus aucun moyen d'en créer un. */
.set-pane-h>.preset-add{display:inline-block}
.tag{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;background:var(--panel);border:1px solid var(--border)}
.tag.ok{color:var(--ok);border-color:var(--ok)}
.tag.err{color:var(--err);border-color:var(--err)}
+6 -2
View File
@@ -168,8 +168,12 @@ func newWebMux() *http.ServeMux {
api := func(path string, h http.HandlerFunc) { mux.HandleFunc(path, requireWebAuth(h)) }
api("/api/ping", handlePing)
api("/api/status", handleStatus)
api("/api/transcribe", handleTranscribe) // dictée vocale (whisper.cpp local)
api("/api/service/log", handleServiceLog) // journal du service pour diagnostiquer un modèle qui ne charge pas
api("/api/transcribe", handleTranscribe) // dictée vocale (whisper.cpp local)
api("/api/dictate/config", handleDictateConfig) // réglages : modèle, langue, matériel, réactivité
api("/api/dictate/models", handleDictateModels) // catalogue + présence sur disque
api("/api/dictate/models/download", handleDictateDownload) // télécharge un modèle
api("/api/dictate/state", handleDictateState) // serveur allumé, modèle chargé, dernière erreur
api("/api/service/log", handleServiceLog) // journal du service pour diagnostiquer un modèle qui ne charge pas
api("/api/vram", handleVram)
api("/api/ram", handleRam)
api("/api/config", handleConfigEnv)
+148 -99
View File
@@ -1,65 +1,53 @@
// 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-cli (whisper.cpp, même famille que llama.cpp), compilé dans
// l'image Docker. Le modèle (ggml-small-q5_1, ~190 Mo, multilingue) 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 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.
//
// 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.
package loki
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"time"
)
const whisperModelURL = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small-q5_1.bin"
// whisperBin localise whisper-cli : variable d'environnement (posée par le
// Dockerfile), sinon le PATH. Vide = dictée indisponible (image trop ancienne,
// ou poste de dev sans whisper.cpp) — le handler le dit clairement.
func whisperBin() string {
if p := os.Getenv("LOKI_WHISPER_BIN"); p != "" {
if _, err := os.Stat(p); err == nil {
return p
}
}
if p, err := exec.LookPath("whisper-cli"); err == nil {
return p
}
return ""
}
func whisperModelPath() string {
return filepath.Join(LokiHome(), "whisper", "ggml-small-q5_1.bin")
}
// Téléchargement du modèle : UNE goroutine, progression consultable. Le premier
// POST le déclenche et répond 503 {downloading, pct} ; l'UI invite à réessayer.
// Téléchargement d'un modèle : UNE goroutine à la fois, progression
// consultable. Le premier POST le déclenche et répond 503 {downloading, pct} ;
// l'UI invite à réessayer.
var wspMu sync.Mutex
var wspDl struct {
id string
running bool
pct int
err string
}
func whisperStartDownload() {
func whisperStartDownload(id string) {
wspMu.Lock()
defer wspMu.Unlock()
if wspDl.running {
return
}
wspDl.running, wspDl.pct, wspDl.err = true, 0, ""
if whisperModelURLFor(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()
err := whisperDownload(id)
wspMu.Lock()
wspDl.running = false
if err != nil {
@@ -69,19 +57,24 @@ func whisperStartDownload() {
}()
}
func whisperDownload() error {
dst := whisperModelPath()
func whisperDownload(id string) error {
dst := whisperModelPathFor(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(whisperModelURL)
resp, err := http.Get(whisperModelURLFor(id))
if err != nil {
return err
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != 200 {
return fmt.Errorf("téléchargement du modèle whisper : 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 {
@@ -94,8 +87,8 @@ func whisperDownload() error {
n, rerr := resp.Body.Read(buf)
if n > 0 {
if _, werr := f.Write(buf[:n]); werr != nil {
f.Close()
os.Remove(tmp)
_ = f.Close()
_ = os.Remove(tmp)
return werr
}
done += int64(n)
@@ -109,83 +102,139 @@ func whisperDownload() error {
break
}
if rerr != nil {
f.Close()
os.Remove(tmp)
_ = f.Close()
_ = os.Remove(tmp)
return rerr
}
}
if err := f.Close(); err != nil {
os.Remove(tmp)
_ = os.Remove(tmp)
return err
}
return os.Rename(tmp, dst)
}
func handleTranscribe(w http.ResponseWriter, r *http.Request) {
bin := whisperBin()
if bin == "" {
sendJSON(w, 501, map[string]any{"error": "dictée indisponible : whisper-cli absent (image à reconstruire, ou poste sans whisper.cpp)"})
return
}
model := whisperModelPath()
if _, err := os.Stat(model); err != nil {
wspMu.Lock()
derr := wspDl.err
pct := wspDl.pct
wspMu.Unlock()
if derr != "" {
// Échec précédent : on le dit ET on relance — un réseau revenu suffit.
wspMu.Lock()
wspDl.err = ""
wspMu.Unlock()
whisperStartDownload()
sendJSON(w, 503, map[string]any{"downloading": true, "pct": 0, "error": derr})
return
}
whisperStartDownload()
sendJSON(w, 503, map[string]any{"downloading": true, "pct": pct})
return
}
// whisperDlEtat : instantané de la progression, pour l'UI et pour les 503.
func whisperDlEtat() (id string, running bool, pct int, errMsg string) {
wspMu.Lock()
defer wspMu.Unlock()
return wspDl.id, wspDl.running, wspDl.pct, wspDl.err
}
func handleTranscribe(w http.ResponseWriter, r *http.Request) {
audio, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 32<<20))
if err != nil || len(audio) < 1000 {
sendJSON(w, 400, map[string]any{"error": "audio vide ou trop court"})
return
}
tmp, err := os.CreateTemp("", "loki-dictee-*.wav")
if err != nil {
sendJSON(w, 500, map[string]any{"error": err.Error()})
return
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if _, err := tmp.Write(audio); err != nil {
tmp.Close()
sendJSON(w, 500, map[string]any{"error": err.Error()})
return
}
tmp.Close()
// -l auto : détection de langue (le français sort en français) ; -nt sans
// horodatages ; -np sans bavardage de chargement. CPU seul : une dictée de
// quelques dizaines de secondes se transcrit en 1-3 s avec small-q5_1.
threads := runtime.NumCPU()
if threads > 8 {
threads = 8
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
defer cancel()
cmd := exec.CommandContext(ctx, bin, "-m", model, "-f", tmpPath, "-l", "auto", "-nt", "-np", "-t", strconv.Itoa(threads))
var stderr strings.Builder
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
cfg := dictateCfgLoad()
if !whisperModelPresent(cfg.Model) {
_, _, pct, derr := whisperDlEtat()
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)
sendJSON(w, 503, map[string]any{"downloading": true, "pct": 0, "error": derr})
return
}
sendJSON(w, 500, map[string]any{"error": "whisper-cli : " + msg})
whisperStartDownload(cfg.Model)
sendJSON(w, 503, map[string]any{"downloading": true, "pct": pct})
return
}
sendJSON(w, 200, map[string]any{"text": strings.TrimSpace(string(out))})
// Le chargement du modèle au premier appel peut être long ; la
// transcription elle-même ne l'est pas.
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
defer cancel()
txt, err := whisperInfer(ctx, audio)
if err != nil {
sendJSON(w, 500, map[string]any{"error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"text": txt})
}
// ─── Réglages de la dictée ───────────────────────────────────────────────────
func handleDictateConfig(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
var c DictateCfg
if err := json.NewDecoder(r.Body).Decode(&c); err != nil {
sendJSON(w, 400, map[string]any{"error": "requête illisible"})
return
}
if err := dictateCfgSave(c); err != nil {
sendJSON(w, 400, map[string]any{"error": err.Error()})
return
}
// Sans ça, le serveur continuerait de tourner avec l'ancien modèle et
// le nouveau réglage semblerait sans effet.
whisperShutdown()
sendJSON(w, 200, map[string]any{"ok": true})
return
}
sendJSON(w, 200, dictateCfgLoad())
}
func handleDictateModels(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, whisperCatalogueTrie())
}
func handleDictateDownload(w http.ResponseWriter, r *http.Request) {
var req struct {
ID string `json:"id"`
}
if r.Method == http.MethodPost {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"error": "requête illisible"})
return
}
id := strings.TrimSpace(req.ID)
if whisperModelURLFor(id) == "" {
sendJSON(w, 400, map[string]any{"error": "modèle de dictée inconnu : " + id})
return
}
whisperStartDownload(id)
}
id, running, pct, errMsg := whisperDlEtat()
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["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.
// ProcessState.String() dit déjà « exit status 2 » ou « signal: illegal
// instruction » ; le second cas mérite son explication, parce que rien dans la
// dictée ne laisse deviner que le binaire ne tourne pas sur ce processeur.
func whisperExitReason(err error) string {
var ee *exec.ExitError
if !errors.As(err, &ee) {
if err == nil {
return "arrêt sans erreur"
}
return err.Error()
}
st := ee.ProcessState.String()
if strings.Contains(st, "signal:") {
return st + " (binaire compilé pour un autre processeur : image à reconstruire)"
}
return st
}
// lastLine : la dernière ligne non vide. whisper bavarde beaucoup avant de
// tomber ; seule la fin renseigne, et un pavé ne tient pas dans un bandeau.
func lastLine(s string) string {
lines := strings.Split(strings.TrimSpace(s), "\n")
for i := len(lines) - 1; i >= 0; i-- {
if l := strings.TrimSpace(lines[i]); l != "" {
return l
}
}
return ""
}
+204
View File
@@ -0,0 +1,204 @@
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 {
t.Helper()
b, err := os.ReadFile("../../Dockerfile")
if err != nil {
t.Fatalf("lecture du Dockerfile : %v", err)
}
out := map[string]string{}
for _, bloc := range strings.Split(string(b), "\nFROM ") {
entete, reste, ok := strings.Cut(bloc, "\n")
if !ok {
continue
}
_, nom, ok := strings.Cut(entete, " AS ")
if !ok || !strings.HasPrefix(strings.TrimSpace(nom), "whisperbuild") {
continue
}
out[strings.TrimSpace(nom)] = entete + "\n" + reste
}
return out
}
// argWhisperFlags rend la valeur de l'ARG WHISPER_CMAKE_FLAGS, continuations
// de ligne comprises. Vide si l'ARG n'existe pas : les étapes devront alors
// porter les drapeaux en clair, et le test le vérifiera.
func argWhisperFlags(t *testing.T) string {
t.Helper()
b, err := os.ReadFile("../../Dockerfile")
if err != nil {
t.Fatalf("lecture du Dockerfile : %v", err)
}
_, apres, ok := strings.Cut(string(b), "ARG WHISPER_CMAKE_FLAGS=")
if !ok {
return ""
}
var val strings.Builder
for _, ligne := range strings.Split(apres, "\n") {
val.WriteString(" " + ligne)
if !strings.HasSuffix(strings.TrimSpace(ligne), "\\") {
break
}
}
return val.String()
}
// La dictée est morte en production parce que whisper avait été compilé avec
// -march=native, donc pour le processeur du runner GitHub (AVX-512, AMX) et pas
// pour la machine qui fait tourner l'image : SIGILL en pleine transcription.
// Les drapeaux qui l'évitent ne se voient pas à l'exécution — rien ne les
// rappelle au prochain qui touchera cette étape de build. Ce test le fait, sur
// CHAQUE étape, y compris celle ajoutée pour CUDA.
func TestDockerfileWhisperNonNatif(t *testing.T) {
etapes := etapesWhisperbuild(t)
if len(etapes) < 2 {
t.Fatalf("%d étape(s) whisperbuild, 2 attendues (CPU et CUDA) : %v", len(etapes), clefs(etapes))
}
// Les drapeaux communs sont factorisés dans WHISPER_CMAKE_FLAGS. Une étape
// est conforme si elle les porte en clair OU si elle référence cet ARG —
// ce qui compte est qu'ils atteignent cmake, pas qu'ils soient recopiés.
commun := argWhisperFlags(t)
for nom, txt := range etapes {
effectif := txt
if strings.Contains(txt, "${WHISPER_CMAKE_FLAGS}") {
effectif += " " + commun
}
for _, drapeau := range []string{"-DGGML_NATIVE=OFF", "-DGGML_AMX_TILE=OFF", "-DGGML_AVX512=OFF"} {
if !strings.Contains(effectif, drapeau) {
t.Errorf("étape %s : %s absent — le binaire sera compilé pour le processeur du runner et mourra d'un SIGILL ailleurs", nom, drapeau)
}
}
if !strings.Contains(txt, "whisper-server") {
t.Errorf("étape %s : ne construit pas la cible whisper-server", nom)
}
// « -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)
}
}
}
}
// Deux binaires, pas un : sur une image runtime bâtie sans CUDA, un binaire lié
// à CUDA ne démarre pas du tout — l'éditeur de liens échoue avant la première
// instruction, donc aucun repli n'est possible depuis le programme.
func TestDockerfileDeuxBinairesWhisper(t *testing.T) {
b, err := os.ReadFile("../../Dockerfile")
if err != nil {
t.Fatalf("lecture du Dockerfile : %v", err)
}
src := string(b)
var avecCuda bool
for nom, txt := range etapesWhisperbuild(t) {
if strings.Contains(txt, "-DGGML_CUDA=ON") {
avecCuda = true
_ = nom
}
}
if !avecCuda {
t.Error("aucune étape whisperbuild ne passe -DGGML_CUDA=ON : la dictée ne pourra jamais utiliser le GPU")
}
for _, bin := range []string{"whisper-server-cpu", "whisper-server-cuda"} {
if !strings.Contains(src, "/usr/local/bin/"+bin) {
t.Errorf("le runtime ne reçoit pas %s — dictate_server.go le cherche à cet emplacement", bin)
}
}
}
// 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.
func TestDockerfileArgFlagsGlobal(t *testing.T) {
b, err := os.ReadFile("../../Dockerfile")
if err != nil {
t.Fatalf("lecture du Dockerfile : %v", err)
}
src := string(b)
// 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")
}
}
func clefs(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
func TestLastLine(t *testing.T) {
cas := []struct{ in, want string }{
{"", ""},
{" \n\n ", ""},
{"une seule ligne", "une seule ligne"},
{"AMX is not ready to be used!\nread_audio_data: ...\n", "read_audio_data: ..."},
{"fin utile\n\n \n", "fin utile"},
}
for _, c := range cas {
if got := lastLine(c.in); got != c.want {
t.Errorf("lastLine(%q) = %q, attendu %q", c.in, got, c.want)
}
}
}