Dictée vocale : micro dans la carte de saisie, whisper.cpp local

Un bouton micro dans la carte de saisie : un clic enregistre, un second
arrête, transcrit et pose le texte dans le champ sans écraser ce qui s'y
trouve. Anneau rouge pulsé pendant l'enregistrement, garde-fou à 90 s.

La transcription est 100 % locale : POST /api/transcribe → whisper-cli
(whisper.cpp, compilé CPU en statique dans une étape dédiée du Dockerfile).
Le modèle ggml-small-q5_1 (~190 Mo, multilingue) n'est pas dans l'image :
téléchargé au premier usage dans /data/whisper/ avec progression (503
{downloading, pct} en attendant), il survit aux recréations du conteneur.

L'audio est encodé en WAV 16 kHz mono côté navigateur (whisper.cpp ne lit
que du PCM) — pas de ffmpeg dans l'image. Le micro n'existe qu'en contexte
sécurisé (HTTPS ou localhost) : le bouton l'explique au lieu d'échouer en
silence.

Vérifié bout en bout avec le binaire whisper.cpp officiel : téléchargement
du modèle par le handler, puis une sinusoïde 440 Hz transcrite « (beeping) ».

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Fable 5 committed 2026-08-20 19:52:38 +02:00
1 parent 43489769f4
commit ad107ed284
9 files changed
+421

No files matched your search

