Files
Loki/docs/superpowers/plans/2026-08-21-dictee-moteur-supervise.md
T
MichaelandClaude Opus 5 3624194b39 Plan : moteur de dictée supervisé et configurable
Sept tâches, de la persistance des réglages au panneau de l'interface. Le
temps réel (WebSocket + découpeur) fait l'objet d'un second plan : ce plan-ci
livre déjà un logiciel utilisable — bon modèle, GPU au choix, lenteur
supprimée — sans le direct.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-21 21:00:43 +02:00

1234 lines
44 KiB
Markdown

# Moteur de dictée supervisé et configurable — plan d'implémentation
> **Pour les agents :** SOUS-SKILL REQUISE — utiliser `superpowers:subagent-driven-development` (recommandé) ou `superpowers:executing-plans` pour dérouler ce plan tâche par tâche. Les étapes utilisent des cases à cocher (`- [ ]`).
**But :** remplacer les lancements de `whisper-cli` par un `whisper-server` supervisé, choisi et configuré depuis un panneau Dictée — modèle, GPU, langue.
**Architecture :** Loki lance `whisper-server` à la demande sur un port local, garde le modèle chargé, et l'éteint après inactivité. Le choix du GPU passe par `CUDA_VISIBLE_DEVICES`. Deux binaires sont livrés (CPU et CUDA) parce que l'image runtime peut être une image sans CUDA. `POST /api/transcribe` conserve son contrat et devient un relais vers ce serveur.
**Pile :** Go (stdlib + `net/http`), whisper.cpp, JavaScript sans framework, magasin clé/valeur `bkState`.
**Spec :** `docs/superpowers/specs/2026-08-21-dictee-temps-reel-design.md`
**Portée :** ce plan couvre les sections 1 et 3 de la spec. Le temps réel (WebSocket + découpeur, section 2) fait l'objet d'un second plan, et n'est PAS implémenté ici. À la fin de ce plan, la dictée reste en un coup — mais rapide, avec un bon modèle, sur le GPU choisi.
## Contraintes globales
- **La CI compile pour Windows** (`GOOS=windows go build ./cmd/loki`). Aucun appel spécifique à Unix dans du code non suffixé `_linux.go`/`_unix.go`. En particulier : pas de `syscall.SysProcAttr{Setsid: true}`.
- **`staticcheck` doit rester à zéro avertissement.** Pas de variable inutilisée, pas d'erreur ignorée sans `_ =`.
- **`gofmt -l .` doit être vide.**
- **`internal/loki/ui/index.html` est GÉNÉRÉ et COMMITÉ.** Après toute modification de `ui/src/`, lancer `go run ./tools/assemble-ui` et commiter le résultat.
- **Les drapeaux de portabilité de whisper.cpp sont obligatoires sur toutes les cibles :** `-DGGML_NATIVE=OFF -DGGML_AVX512=OFF -DGGML_AMX_TILE=OFF -DGGML_AMX_INT8=OFF -DGGML_AMX_BF16=OFF`. Leur absence a causé un SIGILL en production (PR #18).
- **Les modèles ne vont jamais dans l'image Docker.** Ils vivent dans `LokiHome()/whisper/`.
- **Langue par défaut : `fr`.** Pas `auto`.
- **Commentaires et messages d'erreur en français**, comme le reste du dépôt.
## Fichiers
| Fichier | Responsabilité |
|---|---|
| `internal/loki/dictate_config.go` (créer) | Réglages de dictée : structure, lecture/écriture dans `bkState`, valeurs par défaut |
| `internal/loki/dictate_models.go` (créer) | Catalogue des modèles whisper, chemins, téléchargement |
| `internal/loki/dictate_server.go` (créer) | Supervision de `whisper-server` : lancement, attente, extinction sur inactivité, appel `/inference` |
| `internal/loki/web_transcribe.go` (modifier) | `/api/transcribe` relaie vers le serveur ; nouveaux endpoints de config et de modèles |
| `internal/loki/web_server.go` (modifier) | Enregistrement des nouvelles routes |
| `internal/loki/ui/src/index.tmpl.html` (modifier) | Panneau `data-pane="dictee"` + entrée de navigation |
| `internal/loki/ui/src/js/23-dictate.js` (modifier) | Test du micro, messages d'erreur |
| `internal/loki/ui/src/js/24-dictate-settings.js` (créer) | Chargement/enregistrement du panneau Dictée |
| `Dockerfile` (modifier) | Deux cibles `whisper-server` : CPU et CUDA |
| `internal/loki/web_transcribe_test.go` (modifier) | Garde-fou Dockerfile étendu |
---
### Tâche 1 : Les réglages de dictée
**Fichiers :**
- Créer : `internal/loki/dictate_config.go`
- Test : `internal/loki/dictate_config_test.go`
**Interfaces :**
- Consomme : `getStr(bucket, key string) string` et `putStr(bucket, key, val string) error` de `store.go` ; la constante `bkState = "state"`.
- Produit :
- `type DictateCfg struct { Model, Lang, Device, Reactivity string }`
- `func dictateCfgLoad() DictateCfg`
- `func dictateCfgSave(c DictateCfg) error`
- `func (c DictateCfg) cudaVisibleDevices() string`
- `func (c DictateCfg) chunkBounds() (min, max time.Duration)`
`Device` vaut `"cpu"` ou l'indice GPU en décimal (`"0"`, `"1"`). `Reactivity` vaut `"court"`, `"moyen"` ou `"long"`. Les bornes de `chunkBounds` ne servent pas dans ce plan — elles sont posées ici parce que le réglage est stocké ici, et consommées par le plan « temps réel ».
- [ ] **Étape 1 : Écrire le test qui échoue**
Créer `internal/loki/dictate_config_test.go` :
```go
package loki
import (
"testing"
"time"
)
func TestDictateCfgDefauts(t *testing.T) {
c := DictateCfg{}.withDefauts()
if c.Model != "large-v3-turbo-q5_0" {
t.Errorf("modèle par défaut = %q, attendu large-v3-turbo-q5_0", c.Model)
}
if c.Lang != "fr" {
t.Errorf("langue par défaut = %q, attendu fr — la détection auto se trompe sur les tranches courtes", c.Lang)
}
if c.Device != "cpu" {
t.Errorf("matériel par défaut = %q, attendu cpu (aucun GPU supposé)", c.Device)
}
if c.Reactivity != "moyen" {
t.Errorf("réactivité par défaut = %q, attendu moyen", c.Reactivity)
}
}
func TestCudaVisibleDevices(t *testing.T) {
cas := []struct{ device, want string }{
{"cpu", ""},
{"0", "0"},
{"1", "1"},
{"", ""}, // vide = pas de GPU
{"bidon", ""}, // valeur illisible : CPU plutôt qu'un GPU au hasard
{"-1", ""}, // indice négatif : refusé
}
for _, c := range cas {
got := DictateCfg{Device: c.device}.cudaVisibleDevices()
if got != c.want {
t.Errorf("cudaVisibleDevices(%q) = %q, attendu %q", c.device, got, c.want)
}
}
}
func TestChunkBounds(t *testing.T) {
cas := []struct {
react string
min, max time.Duration
}{
{"court", 1000 * time.Millisecond, 5 * time.Second},
{"moyen", 1500 * time.Millisecond, 8 * time.Second},
{"long", 3 * time.Second, 15 * time.Second},
{"inconnu", 1500 * time.Millisecond, 8 * time.Second},
}
for _, c := range cas {
min, max := DictateCfg{Reactivity: c.react}.chunkBounds()
if min != c.min || max != c.max {
t.Errorf("chunkBounds(%q) = %v/%v, attendu %v/%v", c.react, min, max, c.min, c.max)
}
}
}
func TestDictateCfgAllerRetour(t *testing.T) {
withTestStore(t)
in := DictateCfg{Model: "medium-q5_0", Lang: "en", Device: "1", Reactivity: "long"}
if err := dictateCfgSave(in); err != nil {
t.Fatalf("enregistrement : %v", err)
}
out := dictateCfgLoad()
if out != in {
t.Errorf("relu %+v, attendu %+v", out, in)
}
}
func TestDictateCfgSaveRefuseValeursInvalides(t *testing.T) {
withTestStore(t)
err := dictateCfgSave(DictateCfg{Model: "modele-inexistant", Lang: "fr", Device: "cpu", Reactivity: "moyen"})
if err == nil {
t.Error("un modèle hors catalogue doit être refusé : sinon whisper-server ne démarrera jamais et l'erreur n'apparaîtra qu'au premier clic sur le micro")
}
}
```
`withTestStore(t)` est le helper existant du paquet qui ouvre un magasin temporaire. **Avant d'écrire ce test, vérifier son nom réel** :
```bash
grep -rn "func withTestStore\|func testStore\|func openTestDB" internal/loki/*_test.go
```
Si le helper porte un autre nom, utiliser celui-là. S'il n'en existe aucun, en écrire un dans ce fichier de test :
```go
func withTestStore(t *testing.T) {
t.Helper()
dir := t.TempDir()
t.Setenv("LOKI_HOME", dir)
if err := openStore(); err != nil {
t.Fatalf("ouverture du magasin de test : %v", err)
}
t.Cleanup(func() { _ = closeStore() })
}
```
en adaptant `openStore`/`closeStore` aux noms réels trouvés dans `store.go`.
- [ ] **Étape 2 : Lancer le test et vérifier qu'il échoue**
```bash
go test ./internal/loki/ -run 'TestDictateCfg|TestCudaVisibleDevices|TestChunkBounds' -v
```
Attendu : ÉCHEC de compilation, `undefined: DictateCfg`.
- [ ] **Étape 3 : Écrire l'implémentation**
Créer `internal/loki/dictate_config.go` :
```go
// Réglages de la dictée vocale — modèle, langue, matériel, réactivité.
//
// Ils vivent dans le magasin clé/valeur (bkState), comme le jeton Hugging
// Face : ce sont des réglages de SERVEUR, pas de navigateur. Le modèle chargé
// et le GPU occupé sont des propriétés de la machine, identiques pour tous les
// onglets ouverts.
package loki
import (
"encoding/json"
"fmt"
"strconv"
"time"
)
const dictateCfgKey = "dictate_cfg"
// DictateCfg : Device vaut "cpu" ou un indice de GPU en décimal ("0", "1"),
// dans l'ordre de nvidia-smi — le même que celui affiché par /api/vram.
type DictateCfg struct {
Model string `json:"model"`
Lang string `json:"lang"`
Device string `json:"device"`
Reactivity string `json:"reactivity"`
}
// withDefauts comble les champs vides. Appliqué à la LECTURE plutôt qu'à
// l'écriture : une version future qui ajoute un champ trouvera les réglages
// déjà enregistrés sans champ correspondant, et doit pouvoir le combler.
func (c DictateCfg) withDefauts() DictateCfg {
if c.Model == "" {
// large-v3-turbo : meilleur rapport qualité/vitesse en français à ce
// jour. Le défaut penche vers la qualité, comme demandé.
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 observées en production.
c.Lang = "fr"
}
if c.Device == "" {
c.Device = "cpu"
}
if c.Reactivity == "" {
c.Reactivity = "moyen"
}
return c
}
func dictateCfgLoad() DictateCfg {
var c DictateCfg
if raw := getStr(bkState, dictateCfgKey); raw != "" {
_ = json.Unmarshal([]byte(raw), &c)
}
return c.withDefauts()
}
func dictateCfgSave(c DictateCfg) error {
c = c.withDefauts()
// Valider ICI plutôt qu'au démarrage du serveur : un modèle hors catalogue
// accepté en silence ne se manifesterait qu'au premier clic sur le micro,
// loin du geste qui l'a causé.
if _, ok := whisperCatalogue[c.Model]; !ok {
return fmt.Errorf("modèle de dictée inconnu : %s", c.Model)
}
b, err := json.Marshal(c)
if err != nil {
return err
}
return putStr(bkState, dictateCfgKey, string(b))
}
// cudaVisibleDevices traduit le réglage en valeur de CUDA_VISIBLE_DEVICES.
// Vide = aucun GPU visible = whisper tourne sur le CPU. Toute valeur qui n'est
// pas un indice positif retombe sur le CPU : mieux vaut une dictée lente qu'un
// GPU choisi au hasard sur une machine dont on ne sait rien.
func (c DictateCfg) cudaVisibleDevices() string {
n, err := strconv.Atoi(c.Device)
if err != nil || n < 0 {
return ""
}
return strconv.Itoa(n)
}
// chunkBounds : réserve minimale avant de couper, et coupure forcée quand
// aucun silence n'arrive. Consommé par le découpeur du plan « temps réel ».
func (c DictateCfg) chunkBounds() (time.Duration, time.Duration) {
switch c.Reactivity {
case "court":
return 1000 * time.Millisecond, 5 * time.Second
case "long":
return 3 * time.Second, 15 * time.Second
default:
return 1500 * time.Millisecond, 8 * time.Second
}
}
```
- [ ] **Étape 4 : Lancer le test et vérifier qu'il passe**
```bash
go test ./internal/loki/ -run 'TestDictateCfg|TestCudaVisibleDevices|TestChunkBounds' -v
```
Attendu : PASS sur les cinq tests. `TestDictateCfgSaveRefuseValeursInvalides` dépend de `whisperCatalogue`, défini à la tâche 2 — **si la compilation échoue sur `undefined: whisperCatalogue`, faire la tâche 2 d'abord puis revenir valider cette étape.**
- [ ] **Étape 5 : Commiter**
```bash
go run honnef.co/go/tools/cmd/staticcheck@latest ./internal/loki/
gofmt -l internal/loki/
git add internal/loki/dictate_config.go internal/loki/dictate_config_test.go
git commit -m "Dictée : réglages serveur (modèle, langue, matériel, réactivité)"
```
---
### Tâche 2 : Le catalogue des modèles et leur téléchargement
**Fichiers :**
- Créer : `internal/loki/dictate_models.go`
- Test : `internal/loki/dictate_models_test.go`
- Modifier : `internal/loki/web_transcribe.go` (retirer l'ancien mono-modèle)
**Interfaces :**
- Consomme : `LokiHome() string` ; `DictateCfg` de la tâche 1.
- Produit :
- `var whisperCatalogue map[string]whisperModel`
- `type whisperModel struct { Nom, Fichier string; Octets int64; VRAMMo int }`
- `func whisperModelPathFor(id string) string`
- `func whisperModelURLFor(id string) string`
- `func whisperModelPresent(id string) bool`
- `func whisperCatalogueTrie() []map[string]any`
L'ancien `whisperModelPath()` sans argument et l'ancienne constante `whisperModelURL` disparaissent : ils codaient `small-q5_1` en dur.
- [ ] **Étape 1 : Écrire le test qui échoue**
Créer `internal/loki/dictate_models_test.go` :
```go
package loki
import (
"path/filepath"
"strings"
"testing"
)
func TestCatalogueContientLeDefautEtLAncien(t *testing.T) {
// Le défaut doit exister, sinon dictateCfgSave refuse sa propre valeur
// par défaut et plus aucun réglage n'est enregistrable.
if _, ok := whisperCatalogue["large-v3-turbo-q5_0"]; !ok {
t.Error("le modèle par défaut large-v3-turbo-q5_0 est absent du catalogue")
}
// small-q5_1 est le modèle déjà téléchargé sur les installations
// existantes : le retirer du catalogue 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) {
t.Setenv("LOKI_HOME", t.TempDir())
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) {
t.Setenv("LOKI_HOME", t.TempDir())
if p := whisperModelPathFor("nawak"); p != "" {
t.Errorf("un modèle hors catalogue doit donner un chemin vide, pas %q", p)
}
}
func TestCatalogueTrieParTaille(t *testing.T) {
l := whisperCatalogueTrie()
if len(l) < 4 {
t.Fatalf("catalogue de %d entrées, au moins 4 attendues", len(l))
}
var prec int64
for i, m := range l {
o, _ := m["octets"].(int64)
if i > 0 && o < prec {
t.Errorf("entrée %d : catalogue non trié par taille croissante", i)
}
prec = o
for _, clef := range []string{"id", "nom", "octets", "vram_mo", "present"} {
if _, ok := m[clef]; !ok {
t.Errorf("entrée %d : clé %q manquante — l'UI en a besoin", i, clef)
}
}
}
}
```
- [ ] **Étape 2 : Lancer le test et vérifier qu'il échoue**
```bash
go test ./internal/loki/ -run 'TestCatalogue|TestModelPath' -v
```
Attendu : ÉCHEC de compilation, `undefined: whisperCatalogue`.
- [ ] **Étape 3 : Écrire l'implémentation**
Créer `internal/loki/dictate_models.go` :
```go
// Catalogue des modèles de dictée. Aucun n'est embarqué dans l'image : ils
// pèsent de 190 Mo à 1,1 Go et vivent dans <LOKI_HOME>/whisper/, donc dans le
// volume /data — ils survivent aux recréations du conteneur.
package loki
import (
"os"
"path/filepath"
"sort"
)
// whisperModelBase : les modèles ggml officiels de whisper.cpp.
const whisperModelBase = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/"
type whisperModel struct {
Nom string // libellé affiché
Fichier string // nom du .bin, identique en local et chez Hugging Face
Octets int64 // taille du téléchargement, pour prévenir avant de le lancer
VRAMMo int // ordre de grandeur occupé sur le GPU, pour choisir en connaissance de cause
}
// Les tailles sont celles publiées par le dépôt amont. Elles servent à
// informer l'utilisateur avant un téléchargement, pas à vérifier le fichier :
// une taille qui dérive d'une release à l'autre ne doit pas casser la dictée.
var whisperCatalogue = map[string]whisperModel{
"small-q5_1": {Nom: "small (rapide, correct)", Fichier: "ggml-small-q5_1.bin", Octets: 190_000_000, VRAMMo: 600},
"medium-q5_0": {Nom: "medium (bon compromis)", Fichier: "ggml-medium-q5_0.bin", Octets: 539_000_000, VRAMMo: 1400},
"large-v3-turbo-q5_0": {Nom: "large-v3-turbo (recommandé)", Fichier: "ggml-large-v3-turbo-q5_0.bin", Octets: 574_000_000, VRAMMo: 1600},
"large-v3-q5_0": {Nom: "large-v3 (le plus précis, le plus lent)", Fichier: "ggml-large-v3-q5_0.bin", Octets: 1_080_000_000, VRAMMo: 3600},
}
// whisperModelPathFor : chemin local. Vide si l'identifiant est inconnu —
// l'appelant doit traiter ce cas, plutôt que de manipuler un chemin bâti sur
// une chaîne arbitraire venue d'une requête HTTP.
func whisperModelPathFor(id string) string {
m, ok := whisperCatalogue[id]
if !ok {
return ""
}
return filepath.Join(LokiHome(), "whisper", m.Fichier)
}
func whisperModelURLFor(id string) string {
m, ok := whisperCatalogue[id]
if !ok {
return ""
}
return whisperModelBase + m.Fichier
}
func whisperModelPresent(id string) bool {
p := whisperModelPathFor(id)
if p == "" {
return false
}
st, err := os.Stat(p)
return err == nil && st.Size() > 0
}
// whisperCatalogueTrie : le catalogue pour l'UI, du plus léger au plus lourd —
// l'ordre dans lequel se pose la question du compromis.
func whisperCatalogueTrie() []map[string]any {
out := make([]map[string]any, 0, len(whisperCatalogue))
for id, m := range whisperCatalogue {
out = append(out, map[string]any{
"id": id, "nom": m.Nom, "octets": m.Octets,
"vram_mo": m.VRAMMo, "present": whisperModelPresent(id),
})
}
sort.Slice(out, func(i, j int) bool {
return out[i]["octets"].(int64) < out[j]["octets"].(int64)
})
return out
}
```
- [ ] **Étape 4 : Adapter le téléchargeur existant au multi-modèles**
Dans `internal/loki/web_transcribe.go`, supprimer la constante `whisperModelURL` et la fonction `whisperModelPath()`, puis remplacer `whisperStartDownload()` et `whisperDownload()` par des versions prenant l'identifiant du modèle. Lire le fichier en entier avant d'éditer :
```bash
cat internal/loki/web_transcribe.go
```
L'état de téléchargement `wspDl` gagne un champ `id string` pour que l'UI sache quel modèle est en cours. `whisperStartDownload(id string)` refuse de lancer un second téléchargement tant qu'un autre tourne. `whisperDownload(id string)` utilise `whisperModelURLFor(id)` et `whisperModelPathFor(id)`, et écrit dans un fichier temporaire renommé à la fin — un téléchargement interrompu ne doit pas laisser un `.bin` tronqué que `whisperModelPresent` croirait valide.
- [ ] **Étape 5 : Lancer les tests**
```bash
go build ./... && go test ./internal/loki/ -run 'TestCatalogue|TestModelPath|TestDictateCfg' -v
```
Attendu : PASS sur tous, y compris `TestDictateCfgSaveRefuseValeursInvalides` de la tâche 1.
- [ ] **Étape 6 : Commiter**
```bash
go run honnef.co/go/tools/cmd/staticcheck@latest ./internal/loki/
gofmt -l internal/loki/
git add internal/loki/dictate_models.go internal/loki/dictate_models_test.go internal/loki/web_transcribe.go
git commit -m "Dictée : catalogue de modèles et téléchargement par identifiant"
```
---
### Tâche 3 : L'environnement de lancement de whisper-server
**Fichiers :**
- Créer : `internal/loki/dictate_server.go`
- Test : `internal/loki/dictate_server_test.go`
**Interfaces :**
- Consomme : `DictateCfg` (tâche 1), `whisperModelPathFor` (tâche 2).
- Produit :
- `func whisperServerBin(gpu bool) string`
- `func whisperServerArgs(c DictateCfg, port int) ([]string, error)`
- `func whisperServerEnv(c DictateCfg, base []string) []string`
Cette tâche ne lance aucun processus : elle construit et teste la ligne de commande. C'est la partie la plus facile à casser en silence et la plus facile à tester.
- [ ] **Étape 1 : Écrire le test qui échoue**
Créer `internal/loki/dictate_server_test.go` :
```go
package loki
import (
"strings"
"testing"
)
func argVal(args []string, flag string) (string, bool) {
for i, a := range args {
if a == flag && i+1 < len(args) {
return args[i+1], true
}
}
return "", false
}
func TestServerArgs(t *testing.T) {
t.Setenv("LOKI_HOME", t.TempDir())
c := DictateCfg{Model: "small-q5_1", Lang: "fr", Device: "cpu"}.withDefauts()
args, err := whisperServerArgs(c, 8091)
if err != nil {
t.Fatalf("construction des arguments : %v", err)
}
if v, ok := argVal(args, "-m"); !ok || !strings.HasSuffix(v, "ggml-small-q5_1.bin") {
t.Errorf("-m = %q, doit pointer sur le .bin du modèle choisi", v)
}
if v, ok := argVal(args, "-l"); !ok || v != "fr" {
t.Errorf("-l = %q, attendu fr", v)
}
if v, ok := argVal(args, "--port"); !ok || v != "8091" {
t.Errorf("--port = %q, attendu 8091", v)
}
// Écoute locale UNIQUEMENT : whisper-server n'a aucune authentification,
// l'exposer sur 0.0.0.0 ouvrirait une transcription libre à 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 — whisper-server n'a pas d'authentification", v)
}
}
func TestServerArgsModeleInconnu(t *testing.T) {
t.Setenv("LOKI_HOME", t.TempDir())
_, err := whisperServerArgs(DictateCfg{Model: "nawak", Lang: "fr", Device: "cpu"}, 8091)
if err == nil {
t.Error("un modèle hors catalogue doit produire une erreur, pas une commande avec -m vide")
}
}
func TestServerEnvGPU(t *testing.T) {
base := []string{"PATH=/usr/bin", "CUDA_VISIBLE_DEVICES=7", "HOME=/root"}
env := whisperServerEnv(DictateCfg{Device: "1"}, base)
if !contientExact(env, "CUDA_VISIBLE_DEVICES=1") {
t.Errorf("env = %v, doit contenir CUDA_VISIBLE_DEVICES=1", env)
}
// L'ancienne valeur doit être REMPLACÉE, pas doublée : avec deux
// occurrences, c'est la dernière qui gagne selon l'implémentation — le
// choix de l'utilisateur ne doit pas dépendre de ça.
if n := compte(env, "CUDA_VISIBLE_DEVICES="); n != 1 {
t.Errorf("%d occurrences de CUDA_VISIBLE_DEVICES, attendu exactement 1", n)
}
if !contientExact(env, "PATH=/usr/bin") {
t.Error("le reste de l'environnement doit être conservé")
}
env = whisperServerEnv(DictateCfg{Device: "cpu"}, base)
if !contientExact(env, "CUDA_VISIBLE_DEVICES=") {
t.Errorf("env = %v, en mode CPU la variable doit être posée VIDE (et non retirée) pour masquer tous les GPU", env)
}
}
func contientExact(l []string, s string) bool {
for _, v := range l {
if v == s {
return true
}
}
return false
}
func compte(l []string, prefixe string) int {
n := 0
for _, v := range l {
if strings.HasPrefix(v, prefixe) {
n++
}
}
return n
}
```
- [ ] **Étape 2 : Lancer le test et vérifier qu'il échoue**
```bash
go test ./internal/loki/ -run TestServer -v
```
Attendu : ÉCHEC de compilation, `undefined: whisperServerArgs`.
- [ ] **Étape 3 : Écrire l'implémentation**
Créer `internal/loki/dictate_server.go` avec, pour l'instant, uniquement ces trois fonctions :
```go
// Supervision de whisper-server : le modèle est chargé UNE fois et reste en
// mémoire, au lieu d'être relu depuis le disque à chaque phrase dictée.
package loki
import (
"fmt"
"os"
"os/exec"
"strconv"
"strings"
)
// whisperServerBin : deux binaires sont livrés, pas un.
//
// L'image runtime peut être bâtie sur la variante CPU de llama.cpp
// (LLAMACPP_IMAGE=ghcr.io/ggml-org/llama.cpp:server). Sur cette base, les
// bibliothèques CUDA sont absentes et un binaire lié à CUDA ne démarre pas du
// tout : l'éditeur de liens échoue avant la première instruction, il n'y a
// donc aucun repli possible depuis l'intérieur du programme. D'où deux
// binaires et un choix fait ici.
func whisperServerBin(gpu bool) string {
if gpu {
for _, p := range []string{os.Getenv("LOKI_WHISPER_SERVER_CUDA"), "/usr/local/bin/whisper-server-cuda"} {
if p != "" {
if _, err := os.Stat(p); err == nil {
return p
}
}
}
// Pas de binaire CUDA : l'appelant retombera sur le CPU en le disant.
return ""
}
for _, p := range []string{os.Getenv("LOKI_WHISPER_SERVER_CPU"), "/usr/local/bin/whisper-server-cpu"} {
if p != "" {
if _, err := os.Stat(p); err == nil {
return p
}
}
}
if p, err := exec.LookPath("whisper-server"); err == nil {
return p
}
return ""
}
func whisperServerArgs(c DictateCfg, port int) ([]string, error) {
model := whisperModelPathFor(c.Model)
if model == "" {
return nil, fmt.Errorf("modèle de dictée inconnu : %s", c.Model)
}
return []string{
"-m", model,
"-l", c.Lang,
// 127.0.0.1 et pas 0.0.0.0 : whisper-server n'a aucune
// authentification. Sur un conteneur au réseau partagé, l'exposer
// offrirait la transcription à tout le réseau.
"--host", "127.0.0.1",
"--port", strconv.Itoa(port),
}, nil
}
// whisperServerEnv pose CUDA_VISIBLE_DEVICES en REMPLAÇANT toute valeur déjà
// présente. Une variable dupliquée laisse le gagnant dépendre de la libc, et
// le choix de GPU de l'utilisateur deviendrait un coup de dé.
func whisperServerEnv(c DictateCfg, base []string) []string {
const clef = "CUDA_VISIBLE_DEVICES="
out := make([]string, 0, len(base)+1)
for _, v := range base {
if !strings.HasPrefix(v, clef) {
out = append(out, v)
}
}
// Valeur vide en mode CPU : masque TOUS les GPU. Retirer la variable
// ferait l'inverse — ggml les verrait tous.
return append(out, clef+c.cudaVisibleDevices())
}
```
- [ ] **Étape 4 : Lancer le test et vérifier qu'il passe**
```bash
go test ./internal/loki/ -run TestServer -v
```
Attendu : PASS sur les trois tests.
- [ ] **Étape 5 : Commiter**
```bash
go run honnef.co/go/tools/cmd/staticcheck@latest ./internal/loki/
gofmt -l internal/loki/
git add internal/loki/dictate_server.go internal/loki/dictate_server_test.go
git commit -m "Dictée : ligne de commande et environnement de whisper-server"
```
---
### Tâche 4 : La supervision — démarrage paresseux, extinction sur inactivité
**Fichiers :**
- Modifier : `internal/loki/dictate_server.go`
- Test : `internal/loki/dictate_server_test.go`
**Interfaces :**
- Consomme : `whisperServerBin`, `whisperServerArgs`, `whisperServerEnv` (tâche 3) ; `dictateCfgLoad` (tâche 1).
- Produit :
- `func whisperEnsure() (int, error)` — garantit un serveur vivant, renvoie son port
- `func whisperInfer(ctx context.Context, wav []byte) (string, error)`
- `func whisperShutdown()` — arrêt immédiat (changement de réglages)
- `func whisperEtat() map[string]any`
**Décisions de conception à respecter :**
- **Pas de `Setsid`.** La CI compile pour Windows. Le processus est un enfant ordinaire, arrêté par `Process.Kill()`.
- **Port choisi par le système.** Écouter sur `:0` pour connaître un port libre, le refermer, puis le passer à whisper-server. Coder un port en dur casserait sur une machine qui l'utilise déjà.
- **Extinction après 10 min sans appel.** Une minuterie remise à zéro à chaque `whisperInfer`.
- **Un changement de réglages appelle `whisperShutdown()`** : le serveur suivant repart avec le nouveau modèle et le nouveau GPU.
- **Mort du processus rapportée comme en PR #18** : code de sortie ou signal, jamais la seule dernière ligne du journal.
- [ ] **Étape 1 : Écrire les tests qui échouent**
Ajouter à `internal/loki/dictate_server_test.go` :
```go
func TestPortLibre(t *testing.T) {
p, err := portLibre()
if err != nil {
t.Fatalf("portLibre : %v", err)
}
if p < 1024 || p > 65535 {
t.Errorf("port = %d, hors de la plage utilisable", p)
}
// Le port doit être RÉELLEMENT libre : on doit pouvoir s'y lier.
l, err := net.Listen("tcp", "127.0.0.1:"+strconv.Itoa(p))
if err != nil {
t.Fatalf("port %d annoncé libre mais inutilisable : %v", p, err)
}
_ = l.Close()
}
func TestInferParseLaReponse(t *testing.T) {
// whisper-server répond {"text":"..."} sur /inference. On vérifie le
// décodage et le nettoyage sans lancer de vrai serveur.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/inference" {
t.Errorf("chemin appelé = %q, attendu /inference", r.URL.Path)
}
if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "multipart/form-data") {
t.Errorf("Content-Type = %q, whisper-server attend du multipart", ct)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"text":" bonjour ceci est un test "}`))
}))
defer srv.Close()
txt, err := whisperInferSur(context.Background(), srv.URL, []byte("RIFFfaux"))
if err != nil {
t.Fatalf("whisperInferSur : %v", err)
}
if txt != "bonjour ceci est un test" {
t.Errorf("texte = %q, les espaces de bord doivent être retirés", txt)
}
}
func TestInferFiltreLesMarqueurs(t *testing.T) {
// Sur du silence, whisper renvoie ses marqueurs internes. Les laisser
// passer les collerait tels quels dans le champ de saisie — c'est ce qui
// est arrivé avec [BLANK_AUDIO].
for _, marqueur := range []string{"[BLANK_AUDIO]", "(silence)", "[SOUND]", " [ Silence ] "} {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"text":"` + marqueur + `"}`))
}))
txt, err := whisperInferSur(context.Background(), srv.URL, []byte("RIFFfaux"))
srv.Close()
if err != nil {
t.Fatalf("whisperInferSur(%q) : %v", marqueur, err)
}
if txt != "" {
t.Errorf("marqueur %q rendu comme texte %q, attendu vide", marqueur, txt)
}
}
}
func TestInferErreurHTTP(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(500)
_, _ = w.Write([]byte("boom"))
}))
defer srv.Close()
if _, err := whisperInferSur(context.Background(), srv.URL, []byte("RIFFfaux")); err == nil {
t.Error("un 500 de whisper-server doit remonter une erreur")
}
}
```
Ajouter les imports `context`, `net`, `net/http`, `net/http/httptest`, `strconv` au fichier de test.
- [ ] **Étape 2 : Lancer et vérifier l'échec**
```bash
go test ./internal/loki/ -run 'TestPortLibre|TestInfer' -v
```
Attendu : ÉCHEC de compilation, `undefined: portLibre`, `undefined: whisperInferSur`.
- [ ] **Étape 3 : Implémenter**
Ajouter à `internal/loki/dictate_server.go`. `whisperInferSur` prend l'URL de base en paramètre — c'est ce qui rend l'appel testable sans lancer de processus ; `whisperInfer` n'est qu'une enveloppe qui garantit le serveur puis délègue.
```go
// portLibre demande au système un port disponible, puis le relâche. Coder un
// port en dur casserait sur une machine qui l'occupe déjà ; la fenêtre entre
// la fermeture et la reprise par whisper-server est négligeable devant le
// risque d'un conflit permanent.
func portLibre() (int, error) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, err
}
defer func() { _ = l.Close() }()
return l.Addr().(*net.TCPAddr).Port, nil
}
// whisperMarqueur reconnaît les annotations que whisper produit à la place
// d'un texte : [BLANK_AUDIO], (silence), [SOUND]… Un contenu entièrement
// entouré de crochets ou de parenthèses n'est jamais de la parole dictée.
func whisperMarqueur(s string) bool {
s = strings.TrimSpace(s)
if s == "" {
return true
}
return (strings.HasPrefix(s, "[") && strings.HasSuffix(s, "]")) ||
(strings.HasPrefix(s, "(") && strings.HasSuffix(s, ")"))
}
func whisperInferSur(ctx context.Context, base string, wav []byte) (string, error) {
var corps bytes.Buffer
mw := multipart.NewWriter(&corps)
part, err := mw.CreateFormFile("file", "dictee.wav")
if err != nil {
return "", err
}
if _, err := part.Write(wav); err != nil {
return "", err
}
if err := mw.WriteField("response_format", "json"); err != nil {
return "", err
}
if err := mw.Close(); err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/inference", &corps)
if err != nil {
return "", err
}
req.Header.Set("Content-Type", mw.FormDataContentType())
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
brut, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != 200 {
return "", fmt.Errorf("whisper-server a répondu %d : %s", resp.StatusCode, lastLine(string(brut)))
}
var j struct {
Text string `json:"text"`
}
if err := json.Unmarshal(brut, &j); err != nil {
return "", fmt.Errorf("réponse illisible de whisper-server : %w", err)
}
if whisperMarqueur(j.Text) {
return "", nil
}
return strings.TrimSpace(j.Text), nil
}
```
Puis la supervision proprement dite, dans le même fichier :
```go
var wsrvMu sync.Mutex
var wsrv struct {
cmd *exec.Cmd
port int
cfg DictateCfg
sortie strings.Builder // journal du processus, pour dire comment il est mort
timer *time.Timer
err string
}
const whisperIdle = 10 * time.Minute
// whisperEnsure garantit un serveur vivant pour la configuration COURANTE.
// Si les réglages ont changé depuis le lancement, l'ancien est arrêté : c'est
// ce qui rend le changement de modèle ou de GPU effectif sans redémarrer Loki.
func whisperEnsure() (int, error) {
cfg := dictateCfgLoad()
wsrvMu.Lock()
defer wsrvMu.Unlock()
if wsrv.cmd != nil && wsrv.cfg == cfg && wsrv.cmd.ProcessState == nil {
whisperTouchLocked()
return wsrv.port, nil
}
whisperStopLocked()
return whisperStartLocked(cfg)
}
```
Écrire `whisperStartLocked`, `whisperStopLocked` et `whisperTouchLocked` en respectant :
- `whisperStartLocked` refuse si le modèle est absent (`whisperModelPresent`) en renvoyant une erreur qui le dit, et si `whisperServerBin` renvoie `""` pour le GPU demandé, retombe sur le binaire CPU **en consignant pourquoi** dans `wsrv.err`.
- Après `cmd.Start()`, attendre que `GET http://127.0.0.1:<port>/` réponde, avec un délai maximal de 120 s (le chargement d'un modèle de 1 Go depuis un disque lent est long). Si le processus meurt pendant l'attente, renvoyer `whisperExitReason(cmd.Wait())` — la fonction écrite en PR #18 — plus la dernière ligne de son journal.
- `whisperTouchLocked` remet la minuterie d'inactivité à `whisperIdle`, dont l'expiration appelle `whisperShutdown`.
- [ ] **Étape 4 : Lancer les tests**
```bash
go build ./... && go test ./internal/loki/ -run 'TestPortLibre|TestInfer|TestServer' -v
```
Attendu : PASS.
- [ ] **Étape 5 : Vérifier la compilation Windows**
```bash
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o /dev/null ./cmd/loki
```
Attendu : succès. Un échec ici signale un appel spécifique à Unix — le corriger avant de commiter, la CI le refuserait.
- [ ] **Étape 6 : Commiter**
```bash
go run honnef.co/go/tools/cmd/staticcheck@latest ./internal/loki/
gofmt -l internal/loki/
git add internal/loki/dictate_server.go internal/loki/dictate_server_test.go
git commit -m "Dictée : whisper-server supervisé, modèle chargé une seule fois"
```
---
### Tâche 5 : Les routes HTTP
**Fichiers :**
- Modifier : `internal/loki/web_transcribe.go`, `internal/loki/web_server.go`
- Test : `internal/loki/web_transcribe_test.go`
**Interfaces :**
- Consomme : tout ce qui précède.
- Produit : `GET/POST /api/dictate/config`, `GET /api/dictate/models`, `POST /api/dictate/models/download`, `GET /api/dictate/state`.
`POST /api/transcribe` **garde son contrat** : entrée `audio/wav`, sortie `{"text":…}` ou `{"error":…}`, 503 `{"downloading":true,"pct":N}` pendant un téléchargement. Seul son intérieur change — il appelle `whisperEnsure()` puis `whisperInfer()` au lieu de lancer un processus.
- [ ] **Étape 1 : Écrire le test qui échoue**
Ajouter à `internal/loki/web_transcribe_test.go` :
```go
func TestConfigRouteAllerRetour(t *testing.T) {
withTestStore(t)
corps := strings.NewReader(`{"model":"small-q5_1","lang":"en","device":"1","reactivity":"court"}`)
rec := httptest.NewRecorder()
handleDictateConfig(rec, httptest.NewRequest("POST", "/api/dictate/config", corps))
if rec.Code != 200 {
t.Fatalf("POST = %d, corps %s", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
handleDictateConfig(rec, httptest.NewRequest("GET", "/api/dictate/config", nil))
var got DictateCfg
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("réponse illisible : %v", err)
}
if got.Device != "1" || got.Lang != "en" || got.Model != "small-q5_1" {
t.Errorf("relu %+v, attendu le réglage enregistré", got)
}
}
func TestConfigRouteRefuseModeleInconnu(t *testing.T) {
withTestStore(t)
corps := strings.NewReader(`{"model":"nawak","lang":"fr","device":"cpu","reactivity":"moyen"}`)
rec := httptest.NewRecorder()
handleDictateConfig(rec, httptest.NewRequest("POST", "/api/dictate/config", corps))
if rec.Code == 200 {
t.Error("un modèle hors catalogue doit être refusé par la route, pas seulement par le magasin")
}
}
func TestModelsRoute(t *testing.T) {
t.Setenv("LOKI_HOME", t.TempDir())
rec := httptest.NewRecorder()
handleDictateModels(rec, httptest.NewRequest("GET", "/api/dictate/models", nil))
if rec.Code != 200 {
t.Fatalf("GET = %d", rec.Code)
}
var l []map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &l); err != nil {
t.Fatalf("réponse illisible : %v", err)
}
if len(l) < 4 {
t.Errorf("%d modèles renvoyés, au moins 4 attendus", len(l))
}
}
```
- [ ] **Étape 2 : Lancer et vérifier l'échec**
```bash
go test ./internal/loki/ -run 'TestConfigRoute|TestModelsRoute' -v
```
Attendu : ÉCHEC, `undefined: handleDictateConfig`.
- [ ] **Étape 3 : Implémenter les handlers**
Dans `internal/loki/web_transcribe.go`, ajouter `handleDictateConfig`, `handleDictateModels`, `handleDictateDownload`, `handleDictateState`. `handleDictateConfig` en POST appelle `dictateCfgSave` puis **`whisperShutdown()`** — sans ça, le serveur continuerait de tourner avec l'ancien modèle et le réglage semblerait sans effet.
Réécrire le corps de `handleTranscribe` : validation de l'entrée inchangée, puis
```go
port, err := whisperEnsure()
if err != nil {
sendJSON(w, 503, map[string]any{"error": err.Error()})
return
}
txt, err := whisperInferSur(ctx, "http://127.0.0.1:"+strconv.Itoa(port), audio)
```
Le fichier temporaire disparaît : whisper-server reçoit le WAV en mémoire.
- [ ] **Étape 4 : Enregistrer les routes**
Dans `internal/loki/web_server.go`, après la ligne `api("/api/transcribe", handleTranscribe)` :
```go
api("/api/dictate/config", handleDictateConfig) // réglages de la dictée
api("/api/dictate/models", handleDictateModels) // catalogue + présence sur disque
api("/api/dictate/models/download", handleDictateDownload) // télécharge un modèle
api("/api/dictate/state", handleDictateState) // serveur allumé, modèle chargé, dernière erreur
```
- [ ] **Étape 5 : Lancer toute la suite**
```bash
go build ./... && go test ./internal/loki/ -v 2>&1 | tail -20
```
Attendu : PASS.
- [ ] **Étape 6 : Commiter**
```bash
go run honnef.co/go/tools/cmd/staticcheck@latest ./internal/loki/
gofmt -l internal/loki/
git add internal/loki/web_transcribe.go internal/loki/web_server.go internal/loki/web_transcribe_test.go
git commit -m "Dictée : routes de configuration, catalogue et état"
```
---
### Tâche 6 : Le Dockerfile — deux binaires whisper-server
**Fichiers :**
- Modifier : `Dockerfile`
- Test : `internal/loki/web_transcribe_test.go` (étendre `TestDockerfileWhisperNonNatif`)
- [ ] **Étape 1 : Étendre le garde-fou**
Remplacer `TestDockerfileWhisperNonNatif` dans `internal/loki/web_transcribe_test.go` par une version qui vérifie, dans **chaque** étape dont le nom commence par `whisperbuild` :
- la présence de `-DGGML_NATIVE=OFF` et `-DGGML_AMX_TILE=OFF`
- la construction de la cible `whisper-server`
et, globalement :
- qu'une étape porte `-DGGML_CUDA=ON`
- que le runtime copie `whisper-server-cpu` **et** `whisper-server-cuda`
- [ ] **Étape 2 : Lancer et vérifier l'échec**
```bash
go test ./internal/loki/ -run TestDockerfileWhisper -v
```
Attendu : ÉCHEC — le Dockerfile ne construit encore que `whisper-cli`.
- [ ] **Étape 3 : Modifier le Dockerfile**
Dupliquer l'étape `whisperbuild` en `whisperbuild-cpu` (base `ubuntu:22.04`) et `whisperbuild-cuda` (base `nvidia/cuda:12.4.1-devel-ubuntu22.04`, avec `-DGGML_CUDA=ON`). Les deux construisent la cible `whisper-server` et gardent **tous** les drapeaux de portabilité.
**Vérifier la concordance CUDA avant de figer la version de l'image de build :**
```bash
docker run --rm --entrypoint sh ghcr.io/ggml-org/llama.cpp:server-cuda -c 'ls /usr/local/cuda*/lib64/libcudart.so* 2>/dev/null; ldconfig -p | grep -i cudart'
```
La version majeure de `libcudart` du runtime doit correspondre à celle de l'image `nvidia/cuda:*-devel` choisie. En cas d'écart, aligner l'image de build sur le runtime.
Dans l'étape runtime, remplacer la copie de `whisper-cli` par :
```dockerfile
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
```
et retirer `LOKI_WHISPER_BIN` du bloc `ENV` s'il n'est plus lu par le code.
- [ ] **Étape 4 : Vérifier**
```bash
go test ./internal/loki/ -run TestDockerfileWhisper -v
```
Attendu : PASS.
- [ ] **Étape 5 : Bâtir l'image pour de vrai**
```bash
docker build -t loki-test .
```
Attendu : succès. C'est la seule vérification qui prouve que les deux étapes compilent — les tests Go ne lisent que le texte du Dockerfile. **Si Docker n'est pas disponible localement, le noter explicitement et laisser la CI trancher.**
- [ ] **Étape 6 : Commiter**
```bash
git add Dockerfile internal/loki/web_transcribe_test.go
git commit -m "Image : whisper-server en deux binaires, CPU et CUDA"
```
---
### Tâche 7 : Le panneau Dictée
**Fichiers :**
- Créer : `internal/loki/ui/src/js/24-dictate-settings.js`
- Modifier : `internal/loki/ui/src/index.tmpl.html`, `internal/loki/ui/src/js/23-dictate.js`
- Régénérer : `internal/loki/ui/index.html`
- [ ] **Étape 1 : Ajouter l'entrée de navigation**
Dans `internal/loki/ui/src/index.tmpl.html`, après la ligne `<button data-pane="engine-log" …>` (ligne 255) :
```html
<button data-pane="dictee" onclick="settingsShow('dictee')">Dictée</button>
```
- [ ] **Étape 2 : Ajouter le panneau**
Après la section `data-pane="engine"` (qui se termine avant la ligne 471), insérer une `<section class="set-pane" data-pane="dictee">` contenant : un `<select id="dic-device">`, un `<select id="dic-model">`, un `<select id="dic-lang">`, un `<select id="dic-react">`, un bouton d'enregistrement, une ligne d'état `<div id="dic-state">` et un bouton « Tester le micro » avec `<div id="dic-test-out">`. Suivre le balisage des panneaux voisins pour les classes.
- [ ] **Étape 3 : Écrire le module**
Créer `internal/loki/ui/src/js/24-dictate-settings.js` avec `loadDictateSettings()` (remplit les listes depuis `/api/dictate/models`, `/api/vram` et `/api/dictate/config`), `saveDictateSettings()` et `testerMicro()`. Brancher le chargement paresseux dans `22-settings-modal.js` :
```js
const SET_HOOKS = {
'engine-log': () => { loadSvcLog(); showPaths(); },
'dictee': () => { loadDictateSettings(); },
};
```
`testerMicro()` réutilise la capture de `23-dictate.js`, envoie à `/api/transcribe`, et affiche niveau + texte dans `#dic-test-out` **sans toucher à `#input`**.
- [ ] **Étape 4 : Régénérer l'UI**
```bash
go run ./tools/assemble-ui
git diff --stat internal/loki/ui/index.html
```
Attendu : `index.html` modifié. **Ne jamais l'éditer à la main** — il est généré.
- [ ] **Étape 5 : Vérifier dans le navigateur**
```bash
go run ./cmd/loki serve
```
Ouvrir l'interface, Paramètres → Dictée. Vérifier : les GPU apparaissent avec leur VRAM, le catalogue liste quatre modèles avec leur taille, l'enregistrement persiste après rechargement de la page, le test du micro affiche un niveau.
- [ ] **Étape 6 : Commiter**
```bash
go build ./... && go test ./internal/loki/
git add internal/loki/ui/
git commit -m "Dictée : panneau de réglages — GPU, modèle, langue, réactivité, test du micro"
```
---
## Vérification finale
- [ ] `gofmt -l .` — vide
- [ ] `go vet ./...` — silencieux
- [ ] `go test ./...` — vert
- [ ] `go run honnef.co/go/tools/cmd/staticcheck@latest ./...` — zéro avertissement
- [ ] `GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o /dev/null ./cmd/loki` — succès
- [ ] `docker build -t loki-test .` — succès (ou constat explicite que Docker est indisponible localement)
- [ ] `internal/loki/ui/index.html` régénéré et commité
**Ce que la vérification automatique ne couvre pas :** la qualité de reconnaissance. Aucun échantillon de voix n'est disponible côté développement. À livrer, il faudra dicter une phrase et comparer avec l'ancien modèle — c'est une constatation d'usage, pas un test.