mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-12 09:47:20 +02:00
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:
1 parent
43489769f4
commit
ad107ed284
9 files changed
+421
No files matched your search
+18
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))})
|
||||
}
|
||||
Reference in new issue
Block a user