+18
View File
@@ -31,6 +31,20 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /out/loki ./cmd/loki
# limité à cette étape de build.
RUN GOBIN=/out GOTOOLCHAIN=auto go install golang.org/x/tools/gopls@latest
# ── Étape 1 bis : whisper-cli (dictée vocale) ───────────────────────────
# whisper.cpp compilé CPU seul, en statique : un binaire unique, aucune .so à
# trimballer, et pas de compilation CUDA (des minutes de build pour un gain
# nul sur des dictées de quelques secondes). Ubuntu 22.04 : glibc plus
# ancienne que l'image runtime, donc compatible quoi qu'elle embarque.
FROM ubuntu:22.04 AS whisperbuild
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential cmake git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN git clone --depth 1 https://github.com/ggml-org/whisper.cpp /w \
&& cmake -S /w -B /w/build -DCMAKE_BUILD_TYPE=Release \
-DBUILD_SHARED_LIBS=OFF -DWHISPER_BUILD_TESTS=OFF \
&& cmake --build /w/build -j --target whisper-cli
# ── Étape 2 : runtime sur l'image serveur CUDA officielle ───────────────
FROM ${LLAMACPP_IMAGE} AS runtime
@@ -84,6 +98,9 @@ RUN if [ "$PLAYWRIGHT" = "1" ]; then \
COPY --from=gobuild /out/loki /usr/local/bin/loki
COPY --from=gobuild /out/gopls /usr/local/bin/gopls
# Dictée vocale (POST /api/transcribe). Le modèle (~190 Mo) n'est PAS dans
# l'image : téléchargé au premier usage dans /data/whisper/.
COPY --from=whisperbuild /w/build/bin/whisper-cli /usr/local/bin/whisper-cli
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh && mkdir -p /data /models
@@ -92,6 +109,7 @@ ENV LOKI_CONTAINER=1 \
LOKI_HOME=/data \
LOKI_MODEL_DIRS=/models \
LOKI_ENGINE_BIN=/app/llama-server \
LOKI_WHISPER_BIN=/usr/local/bin/whisper-cli \
LD_LIBRARY_PATH=/app \
NVIDIA_VISIBLE_DEVICES=all \
NVIDIA_DRIVER_CAPABILITIES=compute,utility
+6
View File
@@ -327,6 +327,12 @@ Ajoutées par ce fork :
- **Nom du modèle sur chaque réponse** : une pastille à côté de « Loki » dit
quel modèle a produit la réponse. Elle est journalisée avec le tour : elle
survit au rechargement, et un vieux tour garde le modèle de l'époque.
- **Dictée vocale** : un bouton micro dans la carte de saisie enregistre,
transcrit **en local** (whisper.cpp, compilé dans l'image ; modèle
`small-q5_1` multilingue ~190 Mo téléchargé au premier usage dans
`/data/whisper/`) et pose le texte dans le champ. ⚠️ le navigateur n'autorise
le micro qu'en **HTTPS** (ou sur `localhost`) — derrière un reverse proxy
TLS, rien à faire ; en `http://IP:8090`, le bouton l'explique.
- **Cartes raisonnement/outils à hauteur bornée** : un long raisonnement ne
fait plus grandir la page de plusieurs écrans — la carte reste à taille fixe
et défile toute seule pendant la génération. Sur un raisonnement géant, seul
+7
View File
@@ -32,6 +32,13 @@ entier à chaque token, en O(n²)). La carte reste maintenant à taille fixe et
suit la génération en défilant toute seule ; en direct, seul le bas d'un bloc
géant est re-rendu, et le texte complet est posé en fin de bloc.
## Dictée vocale
Un bouton micro dans la carte de saisie : tu parles, le texte s'écrit. La
transcription est 100 % locale — whisper.cpp compilé dans l'image, modèle
multilingue téléchargé au premier usage (~190 Mo, dans `/data/whisper/`).
Le navigateur exige HTTPS (ou localhost) pour donner accès au micro.
## Divers
- Jeton Hugging Face réglable dans l'interface (dépôts verrouillés : le
+99
View File
@@ -2059,6 +2059,12 @@ html[data-files="1"] #files-btn{color:var(--accent)}
.rolebadge{display:inline-block;margin-left:6px;padding:0 6px;border:1px solid var(--accent);border-radius:3px;color:var(--accent);font-size:9px;letter-spacing:.05em;text-transform:uppercase;vertical-align:middle}
/* Dictée : anneau rouge pulsé pendant l'enregistrement, estompé pendant la
transcription. Le bouton garde le gabarit de ses voisins (joindre, fichiers). */
#mic.rec{color:var(--err);border-color:var(--err);animation:micpulse 1.2s ease-in-out infinite}
@keyframes micpulse{50%{box-shadow:0 0 0 5px color-mix(in srgb,var(--err) 25%,transparent)}}
#mic.busy{opacity:.55}
/* ── Réglages en modale ─────────────────────────────────────────────────────
Deux volets : nav des sections à gauche, LE panneau choisi à droite. Les
panneaux réutilisent tels quels les gabarits de la barre latérale (.slist,
@@ -2284,6 +2290,12 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<option value="high">haute</option>
<option value="xhigh">maximale</option>
</select>
<!-- Dictée vocale : enregistre au micro, transcrit en local (whisper.cpp
côté serveur) et pose le texte dans le champ. Voir js/23-dictate.js.
Le navigateur n'autorise le micro qu'en HTTPS (ou localhost). -->
<button id="mic" onclick="dictateToggle()" title="dicter au micro" aria-label="dicter au micro">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="3" width="6" height="12" rx="3"/><path d="M5 11a7 7 0 0 0 14 0"/><path d="M12 18v3"/></svg>
</button>
<!-- Joindre un fichier. Icône dessinée en SVG : pas de dépendance à une
police d'emoji, dont le rendu varie énormément d'un système à l'autre. -->
<button id="attach" onclick="document.getElementById('attach-input').click()" title="joindre un fichier" aria-label="joindre un fichier">
@@ -9084,4 +9096,91 @@ document.addEventListener('keydown', e => {
const m = document.getElementById('settings-modal');
if(m && m.classList.contains('show')) closeSettings();
});
// ─── Dictée vocale ───────────────────────────────────────────────────────────
// Bouton micro de la carte de saisie : un clic enregistre, un second arrête et
// transcrit. La capture est encodée en WAV 16 kHz mono ICI, côté navigateur —
// whisper.cpp ne lit que du PCM, et encoder au bon format évite d'embarquer
// ffmpeg dans l'image. La transcription est locale (POST /api/transcribe →
// whisper-cli). Le texte s'ajoute au champ sans écraser ce qui s'y trouve.
//
// ⚠️ Le micro n'est autorisé par le NAVIGATEUR qu'en contexte sécurisé (HTTPS
// ou localhost) : servi en http://IP:8090, getUserMedia n'existe pas — on
// l'explique plutôt que d'échouer en silence.
let MIC = null; // {ctx, stream, src, node, chunks, rate, timer}
async function dictateToggle(){
if(MIC){ dictateStop(); return; }
if(!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)){
toast('micro bloqué par le navigateur : il faut HTTPS (ou localhost)');
return;
}
let stream;
try{
stream = await navigator.mediaDevices.getUserMedia({audio:{channelCount:1, echoCancellation:true, noiseSuppression:true}});
}catch(e){ toast('accès au micro refusé'); return; }
const ctx = new (window.AudioContext||window.webkitAudioContext)();
const src = ctx.createMediaStreamSource(stream);
// ScriptProcessor : déprécié mais universel, et amplement suffisant pour
// copier des blocs de PCM (AudioWorklet demanderait un module séparé,
// impossible dans un fichier unique servi inline).
const node = ctx.createScriptProcessor(4096, 1, 1);
const chunks = [];
node.onaudioprocess = e => { chunks.push(new Float32Array(e.inputBuffer.getChannelData(0))); };
src.connect(node); node.connect(ctx.destination);
// Garde-fou : une dictée oubliée ne tourne pas des heures.
MIC = {ctx, stream, src, node, chunks, rate:ctx.sampleRate, timer:setTimeout(dictateStop, 90000)};
document.getElementById('mic').classList.add('rec');
}
async function dictateStop(){
const m = MIC; if(!m) return; MIC = null;
clearTimeout(m.timer);
try{ m.src.disconnect(); m.node.disconnect(); }catch(_){}
m.stream.getTracks().forEach(t => t.stop());
try{ m.ctx.close(); }catch(_){}
const btn = document.getElementById('mic');
btn.classList.remove('rec'); btn.classList.add('busy'); btn.disabled = true;
try{
const wav = encodeWav16k(m.chunks, m.rate);
if(wav.byteLength < 16000){ toast('rien entendu'); return; } // < 0,5 s
const r = await jfetch('/api/transcribe', {method:'POST', headers:{'Content-Type':'audio/wav'}, body:wav});
const j = await r.json().catch(()=>({}));
if(r.status === 503 && j.downloading){
toast('modèle de dictée en téléchargement ('+(j.pct||0)+' %) — réessaie dans un instant');
return;
}
if(!r.ok){ toast(j.error || 'échec de la transcription'); return; }
const text = (j.text||'').trim();
if(!text){ toast('rien compris — réessaie'); return; }
const ta = document.getElementById('input');
ta.value = ta.value.trim() ? ta.value.replace(/\s+$/,'') + ' ' + text : text;
autoGrow(ta); ta.focus();
}catch(_){ toast('erreur réseau'); }
finally{ btn.classList.remove('busy'); btn.disabled = false; }
}
// Ré-échantillonne le PCM capturé (44,1/48 kHz selon la machine) vers du WAV
// 16 kHz mono 16 bits — le format d'entrée de whisper.cpp. Décimation simple :
// pour de la voix ré-échantillonnée VERS LE BAS, largement suffisant.
function encodeWav16k(chunks, fromRate){
const toRate = 16000;
let n = 0; for(const c of chunks) n += c.length;
const all = new Float32Array(n); let o = 0;
for(const c of chunks){ all.set(c, o); o += c.length; }
const ratio = fromRate / toRate, outN = Math.floor(n / ratio);
const pcm = new Int16Array(outN);
for(let i = 0; i < outN; i++){
const s = Math.max(-1, Math.min(1, all[Math.floor(i*ratio)] || 0));
pcm[i] = s < 0 ? s*0x8000 : s*0x7fff;
}
const buf = new ArrayBuffer(44 + pcm.length*2), v = new DataView(buf);
const wr = (off, s) => { for(let i = 0; i < s.length; i++) v.setUint8(off+i, s.charCodeAt(i)); };
wr(0,'RIFF'); v.setUint32(4, 36 + pcm.length*2, true); wr(8,'WAVEfmt ');
v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true);
v.setUint32(24, toRate, true); v.setUint32(28, toRate*2, true);
v.setUint16(32, 2, true); v.setUint16(34, 16, true);
wr(36,'data'); v.setUint32(40, pcm.length*2, true);
new Int16Array(buf, 44).set(pcm);
return buf;
}
</script></body></html>
+6
View File
@@ -160,6 +160,12 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<option value="high">haute</option>
<option value="xhigh">maximale</option>
</select>
<!-- Dictée vocale : enregistre au micro, transcrit en local (whisper.cpp
côté serveur) et pose le texte dans le champ. Voir js/23-dictate.js.
Le navigateur n'autorise le micro qu'en HTTPS (ou localhost). -->
<button id="mic" onclick="dictateToggle()" title="dicter au micro" aria-label="dicter au micro">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="3" width="6" height="12" rx="3"/><path d="M5 11a7 7 0 0 0 14 0"/><path d="M12 18v3"/></svg>
</button>
<!-- Joindre un fichier. Icône dessinée en SVG : pas de dépendance à une
police d'emoji, dont le rendu varie énormément d'un système à l'autre. -->
<button id="attach" onclick="document.getElementById('attach-input').click()" title="joindre un fichier" aria-label="joindre un fichier">
+87
View File
@@ -0,0 +1,87 @@
// ─── Dictée vocale ───────────────────────────────────────────────────────────
// Bouton micro de la carte de saisie : un clic enregistre, un second arrête et
// transcrit. La capture est encodée en WAV 16 kHz mono ICI, côté navigateur —
// whisper.cpp ne lit que du PCM, et encoder au bon format évite d'embarquer
// ffmpeg dans l'image. La transcription est locale (POST /api/transcribe →
// whisper-cli). Le texte s'ajoute au champ sans écraser ce qui s'y trouve.
//
// ⚠️ Le micro n'est autorisé par le NAVIGATEUR qu'en contexte sécurisé (HTTPS
// ou localhost) : servi en http://IP:8090, getUserMedia n'existe pas — on
// l'explique plutôt que d'échouer en silence.
let MIC = null; // {ctx, stream, src, node, chunks, rate, timer}
async function dictateToggle(){
if(MIC){ dictateStop(); return; }
if(!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)){
toast('micro bloqué par le navigateur : il faut HTTPS (ou localhost)');
return;
}
let stream;
try{
stream = await navigator.mediaDevices.getUserMedia({audio:{channelCount:1, echoCancellation:true, noiseSuppression:true}});
}catch(e){ toast('accès au micro refusé'); return; }
const ctx = new (window.AudioContext||window.webkitAudioContext)();
const src = ctx.createMediaStreamSource(stream);
// ScriptProcessor : déprécié mais universel, et amplement suffisant pour
// copier des blocs de PCM (AudioWorklet demanderait un module séparé,
// impossible dans un fichier unique servi inline).
const node = ctx.createScriptProcessor(4096, 1, 1);
const chunks = [];
node.onaudioprocess = e => { chunks.push(new Float32Array(e.inputBuffer.getChannelData(0))); };
src.connect(node); node.connect(ctx.destination);
// Garde-fou : une dictée oubliée ne tourne pas des heures.
MIC = {ctx, stream, src, node, chunks, rate:ctx.sampleRate, timer:setTimeout(dictateStop, 90000)};
document.getElementById('mic').classList.add('rec');
}
async function dictateStop(){
const m = MIC; if(!m) return; MIC = null;
clearTimeout(m.timer);
try{ m.src.disconnect(); m.node.disconnect(); }catch(_){}
m.stream.getTracks().forEach(t => t.stop());
try{ m.ctx.close(); }catch(_){}
const btn = document.getElementById('mic');
btn.classList.remove('rec'); btn.classList.add('busy'); btn.disabled = true;
try{
const wav = encodeWav16k(m.chunks, m.rate);
if(wav.byteLength < 16000){ toast('rien entendu'); return; } // < 0,5 s
const r = await jfetch('/api/transcribe', {method:'POST', headers:{'Content-Type':'audio/wav'}, body:wav});
const j = await r.json().catch(()=>({}));
if(r.status === 503 && j.downloading){
toast('modèle de dictée en téléchargement ('+(j.pct||0)+' %) — réessaie dans un instant');
return;
}
if(!r.ok){ toast(j.error || 'échec de la transcription'); return; }
const text = (j.text||'').trim();
if(!text){ toast('rien compris — réessaie'); return; }
const ta = document.getElementById('input');
ta.value = ta.value.trim() ? ta.value.replace(/\s+$/,'') + ' ' + text : text;
autoGrow(ta); ta.focus();
}catch(_){ toast('erreur réseau'); }
finally{ btn.classList.remove('busy'); btn.disabled = false; }
}
// Ré-échantillonne le PCM capturé (44,1/48 kHz selon la machine) vers du WAV
// 16 kHz mono 16 bits — le format d'entrée de whisper.cpp. Décimation simple :
// pour de la voix ré-échantillonnée VERS LE BAS, largement suffisant.
function encodeWav16k(chunks, fromRate){
const toRate = 16000;
let n = 0; for(const c of chunks) n += c.length;
const all = new Float32Array(n); let o = 0;
for(const c of chunks){ all.set(c, o); o += c.length; }
const ratio = fromRate / toRate, outN = Math.floor(n / ratio);
const pcm = new Int16Array(outN);
for(let i = 0; i < outN; i++){
const s = Math.max(-1, Math.min(1, all[Math.floor(i*ratio)] || 0));
pcm[i] = s < 0 ? s*0x8000 : s*0x7fff;
}
const buf = new ArrayBuffer(44 + pcm.length*2), v = new DataView(buf);
const wr = (off, s) => { for(let i = 0; i < s.length; i++) v.setUint8(off+i, s.charCodeAt(i)); };
wr(0,'RIFF'); v.setUint32(4, 36 + pcm.length*2, true); wr(8,'WAVEfmt ');
v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true);
v.setUint32(24, toRate, true); v.setUint32(28, toRate*2, true);
v.setUint16(32, 2, true); v.setUint16(34, 16, true);
wr(36,'data'); v.setUint32(40, pcm.length*2, true);
new Int16Array(buf, 44).set(pcm);
return buf;
}
+6
View File
@@ -2028,6 +2028,12 @@ html[data-files="1"] #files-btn{color:var(--accent)}
.rolebadge{display:inline-block;margin-left:6px;padding:0 6px;border:1px solid var(--accent);border-radius:3px;color:var(--accent);font-size:9px;letter-spacing:.05em;text-transform:uppercase;vertical-align:middle}
/* Dictée : anneau rouge pulsé pendant l'enregistrement, estompé pendant la
transcription. Le bouton garde le gabarit de ses voisins (joindre, fichiers). */
#mic.rec{color:var(--err);border-color:var(--err);animation:micpulse 1.2s ease-in-out infinite}
@keyframes micpulse{50%{box-shadow:0 0 0 5px color-mix(in srgb,var(--err) 25%,transparent)}}
#mic.busy{opacity:.55}
/* ── Réglages en modale ─────────────────────────────────────────────────────
Deux volets : nav des sections à gauche, LE panneau choisi à droite. Les
panneaux réutilisent tels quels les gabarits de la barre latérale (.slist,
+1
View File
@@ -168,6 +168,7 @@ func newWebMux() *http.ServeMux {
api := func(path string, h http.HandlerFunc) { mux.HandleFunc(path, requireWebAuth(h)) }
api("/api/ping", handlePing)
api("/api/status", handleStatus)
api("/api/transcribe", handleTranscribe) // dictée vocale (whisper.cpp local)
api("/api/service/log", handleServiceLog) // journal du service pour diagnostiquer un modèle qui ne charge pas
api("/api/vram", handleVram)
api("/api/ram", handleRam)
+191
View File
@@ -0,0 +1,191 @@
// Dictée vocale — POST /api/transcribe : un WAV (16 kHz mono, encodé côté
// navigateur) entre, le texte transcrit sort. La transcription est 100 % locale,
// par whisper-cli (whisper.cpp, même famille que llama.cpp), compilé dans
// l'image Docker. Le modèle (ggml-small-q5_1, ~190 Mo, multilingue) est
// téléchargé au premier usage dans LOKI_HOME/whisper/ — comme le moteur, il ne
// gonfle pas l'image et survit aux recréations du conteneur via /data.
package loki
import (
"context"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"time"
)
const whisperModelURL = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small-q5_1.bin"
// whisperBin localise whisper-cli : variable d'environnement (posée par le
// Dockerfile), sinon le PATH. Vide = dictée indisponible (image trop ancienne,
// ou poste de dev sans whisper.cpp) — le handler le dit clairement.
func whisperBin() string {
if p := os.Getenv("LOKI_WHISPER_BIN"); p != "" {
if _, err := os.Stat(p); err == nil {
return p
}
}
if p, err := exec.LookPath("whisper-cli"); err == nil {
return p
}
return ""
}
func whisperModelPath() string {
return filepath.Join(LokiHome(), "whisper", "ggml-small-q5_1.bin")
}
// Téléchargement du modèle : UNE goroutine, progression consultable. Le premier
// POST le déclenche et répond 503 {downloading, pct} ; l'UI invite à réessayer.
var wspMu sync.Mutex
var wspDl struct {
running bool
pct int
err string
}
func whisperStartDownload() {
wspMu.Lock()
defer wspMu.Unlock()
if wspDl.running {
return
}
wspDl.running, wspDl.pct, wspDl.err = true, 0, ""
go func() {
err := whisperDownload()
wspMu.Lock()
wspDl.running = false
if err != nil {
wspDl.err = err.Error()
}
wspMu.Unlock()
}()
}
func whisperDownload() error {
dst := whisperModelPath()
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
resp, err := http.Get(whisperModelURL)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("téléchargement du modèle whisper : HTTP %d", resp.StatusCode)
}
tmp := dst + ".part"
f, err := os.Create(tmp)
if err != nil {
return err
}
total := resp.ContentLength
var done int64
buf := make([]byte, 1<<20)
for {
n, rerr := resp.Body.Read(buf)
if n > 0 {
if _, werr := f.Write(buf[:n]); werr != nil {
f.Close()
os.Remove(tmp)
return werr
}
done += int64(n)
if total > 0 {
wspMu.Lock()
wspDl.pct = int(done * 100 / total)
wspMu.Unlock()
}
}
if rerr == io.EOF {
break
}
if rerr != nil {
f.Close()
os.Remove(tmp)
return rerr
}
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return err
}
return os.Rename(tmp, dst)
}
func handleTranscribe(w http.ResponseWriter, r *http.Request) {
bin := whisperBin()
if bin == "" {
sendJSON(w, 501, map[string]any{"error": "dictée indisponible : whisper-cli absent (image à reconstruire, ou poste sans whisper.cpp)"})
return
}
model := whisperModelPath()
if _, err := os.Stat(model); err != nil {
wspMu.Lock()
derr := wspDl.err
pct := wspDl.pct
wspMu.Unlock()
if derr != "" {
// Échec précédent : on le dit ET on relance — un réseau revenu suffit.
wspMu.Lock()
wspDl.err = ""
wspMu.Unlock()
whisperStartDownload()
sendJSON(w, 503, map[string]any{"downloading": true, "pct": 0, "error": derr})
return
}
whisperStartDownload()
sendJSON(w, 503, map[string]any{"downloading": true, "pct": pct})
return
}
audio, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 32<<20))
if err != nil || len(audio) < 1000 {
sendJSON(w, 400, map[string]any{"error": "audio vide ou trop court"})
return
}
tmp, err := os.CreateTemp("", "loki-dictee-*.wav")
if err != nil {
sendJSON(w, 500, map[string]any{"error": err.Error()})
return
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if _, err := tmp.Write(audio); err != nil {
tmp.Close()
sendJSON(w, 500, map[string]any{"error": err.Error()})
return
}
tmp.Close()
// -l auto : détection de langue (le français sort en français) ; -nt sans
// horodatages ; -np sans bavardage de chargement. CPU seul : une dictée de
// quelques dizaines de secondes se transcrit en 1-3 s avec small-q5_1.
threads := runtime.NumCPU()
if threads > 8 {
threads = 8
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
defer cancel()
cmd := exec.CommandContext(ctx, bin, "-m", model, "-f", tmpPath, "-l", "auto", "-nt", "-np", "-t", strconv.Itoa(threads))
var stderr strings.Builder
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
sendJSON(w, 500, map[string]any{"error": "whisper-cli : " + msg})
return
}
sendJSON(w, 200, map[string]any{"text": strings.TrimSpace(string(out))})
}