Files
Athena-Deck/stt-ui.js
T

18 lines
7.8 KiB
JavaScript

window.STTUI=(()=>{
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
async function api(path,data){const response=await fetch('/api/v1/'+path,data===undefined?{}:{method:'POST',headers:{'X-Athena-Deck':'1',...(data instanceof FormData?{}:{'Content-Type':'application/json'})},body:data instanceof FormData?data:JSON.stringify(data)});const result=await response.json();if(!response.ok)throw Error(result.error||'STT-Anfrage fehlgeschlagen');return result;}
function html(setup=false){return `<section class="card" id="stt-panel"><h2>${setup?'Spracherkennung einrichten':'Audio in Text umwandeln'}</h2><p id="stt-status" role="status"></p><p id="stt-error" role="alert"></p>${setup?'<p>Qwen3-ASR 0.6B Q8_0 nutzt die vorhandene llama.cpp-Laufzeit auf der CPU. Installiert werden die geprüfte Modellvariante von ggml-org (rund 805 MB) und der Audio-Projektor (214 MB). Andere GGUF-Konvertierungen können inkompatibel sein. Bestehende Dienste werden nicht verwendet oder verändert.</p><a class="link" href="#runtime">llama.cpp installieren / Build auswählen →</a><p><button id="stt-setup">Modell und Audio-Projektor einrichten</button></p><p>Der Download erscheint unter STT → Downloads. Nach Abschluss ein neues Profil aus der Bibliothek anlegen oder ein bestehendes Profil unten ausdrücklich umstellen. Alte Dateien bleiben erhalten.</p><h3>Vorhandenes Profil umstellen</h3><p>Ersetzt die Modellzuordnung durch die geprüfte ggml-org-Variante. Profilname bleibt erhalten.</p><select id="stt-profile"></select><button id="stt-assign">Kompatibles Modell diesem Profil zuordnen</button><h3>Speicherverhalten</h3><p>Automatisch hält das Modell nach der ersten Anfrage geladen. Immer bereit lädt das gewählte Profil im LLM-Betrieb vor, soweit Ressourcen frei sind. Nach jeder Anfrage entladen gibt Speicher sofort frei.</p><label>Verhalten<select id="stt-policy"><option value="auto">Automatisch</option><option value="warm">Immer bereit</option><option value="per_request">Nach jeder Anfrage entladen</option></select></label><label>Profil für „Immer bereit“<select id="stt-warm-profile"></select></label><button id="stt-policy-save">Speicherverhalten speichern</button><p id="stt-policy-state" role="status"></p>':'<form id="stt-form"><label>Profil<select id="stt-profile" required></select></label><label>Audiodatei (maximal 120 Sekunden, 20 MiB)<input type="file" id="stt-file" accept="audio/*" required></label><label>Sprache<select id="stt-language"><option value="de">Deutsch</option><option value="en">Englisch</option><option value="auto">Automatisch</option></select></label><button id="stt-start">Transkribieren</button><button type="button" class="secondary" id="stt-cancel">Abbrechen</button></form><h3>Transkript</h3><pre id="stt-result" style="white-space:pre-wrap"></pre><p>Die Audiodatei wird im Browser in Mono-WAV mit 16 kHz umgewandelt. Unterstützte Eingabeformate hängen vom Browser ab. Audio und Transkript werden nicht dauerhaft gespeichert; das letzte Ergebnis bleibt bis zum nächsten Auftrag im Arbeitsspeicher.</p>'}</section>`;}
async function wav(file){if(file.size>20*1024**2)throw Error('Datei ist größer als 20 MiB.');const context=new AudioContext();let decoded;try{decoded=await context.decodeAudioData(await file.arrayBuffer());}finally{await context.close();}if(!decoded.duration||decoded.duration>120)throw Error('Maximal 120 Sekunden Audio erlaubt.');const offline=new OfflineAudioContext(1,Math.ceil(decoded.duration*16000),16000),source=offline.createBufferSource();source.buffer=decoded;source.connect(offline.destination);source.start();const samples=(await offline.startRendering()).getChannelData(0),buffer=new ArrayBuffer(44+samples.length*2),v=new DataView(buffer);const str=(at,s)=>{for(let i=0;i<s.length;i++)v.setUint8(at+i,s.charCodeAt(i));};str(0,'RIFF');v.setUint32(4,36+samples.length*2,true);str(8,'WAVEfmt ');v.setUint32(16,16,true);v.setUint16(20,1,true);v.setUint16(22,1,true);v.setUint32(24,16000,true);v.setUint32(28,32000,true);v.setUint16(32,2,true);v.setUint16(34,16,true);str(36,'data');v.setUint32(40,samples.length*2,true);samples.forEach((s,i)=>v.setInt16(44+i*2,Math.max(-1,Math.min(1,s))*(s<0?32768:32767),true));return new Blob([buffer],{type:'audio/wav'});}
function bind(setup=false){const root=document.querySelector('#stt-panel');if(!root)return;const el=id=>root.querySelector('#'+id);let busy=false,profiles=[];
async function refresh(){try{const s=await api('stt');if(!root.isConnected)return;el('stt-status').textContent=setup?`llama.cpp: ${s.installed?'vorhanden':'fehlt'} · Modell: ${s.model?'vorhanden':'fehlt'} · Audio-Projektor: ${s.projector?'vorhanden':'fehlt'}`:(s.job?.phase||'Profil und Audiodatei auswählen.')+` · CPU-Modell ${s.loaded?'geladen':'nicht geladen'}`;if(setup){el('stt-setup').disabled=busy||(s.projector&&s.model);el('stt-assign').disabled=busy||!s.model||!profiles.length;}else{el('stt-start').disabled=busy||s.job?.state==='running'||!profiles.length;el('stt-cancel').disabled=s.job?.state!=='running';el('stt-result').textContent=s.job?.text||'';}}catch(err){if(root.isConnected)el('stt-error').textContent=err.message;}finally{if(root.isConnected)setTimeout(refresh,2000);}}
if(setup){api('profiles').then(p=>{if(!root.isConnected)return;profiles=p.profiles.filter(p=>p.kind==='stt');el('stt-profile').innerHTML=profiles.map(p=>`<option value="${e(p.id)}">${e(p.name)}</option>`).join('');}).catch(err=>{if(root.isConnected)el('stt-error').textContent=err.message;});el('stt-assign').onclick=async()=>{const p=profiles.find(p=>p.id===el('stt-profile').value);if(!p)return;busy=true;try{await api('stt/assign',{id:p.id,revision:p.revision});el('stt-error').textContent='Profil umgestellt. Jetzt STT → Testen öffnen.';p.revision++;}catch(err){el('stt-error').textContent=err.message;}finally{busy=false;}};el('stt-setup').onclick=async()=>{busy=true;try{await api('stt/setup',{});el('stt-error').textContent='Download ist eingeplant. Fortschritt unter Downloads.';}catch(err){el('stt-error').textContent=err.message;}finally{busy=false;}};}
else{api('profiles').then(p=>{if(!root.isConnected)return;profiles=p.profiles.filter(p=>p.kind==='stt'&&p.runnable);el('stt-profile').innerHTML=profiles.map(p=>`<option value="${e(p.id)}">${e(p.name)}</option>`).join('')||'<option value="">Zuerst STT einrichten und ein Profil anlegen</option>';}).catch(err=>{if(root.isConnected)el('stt-error').textContent=err.message;});el('stt-form').onsubmit=async ev=>{ev.preventDefault();busy=true;el('stt-start').disabled=true;el('stt-error').textContent='';try{const body=new FormData();body.set('file',await wav(el('stt-file').files[0]),'audio.wav');body.set('profile_id',el('stt-profile').value);body.set('language',el('stt-language').value);if(root.isConnected)await api('stt/start',body);}catch(err){if(root.isConnected)el('stt-error').textContent=err.message;}finally{busy=false;}};el('stt-cancel').onclick=()=>api('stt/cancel',{}).catch(err=>{if(root.isConnected)el('stt-error').textContent=err.message;});} if(setup){
const policy=el('stt-policy'),warm=el('stt-warm-profile'),save=el('stt-policy-save'),status=el('stt-policy-state');
Promise.all([api('audio-policy'),api('profiles')]).then(([current,rows])=>{if(!root.isConnected)return;warm.innerHTML=rows.profiles.filter(p=>p.kind==='stt'&&p.runnable).map(p=>`<option value="${e(p.id)}">${e(p.name)}</option>`).join('');const selected=current.settings.stt;policy.value=selected.mode;if(selected.profile_id)warm.value=selected.profile_id;status.textContent=current.errors.stt||'';}).catch(err=>{if(root.isConnected)status.textContent=err.message;});
save.onclick=async()=>{save.disabled=true;try{const updated=await api('audio-policy',{kind:'stt',mode:policy.value,profile_id:warm.value||null});status.textContent=updated.errors.stt||'Gespeichert.';}catch(err){status.textContent=err.message;}finally{save.disabled=false;}};
}
refresh();
}
return {html,bind};
})();