mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-23 23:18:13 +02:00
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
1234 lines
44 KiB
Markdown
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.
|