mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-16 19:59:56 +02:00
Parakeet TDT 0.6B v3 (NVIDIA), servi par sherpa-onnx. La v3 et non la v2 : c'est la seule des deux qui parle français — la v2 est anglais seul. CE QUI DISPARAÎT DU DOCKERFILE Deux étapes de compilation, dont une CUDA de ~200 fichiers nvcc qui a été tuée par l'OOM du runner plus d'une fois, et avec elles tout l'appareillage de garde-fous qu'elles réclamaient (GGML_NATIVE=OFF contre le SIGILL en production, bornage des architectures CUDA, deux binaires CPU/CUDA à choisir à l'exécution). À la place : le téléchargement d'un binaire statique de 35 Mo, version épinglée et empreinte SHA-256 vérifiée — le binaire s'exécute sur la machine de l'utilisateur, une release remplacée en amont ne doit pas passer en silence. CE QUI CHANGE DANS LE CODE La forme est la même — un processus local supervisé, éteint après dix minutes d'inactivité. Le dialogue, lui, change : whisper-server exposait du HTTP multipart, sherpa-onnx n'expose qu'un WebSocket dont le protocole tient en deux entiers et des flottants. D'où un client WebSocket et une conversion WAV → float32 côté serveur. Le parcours des blocs du WAV n'est pas du zèle : l'offset 44 codé en dur transforme un bloc LIST intercalé en craquement au début de chaque phrase. DEUX RÉGLAGES DISPARAISSENT, ET C'EST LE MOTEUR QUI L'IMPOSE La LANGUE : Parakeet la détecte lui-même, il n'a aucun drapeau pour la forcer. Le réglage n'aurait servi qu'à mentir. À surveiller : whisper avait précisément écarté la détection automatique parce qu'elle se trompait sur des tranches courtes. Le GPU : le build livré est le statique CPU. Annoncer un sélecteur de carte sans pouvoir l'honorer serait pire que de ne rien annoncer — et un modèle de 0,6 B en int8 sur des tranches de quelques secondes n'en a pas besoin, la carte reste au moteur de chat. MIGRATION Un réglage enregistré du temps de whisper retombe sur le défaut au lieu de casser la dictée. Les modèles ggml de /data/whisper/ ne servent plus à rien mais ne sont PAS effacés : ce sont des données que personne n'a demandé de perdre. Ils sont à supprimer à la main. Le paquet UI regénéré ici couvre aussi les sources du commit précédent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
144 lines
6.9 KiB
JavaScript
144 lines
6.9 KiB
JavaScript
// ─── Réglages de la dictée ───────────────────────────────────────────────────
|
||
// Panneau Paramètres → Dictée : modèle, réactivité, plus un test du micro. Ce
|
||
// sont des réglages de SERVEUR (le modèle chargé appartient à la machine), donc
|
||
// rien n'est gardé en localStorage : tout passe par /api/dictate/*.
|
||
//
|
||
// Ni langue ni matériel ici, et le moteur l'impose : Parakeet détecte la langue
|
||
// lui-même, et le binaire livré est le build CPU. Afficher des sélecteurs qu'on
|
||
// ne peut pas honorer serait pire que de ne rien afficher.
|
||
let DIC_CFG = null;
|
||
|
||
async function loadDictateSettings(){
|
||
const [cfg, modeles] = await Promise.all([
|
||
jfetch('/api/dictate/config').then(r => r.json()).catch(()=>({})),
|
||
jfetch('/api/dictate/models').then(r => r.json()).catch(()=>[]),
|
||
]);
|
||
DIC_CFG = cfg || {};
|
||
|
||
const mod = document.getElementById('dic-model');
|
||
mod.innerHTML = '';
|
||
(modeles||[]).forEach(m => {
|
||
const mo = Math.round(m.octets / 1e6);
|
||
// Dire ce qui est déjà là : sans ça, choisir un modèle absent ressemble à
|
||
// un réglage instantané alors que c'est un téléchargement de ~500 Mo.
|
||
// Et dire ce qu'il COMPREND : c'est le vrai critère de choix entre les deux
|
||
// (v3 multilingue, v2 anglais seul), pas leur taille, quasi identique.
|
||
const etat = m.present ? 'déjà téléchargé' : mo + ' Mo à télécharger';
|
||
mod.appendChild(new Option(`${m.nom} — ${m.langues} · ${etat}`, m.id));
|
||
});
|
||
mod.value = cfg.model || '';
|
||
|
||
document.getElementById('dic-react').value = cfg.reactivity || 'moyen';
|
||
refreshDictateState();
|
||
}
|
||
|
||
async function dictateSave(){
|
||
const cfg = {
|
||
model: document.getElementById('dic-model').value,
|
||
reactivity: document.getElementById('dic-react').value,
|
||
};
|
||
const el = document.getElementById('dic-save-state');
|
||
el.textContent = 'enregistrement…';
|
||
const r = await jfetch('/api/dictate/config', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(cfg)});
|
||
const j = await r.json().catch(()=>({}));
|
||
if(!r.ok){ el.textContent = j.error || 'échec'; return; }
|
||
DIC_CFG = cfg;
|
||
// Le serveur de dictée a été arrêté côté Loki : le prochain clic sur le
|
||
// micro le relancera avec ces réglages. On le dit, sinon la première dictée
|
||
// suivante paraît anormalement lente sans raison visible.
|
||
el.textContent = 'enregistré — la prochaine dictée rechargera le modèle';
|
||
refreshDictateState();
|
||
}
|
||
|
||
async function dictateDownload(){
|
||
const id = document.getElementById('dic-model').value;
|
||
const el = document.getElementById('dic-dl-state');
|
||
el.textContent = 'démarrage…';
|
||
const r = await jfetch('/api/dictate/models/download', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({id})});
|
||
const j = await r.json().catch(()=>({}));
|
||
if(!r.ok){ el.textContent = j.error || 'échec'; return; }
|
||
suivreTelechargement();
|
||
}
|
||
|
||
// Sondage tant que le téléchargement tourne. Un modèle de 1 Go prend plusieurs
|
||
// minutes : sans progression affichée, l'utilisateur ne sait pas s'il se passe
|
||
// quelque chose.
|
||
async function suivreTelechargement(){
|
||
const el = document.getElementById('dic-dl-state');
|
||
const r = await jfetch('/api/dictate/models/download');
|
||
const j = await r.json().catch(()=>({}));
|
||
if(j.error){ el.textContent = 'échec : ' + j.error; return; }
|
||
if(j.running){
|
||
el.textContent = `téléchargement de ${j.id} — ${j.pct||0} %`;
|
||
setTimeout(suivreTelechargement, 1000);
|
||
return;
|
||
}
|
||
el.textContent = 'téléchargé';
|
||
loadDictateSettings();
|
||
}
|
||
|
||
async function refreshDictateState(){
|
||
const el = document.getElementById('dic-state');
|
||
if(!el) return;
|
||
const j = await jfetch('/api/dictate/state').then(r => r.json()).catch(()=>null);
|
||
if(!j){ el.textContent = ''; return; }
|
||
const bouts = [];
|
||
bouts.push(j.actif ? `moteur allumé depuis ${j.depuis_s||0} s` : 'moteur éteint (il démarre au premier clic sur le micro)');
|
||
bouts.push(j.present ? `modèle ${j.modele} présent` : `modèle ${j.modele} ABSENT — à télécharger`);
|
||
// Le dire explicitement : sans ça, on cherche un réglage de carte qui
|
||
// n'existe pas, ou on croit à une panne devant un GPU inactif.
|
||
if(j.device === 'cpu') bouts.push('sur le processeur');
|
||
if(j.dl && j.dl.running) bouts.push(`téléchargement ${j.dl.id} ${j.dl.pct||0} %`);
|
||
el.textContent = bouts.join(' · ');
|
||
}
|
||
|
||
// Test du micro : capte 3 s, mesure le niveau réel et transcrit, SANS toucher
|
||
// au champ de saisie. C'est le diagnostic qu'il fallait bricoler à la main
|
||
// quand la dictée ne rendait rien — niveau nul, micro muet, serveur en panne
|
||
// se ressemblent tous de l'extérieur.
|
||
async function dictateTestMic(){
|
||
const out = document.getElementById('dic-test-out');
|
||
const btn = document.getElementById('dic-test');
|
||
if(!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)){
|
||
out.textContent = 'micro bloqué par le navigateur : il faut HTTPS (ou localhost)';
|
||
return;
|
||
}
|
||
btn.disabled = true;
|
||
out.textContent = 'écoute… parle maintenant (3 s)';
|
||
let stream;
|
||
try{
|
||
stream = await navigator.mediaDevices.getUserMedia({audio:{channelCount:1, echoCancellation:true, noiseSuppression:true}});
|
||
}catch(e){ out.textContent = 'accès au micro refusé'; btn.disabled = false; return; }
|
||
const ctx = new (window.AudioContext||window.webkitAudioContext)();
|
||
const src = ctx.createMediaStreamSource(stream);
|
||
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);
|
||
await new Promise(r => setTimeout(r, 3000));
|
||
try{ src.disconnect(); node.disconnect(); }catch(_){}
|
||
stream.getTracks().forEach(t => t.stop());
|
||
const rate = ctx.sampleRate;
|
||
try{ ctx.close(); }catch(_){}
|
||
|
||
let n = 0; for(const c of chunks) n += c.length;
|
||
let somme = 0, crete = 0;
|
||
for(const c of chunks) for(let i = 0; i < c.length; i++){
|
||
const v = c[i]; somme += v*v;
|
||
const a = Math.abs(v); if(a > crete) crete = a;
|
||
}
|
||
const rms = Math.sqrt(somme / (n||1));
|
||
const niveau = rms < 0.002 ? 'QUASI MUET' : rms < 0.01 ? 'faible' : 'correct';
|
||
out.textContent = `niveau ${niveau} (rms ${rms.toFixed(4)}, crête ${crete.toFixed(3)}) — transcription…`;
|
||
|
||
const wav = encodeWav16k(chunks, rate);
|
||
const r = await jfetch('/api/transcribe', {method:'POST', headers:{'Content-Type':'audio/wav'}, body: wav});
|
||
const j = await r.json().catch(()=>({}));
|
||
const tete = `niveau ${niveau} (rms ${rms.toFixed(4)}) — `;
|
||
if(!r.ok) out.textContent = tete + (j.error || 'échec de la transcription');
|
||
else if(!(j.text||'').trim()) out.textContent = tete + 'rien compris' + (rms < 0.002 ? ' (le micro n’a rien capté : vérifie l’entrée choisie par le système)' : '');
|
||
else out.textContent = tete + '« ' + j.text.trim() + ' »';
|
||
btn.disabled = false;
|
||
refreshDictateState();
|
||
}
|