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