diff --git a/.env.example b/.env.example index 75f619c..a7bbbe6 100644 --- a/.env.example +++ b/.env.example @@ -58,3 +58,7 @@ JARVIS_DEBUG_KEEP_FILES=4 JARVIS_DEBUG_MAX_STRING=24000 # GET /api/state ist sehr häufig; standardmäßig nicht mitschreiben. JARVIS_DEBUG_HTTP_STATE=false + +# Docker v7.6: whisper-cli is built into the image at /usr/local/bin/whisper-cli. +# The compose.yaml sets absolute container paths automatically. The values above +# are mainly useful for native/non-Docker runs. diff --git a/Dockerfile b/Dockerfile index 3ddf69d..58d2703 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,35 @@ -FROM golang:1.26-bookworm AS build +FROM golang:1.23-bookworm AS build WORKDIR /src COPY go.mod ./ COPY cmd ./cmd COPY internal ./internal RUN CGO_ENABLED=0 go build -o /out/jarvis ./cmd/homehub +# Build whisper.cpp inside the image so Docker STT does not depend on a host binary. +FROM debian:bookworm AS whisper-build +ARG WHISPER_CPP_REF=v1.9.1 +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates git cmake build-essential && rm -rf /var/lib/apt/lists/* +WORKDIR /src +RUN git clone --depth 1 --branch "${WHISPER_CPP_REF}" https://github.com/ggml-org/whisper.cpp.git . \ + && cmake -S . -B build \ + -DCMAKE_BUILD_TYPE=Release \ + -DWHISPER_BUILD_TESTS=OFF \ + -DWHISPER_BUILD_EXAMPLES=ON \ + -DBUILD_SHARED_LIBS=OFF \ + && cmake --build build --config Release --target whisper-cli -j2 \ + && cp build/bin/whisper-cli /out-whisper-cli + FROM debian:bookworm-slim -RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates ffmpeg poppler-utils tesseract-ocr tesseract-ocr-deu && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates ffmpeg poppler-utils tesseract-ocr tesseract-ocr-deu \ + libstdc++6 libgomp1 \ + && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY --from=build /out/jarvis /app/jarvis -RUN mkdir -p /app/data /models /tools +COPY --from=whisper-build /out-whisper-cli /usr/local/bin/whisper-cli +COPY scripts /app/scripts +RUN chmod 0755 /usr/local/bin/whisper-cli /app/scripts/*.sh \ + && mkdir -p /app/data /models /tools EXPOSE 8080 CMD ["/app/jarvis"] diff --git a/README.md b/README.md index 5a42211..1295b43 100644 --- a/README.md +++ b/README.md @@ -496,3 +496,46 @@ Der Clean-Modus besitzt stärkere Textkontraste für Chat-Hervorhebungen, sekund Die Clean-Home-Seite verwendet jetzt ein bewusstes 12-Spalten-Raster: Begrüßung + nächster Termin = 7/5, Aufgaben + Essen = 6/6 und Local AI = volle Breite. Dadurch entstehen auf großen Displays keine ungenutzten Restspalten mehr. + +## Docker Voice / Whisper (v7.6) + +Im Docker-Betrieb ist `whisper-cli` jetzt **Bestandteil des JARVIS-Images**. Beim Image-Build wird `whisper.cpp` (standardmäßig `v1.9.1`) kompiliert und `/usr/local/bin/whisper-cli` in das Runtime-Image kopiert. Damit ist STT nicht mehr davon abhängig, dass auf dem Host `./local-tools/whisper-cli` liegt. + +Das Whisper-Modell bleibt als Volume unter `/models`, damit die große Modelldatei nicht bei jedem Build im Image landet. Für das Standard-Setup muss auf dem Host vorhanden sein: + +```text +./models/ggml-small.bin +``` + +Der schnellste Weg, Voice vollständig vorzubereiten, ist: + +```bash +./scripts/setup-docker-voice.sh +docker compose build --no-cache jarvis +docker compose up -d +``` + +Das Setup-Script lädt das Whisper-small-Modell sowie die deutsche Piper-Stimme herunter und legt das Piper-Linux-Paket passend zur Host-Architektur unter `./local-tools` ab. + +### Diagnose im Container + +```bash +docker compose exec jarvis /app/scripts/doctor.sh +``` + +oder nur Whisper: + +```bash +docker compose exec jarvis sh -lc 'command -v whisper-cli && whisper-cli --help >/dev/null && ls -lh /models/ggml-small.bin' +``` + +Im HUD wird bei nicht bereitem STT jetzt unterschieden zwischen: + +- `BIN` – Whisper-Binary fehlt oder ist nicht ausführbar +- `MODEL` – Whisper-Modell fehlt +- `FFMPEG` – Audio-Konverter fehlt +- `LOCAL` – STT ist vollständig einsatzbereit + +Die API liefert zusätzlich die aufgelösten Binary-/Modellpfade und konkrete Diagnosefehler unter `system.voice`. Auch `docker compose logs jarvis` schreibt beim Start den Voice-Status mit den einzelnen Komponenten. + +> Hinweis: Wenn du von einer älteren Version aktualisierst, reicht `docker compose up -d` möglicherweise nicht, weil Docker das alte Image weiterverwendet. Für den ersten Start von v7.6 daher einmal `docker compose build --no-cache jarvis` ausführen. diff --git a/cmd/homehub/main.go b/cmd/homehub/main.go index 600238c..39ea8b0 100644 --- a/cmd/homehub/main.go +++ b/cmd/homehub/main.go @@ -181,6 +181,14 @@ func main() { }, } a.tools = newToolRegistry(a) + voiceStatus := a.voice.Status(wakeWord) + log.Printf("Voice erkannt: stt=%v tts=%v ffmpeg=%v whisperBin=%v whisperModel=%v piperBin=%v piperModel=%v", voiceStatus.STTReady, voiceStatus.TTSReady, voiceStatus.FFmpegReady, voiceStatus.WhisperBinReady, voiceStatus.WhisperModelReady, voiceStatus.PiperBinReady, voiceStatus.PiperModelReady) + if !voiceStatus.STTReady { + log.Printf("Voice/STT Diagnose: ffmpeg=%q whisper=%q model=%q ffmpegErr=%q whisperErr=%q modelErr=%q", voiceStatus.FFmpegBin, voiceStatus.WhisperBin, voiceStatus.WhisperModel, voiceStatus.FFmpegError, voiceStatus.WhisperError, voiceStatus.WhisperModelError) + } + if !voiceStatus.TTSReady { + log.Printf("Voice/TTS Diagnose: piper=%q model=%q piperErr=%q modelErr=%q", voiceStatus.PiperBin, voiceStatus.PiperModel, voiceStatus.PiperError, voiceStatus.PiperModelError) + } mux := http.NewServeMux() mux.HandleFunc("GET /api/state", a.handleState) diff --git a/compose.yaml b/compose.yaml index 804c676..2a0c686 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,6 +1,6 @@ services: jarvis: - build: . + image: ports: - "8080:8080" volumes: @@ -14,9 +14,9 @@ services: JARVIS_WAKE_WORD: jarvis JARVIS_TIMEZONE: Europe/Berlin TZ: Europe/Berlin - OLLAMA_URL: http://ollama:11434 - OLLAMA_MODEL: auto - OLLAMA_EMBED_MODEL: auto + OLLAMA_URL: http://10.10.11.123:11434 + OLLAMA_MODEL: gemma4:latest + OLLAMA_EMBED_MODEL: embeddinggemma:latest TASK_SCOUT_INTERVAL: 2m HOME_AUTOMATION_INTERVAL: 1m KANBAN_AI_INTERVAL: 5m @@ -30,15 +30,3 @@ services: VOICE_LANGUAGE: de VOICE_PIPER_BIN: /tools/piper VOICE_PIPER_MODEL: /models/de_DE-thorsten-medium.onnx - depends_on: - - ollama - - ollama: - image: ollama/ollama:latest - ports: - - "11434:11434" - volumes: - - ollama-data:/root/.ollama - -volumes: - ollama-data: diff --git a/go.mod b/go.mod index 14fe74f..5ad262d 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module homehub -go 1.26 +go 1.23 diff --git a/internal/ui/static/app.js b/internal/ui/static/app.js index 7e52e36..0f495bd 100644 --- a/internal/ui/static/app.js +++ b/internal/ui/static/app.js @@ -10,6 +10,7 @@ const fmtDay=d=>d.toLocaleDateString('de-DE',{weekday:'short',day:'2-digit',mont const mealName=m=>({breakfast:'FRÜH',lunch:'MITTAG',dinner:'ABEND',snack:'SNACK'}[m]||String(m||'ABEND').toUpperCase()); const taskOrigin=t=>t.source==='jarvis-command'?'JARVIS':t.aiManaged?'AI SCOUT':'MANUAL'; const messageHTML=text=>esc(text).replace(/\*\*(.+?)\*\*/g,'$1').replace(/`([^`]+)`/g,'$1').replace(/\n/g,'
'); +function voiceSetupMessage(voice={},kind='stt'){if(kind==='stt'){if(!voice.ffmpegReady)return 'FFmpeg fehlt im Container';if(voice.whisperBinReady===false)return `Whisper-Binary fehlt: ${voice.whisperBin||'nicht gefunden'}`;if(voice.whisperModelReady===false)return `Whisper-Modell fehlt: ${voice.whisperModel||'/models/ggml-small.bin'}`;return voice.whisperError||voice.whisperModelError||'Lokales Whisper ist noch nicht konfiguriert'}if(voice.piperBinReady===false)return `Piper-Binary fehlt: ${voice.piperBin||'/tools/piper'}`;if(voice.piperModelReady===false)return `Piper-Modell fehlt: ${voice.piperModel||'/models/de_DE-thorsten-medium.onnx'}`;return voice.piperError||voice.piperModelError||'Lokales Piper ist noch nicht konfiguriert'} const THEME_KEY='jarvis-ui-theme',VIEW_KEY='jarvis-clean-view'; const cleanViews={ home:{eyebrow:'Übersicht',title:'Home',subtitle:'Dein Zuhause, Termine und Aufgaben auf einen Blick.'}, @@ -27,7 +28,7 @@ function toast(msg){const el=$('#toast');el.textContent=msg;el.classList.add('sh function tickClock(){const n=new Date();$('#clock').textContent=n.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit',second:'2-digit'});$('#date').textContent=n.toLocaleDateString('de-DE',{weekday:'short',day:'2-digit',month:'short',year:'numeric'}).toUpperCase();if($('#cleanTopDate'))$('#cleanTopDate').textContent=n.toLocaleDateString('de-DE',{weekday:'long',day:'numeric',month:'long',year:'numeric'})}setInterval(tickClock,1000);tickClock(); function setChip(id,ok,label){const dot=$(id+'Dot');if(!dot)return;const chip=dot.closest('.status-chip');chip.classList.toggle('online',!!ok);chip.classList.toggle('offline',ok===false);$(id+'Status').textContent=label} async function refresh(){try{state=await api('/api/state');renderState();setChip('#core',true,'ONLINE')}catch(e){setChip('#core',false,'OFFLINE')}} -function renderState(){const sys=state.system||{},voice=sys.voice||{},ollama=sys.ollama||{},chunks=Number(sys.ragChunks||0);const chatReady=ollama.chatReady!==false&&!!(ollama.model||sys.model),embedReady=ollama.embeddingReady!==false&&!!(ollama.embeddingModel||sys.embeddingModel);setChip('#core',ollama.reachable===false?false:chatReady,chatReady?'LOCAL':(ollama.reachable===false?'OLLAMA OFF':'NO MODEL'));$('#coreStatus').title=chatReady?`ACTIVE MODEL: ${ollama.model||sys.model}`:(ollama.lastError||'Kein Chat-Modell erkannt');setChip('#rag',embedReady?(chunks>0?true:null):false,embedReady?(chunks?`${chunks} CHUNKS`:'EMPTY'):'NO EMBED');setChip('#stt',!!voice.sttReady,voice.sttReady?'LOCAL':'SETUP');setChip('#tts',!!voice.ttsReady,voice.ttsReady?'LOCAL':'SETUP');const mt=$('#modelTag'),ctxN=Number(sys.context?.numCtx||0),ctxLabel=ctxN>=1024?`${Math.round(ctxN/1024)}K`:(ctxN||'—');if(mt){mt.textContent=chatReady?`LOCAL · ${String(ollama.model||sys.model).toUpperCase()} · CTX ${ctxLabel}`:'LOCAL · DEGRADED';mt.title=`Tool Agent: ${sys.agent?.mode||'unknown'} · ${sys.agent?.tools?.length||0} tools · max ${sys.agent?.maxSteps||0} steps · Conversation ${sys.context?.historyTokens||0} tokens`;}const scout=$('#scoutBtn'),kai=$('#kanbanAI'),reindex=$('#reindexBtn');if(scout){scout.disabled=!chatReady;scout.title=chatReady?'Lokalen Task-Scout starten':'Benötigt ein lokales Ollama-Chatmodell'}if(kai){kai.disabled=!chatReady;kai.title=chatReady?'Kanban mit lokalem Modell organisieren':'Benötigt ein lokales Ollama-Chatmodell'}if(reindex){reindex.disabled=!embedReady;reindex.title=embedReady?'RAG neu indexieren':'Benötigt ein lokales Embedding-Modell'}$('#chunkCount').textContent=chunks;$('#memoryBar').style.width=`${Math.min(100,5+Math.log2(chunks+1)*12)}%`;$('#wakeWord').textContent=String(voice.wakeWord||'jarvis').toUpperCase();$('#whisperDiag').textContent=voice.sttReady?'ONLINE':'MISSING';$('#whisperDiag').className=voice.sttReady?'good':'bad';$('#piperDiag').textContent=voice.ttsReady?'ONLINE':'MISSING';$('#piperDiag').className=voice.ttsReady?'good':'bad';renderCleanHome();renderBrief();renderCalendar();renderTasks();renderKanban();renderGroceries();renderMeals();renderRecipes();renderReminders();renderDocuments();renderActivity();const dbg=sys.debug||{};const dstat=$('#debugTraceStatus');if(dstat){dstat.classList.toggle('off',!dbg.enabled);dstat.innerHTML=` ${dbg.enabled?'DEBUG TRACE':'TRACE OFF'}`};if($('#debugExportBtn'))$('#debugExportBtn').disabled=!dbg.enabled;if($('#debugClearBtn'))$('#debugClearBtn').disabled=!dbg.enabled;if(!bootstrappedChat){renderInitialChat();bootstrappedChat=true}} +function renderState(){const sys=state.system||{},voice=sys.voice||{},ollama=sys.ollama||{},chunks=Number(sys.ragChunks||0);const chatReady=ollama.chatReady!==false&&!!(ollama.model||sys.model),embedReady=ollama.embeddingReady!==false&&!!(ollama.embeddingModel||sys.embeddingModel);setChip('#core',ollama.reachable===false?false:chatReady,chatReady?'LOCAL':(ollama.reachable===false?'OLLAMA OFF':'NO MODEL'));$('#coreStatus').title=chatReady?`ACTIVE MODEL: ${ollama.model||sys.model}`:(ollama.lastError||'Kein Chat-Modell erkannt');setChip('#rag',embedReady?(chunks>0?true:null):false,embedReady?(chunks?`${chunks} CHUNKS`:'EMPTY'):'NO EMBED');const sttLabel=voice.sttReady?'LOCAL':(!voice.whisperBinReady?'BIN':(!voice.whisperModelReady?'MODEL':(!voice.ffmpegReady?'FFMPEG':'SETUP'))),ttsLabel=voice.ttsReady?'LOCAL':(!voice.piperBinReady?'BIN':(!voice.piperModelReady?'MODEL':'SETUP'));setChip('#stt',!!voice.sttReady,sttLabel);setChip('#tts',!!voice.ttsReady,ttsLabel);$('#stt').title=voice.sttReady?`Whisper: ${voice.whisperBin||'whisper-cli'} · Modell: ${voice.whisperModel||''}`:voiceSetupMessage(voice,'stt');$('#tts').title=voice.ttsReady?`Piper: ${voice.piperBin||'piper'} · Modell: ${voice.piperModel||''}`:voiceSetupMessage(voice,'tts');const mt=$('#modelTag'),ctxN=Number(sys.context?.numCtx||0),ctxLabel=ctxN>=1024?`${Math.round(ctxN/1024)}K`:(ctxN||'—');if(mt){mt.textContent=chatReady?`LOCAL · ${String(ollama.model||sys.model).toUpperCase()} · CTX ${ctxLabel}`:'LOCAL · DEGRADED';mt.title=`Tool Agent: ${sys.agent?.mode||'unknown'} · ${sys.agent?.tools?.length||0} tools · max ${sys.agent?.maxSteps||0} steps · Conversation ${sys.context?.historyTokens||0} tokens`;}const scout=$('#scoutBtn'),kai=$('#kanbanAI'),reindex=$('#reindexBtn');if(scout){scout.disabled=!chatReady;scout.title=chatReady?'Lokalen Task-Scout starten':'Benötigt ein lokales Ollama-Chatmodell'}if(kai){kai.disabled=!chatReady;kai.title=chatReady?'Kanban mit lokalem Modell organisieren':'Benötigt ein lokales Ollama-Chatmodell'}if(reindex){reindex.disabled=!embedReady;reindex.title=embedReady?'RAG neu indexieren':'Benötigt ein lokales Embedding-Modell'}$('#chunkCount').textContent=chunks;$('#memoryBar').style.width=`${Math.min(100,5+Math.log2(chunks+1)*12)}%`;$('#wakeWord').textContent=String(voice.wakeWord||'jarvis').toUpperCase();$('#whisperDiag').textContent=voice.sttReady?'ONLINE':sttLabel;$('#whisperDiag').title=voice.sttReady?'Whisper bereit':voiceSetupMessage(voice,'stt');$('#whisperDiag').className=voice.sttReady?'good':'bad';$('#piperDiag').textContent=voice.ttsReady?'ONLINE':ttsLabel;$('#piperDiag').title=voice.ttsReady?'Piper bereit':voiceSetupMessage(voice,'tts');$('#piperDiag').className=voice.ttsReady?'good':'bad';renderCleanHome();renderBrief();renderCalendar();renderTasks();renderKanban();renderGroceries();renderMeals();renderRecipes();renderReminders();renderDocuments();renderActivity();const dbg=sys.debug||{};const dstat=$('#debugTraceStatus');if(dstat){dstat.classList.toggle('off',!dbg.enabled);dstat.innerHTML=` ${dbg.enabled?'DEBUG TRACE':'TRACE OFF'}`};if($('#debugExportBtn'))$('#debugExportBtn').disabled=!dbg.enabled;if($('#debugClearBtn'))$('#debugClearBtn').disabled=!dbg.enabled;if(!bootstrappedChat){renderInitialChat();bootstrappedChat=true}} function renderCleanHome(){ const now=new Date(),today=new Date();today.setHours(0,0,0,0);const tomorrow=new Date(today);tomorrow.setDate(tomorrow.getDate()+1);const weekEnd=new Date(today);weekEnd.setDate(weekEnd.getDate()+7); const events=[...(state.events||[])].filter(e=>{const d=parseDate(e.start);return d&&d>=today}).sort((a,b)=>String(a.start).localeCompare(String(b.start))); @@ -42,7 +43,7 @@ function renderCleanHome(){ const ev=events[0],evEl=$('#cleanNextEvent');if(evEl)evEl.innerHTML=ev?`${esc(ev.title)}

${esc(fmtDay(parseDate(ev.start)))} · ${esc(ev.allDay?'Ganztägig':fmtClock(ev.start))}${ev.location?' · '+esc(ev.location):''}

`:'Keine Termine

Dein Kalender ist frei.

'; const task=tasks[0],taskEl=$('#cleanNextTask');if(taskEl)taskEl.innerHTML=task?`${esc(task.title)}

${task.priority==='high'?'Hohe Priorität':'Offen'}${task.due?' · '+esc(fmtTime(task.due)):''}

`:'Keine offenen Aufgaben

Du bist auf dem aktuellen Stand.

'; const meal=nextMeal,mealEl=$('#cleanNextMeal');if(mealEl)mealEl.innerHTML=meal?`${esc(meal.title||meal.recipeTitle||'Geplante Mahlzeit')}

${esc(fmtDay(parseDate(meal.date)))} · ${esc(mealName(meal.meal))}${meal.servings?` · ${meal.servings} Portionen`:''}

`:'Noch nichts geplant

Plane eine Mahlzeit für heute oder morgen.

'; - const sys=state.system||{},ollama=sys.ollama||{},voice=sys.voice||{};if($('#cleanCoreQuick'))$('#cleanCoreQuick').textContent=ollama.chatReady===false?'Nicht bereit':String(ollama.model||sys.model||'Lokal');if($('#cleanRagQuick'))$('#cleanRagQuick').textContent=Number(sys.ragChunks||0)?`${sys.ragChunks} Chunks`:'Leer';if($('#cleanVoiceQuick'))$('#cleanVoiceQuick').textContent=voice.sttReady?'Bereit':'Setup';if($('#cleanModelStatus'))$('#cleanModelStatus').textContent=ollama.chatReady===false?'Modell fehlt':String(ollama.model||sys.model||'Online'); + const sys=state.system||{},ollama=sys.ollama||{},voice=sys.voice||{};if($('#cleanCoreQuick'))$('#cleanCoreQuick').textContent=ollama.chatReady===false?'Nicht bereit':String(ollama.model||sys.model||'Lokal');if($('#cleanRagQuick'))$('#cleanRagQuick').textContent=Number(sys.ragChunks||0)?`${sys.ragChunks} Chunks`:'Leer';if($('#cleanVoiceQuick')){const cv=$('#cleanVoiceQuick');cv.textContent=voice.sttReady?'Bereit':sttLabel;cv.title=voice.sttReady?'Lokales Whisper bereit':voiceSetupMessage(voice,'stt')}if($('#cleanModelStatus'))$('#cleanModelStatus').textContent=ollama.chatReady===false?'Modell fehlt':String(ollama.model||sys.model||'Online'); } function renderBrief(){const start=new Date();start.setHours(0,0,0,0);const until=new Date(start);until.setDate(until.getDate()+7);$('#briefEvents').textContent=(state.events||[]).filter(e=>{const d=parseDate(e.start);return d&&d>=start&&dt.status!=='done').length;$('#briefGroceries').textContent=(state.groceries||[]).filter(g=>!g.checked).length;$('#briefReminders').textContent=(state.reminders||[]).filter(r=>!r.done).length} function renderCalendar(){const today=new Date();today.setHours(0,0,0,0);const end=new Date(today);end.setDate(end.getDate()+7);const events=[...(state.events||[])].sort((a,b)=>String(a.start).localeCompare(String(b.start)));const days=[];for(let i=0;i<7;i++){const d=new Date(today);d.setDate(d.getDate()+i);const key=localDateKey(d),count=events.filter(e=>{const ed=parseDate(e.start);return ed&&localDateKey(ed)===key}).length;days.push(`
${d.toLocaleDateString('de-DE',{weekday:'short'}).toUpperCase()}${d.getDate()}${count?`${count}`:''}
`)}$('#weekRail').innerHTML=days.join('');const visible=events.filter(e=>{const d=parseDate(e.start);return d&&d>=today&&d`
${esc(e.allDay?'GANZTÄGIG':fmtClock(e.start))}
${esc(e.title)}${esc(fmtDay(parseDate(e.start)))}${e.location?' · '+esc(e.location):''} · ${e.aiManaged?'JARVIS':'MANUAL'}
`).join(''):'
KEINE TERMINE IN DEN NÄCHSTEN 7 TAGEN
';$$('[data-delete-event]').forEach(b=>b.onclick=()=>deleteEntity('/api/events/',b.dataset.deleteEvent,'Termin entfernt'))} @@ -88,10 +89,10 @@ $$('[data-add]').forEach(b=>b.onclick=()=>openDialog(b.dataset.add));const syncM $('#entityForm').onsubmit=async e=>{e.preventDefault();const def=dialogDefs[dialogType],fd=new FormData(e.currentTarget),raw=Object.fromEntries(fd.entries()),v=dialogEditing?{...dialogEditing,...raw}:raw;['due','start','end','at'].forEach(k=>{if(v[k])v[k]=new Date(v[k]).toISOString();else if(k in v)delete v[k]});['servings','prepMinutes'].forEach(k=>{if(v[k])v[k]=Number(v[k])});if(dialogType==='recipe'){v.ingredients=String(v.ingredientsText||'').split('\n').map(x=>x.trim()).filter(Boolean).map(line=>{const p=line.split('|').map(x=>x.trim());return p.length>=3?{quantity:p[0],unit:p[1],name:p.slice(2).join(' | ')}:{name:line}});v.steps=String(v.stepsText||'').split('\n').map(x=>x.trim()).filter(Boolean);v.tags=String(v.tagsText||'').split(',').map(x=>x.trim()).filter(Boolean);delete v.ingredientsText;delete v.stepsText;delete v.tagsText}if(dialogType==='meal'&&v.recipeId){const r=state.recipes.find(x=>x.id===v.recipeId);if(r&&!v.title)v.title=r.title}if(dialogType==='task'){v.status='open';v.aiManaged=false}if(dialogType==='kanban'){v.aiManaged=false;v.source='manual'}try{await post(def.endpoint,v);dialogEditing=null;$('#entityDialog').close();toast('Gespeichert');await refresh()}catch(err){toast(err.message)}}; function mediaOptions(){for(const t of['audio/webm;codecs=opus','audio/webm','audio/ogg;codecs=opus','audio/mp4'])if(window.MediaRecorder?.isTypeSupported?.(t))return{mimeType:t};return{}}function extFor(type){if(type.includes('ogg'))return'.ogg';if(type.includes('mp4'))return'.mp4';return'.webm'}async function ensureMic(){if(!navigator.mediaDevices?.getUserMedia)throw new Error('Browser unterstützt keinen Mikrofonzugriff');return navigator.mediaDevices.getUserMedia({audio:{echoCancellation:true,noiseSuppression:true,autoGainControl:true}})}async function transcribeBlob(blob){const fd=new FormData();fd.append('audio',blob,'voice'+extFor(blob.type||'audio/webm'));const out=await api('/api/voice/transcribe',{method:'POST',body:fd});return String(out.text||'').trim()} -async function startPTT(contextName=''){if(pttRecorder){stopPTT();return}if(!(state.system?.voice?.sttReady)){toast('Lokales Whisper ist noch nicht konfiguriert');return}pttContext=contextName;const resumeWake=wakeEnabled;if(resumeWake)await stopWake(false);try{pttStream=await ensureMic();pttChunks=[];pttRecorder=new MediaRecorder(pttStream,mediaOptions());pttRecorder.ondataavailable=e=>{if(e.data?.size)pttChunks.push(e.data)};pttRecorder.onstop=async()=>{const rec=pttRecorder;pttRecorder=null;const blob=new Blob(pttChunks,{type:rec?.mimeType||'audio/webm'});pttChunks=[];pttStream?.getTracks().forEach(t=>t.stop());pttStream=null;$('#globalMic').classList.remove('recording');$('#voiceOverlay').classList.remove('show');setCoreMode('TRANSCRIBE');try{const text=await transcribeBlob(blob);$('#overlayTranscript').textContent=text||'Kein Sprachsignal erkannt';if(text)await sendCommand(text,true,pttContext)}catch(e){toast(e.message);setCoreMode('STANDBY')}pttContext='';if(resumeWake&&$('#wakeToggle').checked)startWake()};pttRecorder.start();$('#globalMic').classList.add('recording');$('#voiceOverlay').classList.add('show');$('#voiceContextLabel').textContent=(contextName||'GLOBAL').toUpperCase();$('#overlayStatus').textContent='LISTENING';$('#overlayTranscript').textContent='Sprich jetzt · erneut drücken zum Senden';setCoreMode('LISTENING')}catch(e){toast(e.message);pttContext='';if(resumeWake&&$('#wakeToggle').checked)startWake()}} +async function startPTT(contextName=''){if(pttRecorder){stopPTT();return}if(!(state.system?.voice?.sttReady)){toast(voiceSetupMessage(state.system?.voice||{},'stt'));return}pttContext=contextName;const resumeWake=wakeEnabled;if(resumeWake)await stopWake(false);try{pttStream=await ensureMic();pttChunks=[];pttRecorder=new MediaRecorder(pttStream,mediaOptions());pttRecorder.ondataavailable=e=>{if(e.data?.size)pttChunks.push(e.data)};pttRecorder.onstop=async()=>{const rec=pttRecorder;pttRecorder=null;const blob=new Blob(pttChunks,{type:rec?.mimeType||'audio/webm'});pttChunks=[];pttStream?.getTracks().forEach(t=>t.stop());pttStream=null;$('#globalMic').classList.remove('recording');$('#voiceOverlay').classList.remove('show');setCoreMode('TRANSCRIBE');try{const text=await transcribeBlob(blob);$('#overlayTranscript').textContent=text||'Kein Sprachsignal erkannt';if(text)await sendCommand(text,true,pttContext)}catch(e){toast(e.message);setCoreMode('STANDBY')}pttContext='';if(resumeWake&&$('#wakeToggle').checked)startWake()};pttRecorder.start();$('#globalMic').classList.add('recording');$('#voiceOverlay').classList.add('show');$('#voiceContextLabel').textContent=(contextName||'GLOBAL').toUpperCase();$('#overlayStatus').textContent='LISTENING';$('#overlayTranscript').textContent='Sprich jetzt · erneut drücken zum Senden';setCoreMode('LISTENING')}catch(e){toast(e.message);pttContext='';if(resumeWake&&$('#wakeToggle').checked)startWake()}} function stopPTT(){if(pttRecorder&&pttRecorder.state!=='inactive'){$('#overlayStatus').textContent='PROCESSING';pttRecorder.stop()}}$('#globalMic').onclick=()=>startPTT('');$('#inlineMic').onclick=()=>startPTT('');$$('.module-mic').forEach(b=>b.onclick=()=>startPTT(b.dataset.voiceContext||'')); function recordFixedChunk(stream,ms){return new Promise((resolve,reject)=>{let chunks=[],rec;try{rec=new MediaRecorder(stream,mediaOptions())}catch(e){reject(e);return}rec.ondataavailable=e=>{if(e.data?.size)chunks.push(e.data)};rec.onerror=e=>reject(e.error||new Error('Recorder error'));rec.onstop=()=>resolve(new Blob(chunks,{type:rec.mimeType||'audio/webm'}));rec.start();setTimeout(()=>{if(rec.state!=='inactive')rec.stop()},ms)})} -async function startWake(){if(wakeEnabled)return;if(!(state.system?.voice?.sttReady)){$('#wakeToggle').checked=false;toast('Wake-Word benötigt lokales Whisper');return}wakeEnabled=true;const token=++wakeToken;try{wakeStream=await ensureMic();$('#micDiag').textContent='HOT';$('#micDiag').className='good';$('#wakeHelp').textContent=`Lokaler Wake-Scan aktiv. Sage „${String(state.system.voice.wakeWord||'jarvis').toUpperCase()}“ und danach deinen Befehl.`;setCoreMode('WAKE ARMED')}catch(e){wakeEnabled=false;$('#wakeToggle').checked=false;toast(e.message);return}while(wakeEnabled&&token===wakeToken){try{const blob=await recordFixedChunk(wakeStream,3000);if(!wakeEnabled||token!==wakeToken||speaking||blob.size<1000)continue;if(wakeBusy)continue;wakeBusy=true;let text='';try{text=await transcribeBlob(blob)}catch(e){if(wakeEnabled)console.debug(e.message)}finally{wakeBusy=false}if(text)processWakeTranscript(text)}catch(e){if(wakeEnabled)toast('Wake-Word: '+e.message);break}}} +async function startWake(){if(wakeEnabled)return;if(!(state.system?.voice?.sttReady)){$('#wakeToggle').checked=false;toast(voiceSetupMessage(state.system?.voice||{},'stt'));return}wakeEnabled=true;const token=++wakeToken;try{wakeStream=await ensureMic();$('#micDiag').textContent='HOT';$('#micDiag').className='good';$('#wakeHelp').textContent=`Lokaler Wake-Scan aktiv. Sage „${String(state.system.voice.wakeWord||'jarvis').toUpperCase()}“ und danach deinen Befehl.`;setCoreMode('WAKE ARMED')}catch(e){wakeEnabled=false;$('#wakeToggle').checked=false;toast(e.message);return}while(wakeEnabled&&token===wakeToken){try{const blob=await recordFixedChunk(wakeStream,3000);if(!wakeEnabled||token!==wakeToken||speaking||blob.size<1000)continue;if(wakeBusy)continue;wakeBusy=true;let text='';try{text=await transcribeBlob(blob)}catch(e){if(wakeEnabled)console.debug(e.message)}finally{wakeBusy=false}if(text)processWakeTranscript(text)}catch(e){if(wakeEnabled)toast('Wake-Word: '+e.message);break}}} async function stopWake(updateToggle=true){wakeEnabled=false;++wakeToken;wakeStream?.getTracks().forEach(t=>t.stop());wakeStream=null;wakeArmedUntil=0;$('#micDiag').textContent='READY';$('#micDiag').className='';if(updateToggle)$('#wakeToggle').checked=false;$('#wakeHelp').textContent='Wake-Word aus. Jeder Modul-Button und der globale Voice-Core starten Push-to-talk.';setCoreMode('STANDBY')} function processWakeTranscript(raw){const text=raw.trim(),wake=String(state.system?.voice?.wakeWord||'jarvis').toLowerCase(),lower=text.toLowerCase(),idx=lower.indexOf(wake);if(idx>=0){const after=text.slice(idx+wake.length).replace(/^[\s,.:;!\-]+/,'').trim();wakeArmedUntil=Date.now()+8500;$('#voiceOverlay').classList.add('show');$('#voiceContextLabel').textContent='GLOBAL';$('#overlayStatus').textContent='WAKE CONFIRMED';$('#overlayTranscript').textContent=after||'Ich höre …';setCoreMode('COMMAND');if(after.length>1){wakeArmedUntil=0;$('#voiceOverlay').classList.remove('show');sendCommand(after,true)}else setTimeout(()=>{if(Date.now()>wakeArmedUntil)$('#voiceOverlay').classList.remove('show')},9000);return}if(Date.now()1){wakeArmedUntil=0;$('#voiceOverlay').classList.remove('show');sendCommand(text,true)}}$('#wakeToggle').onchange=e=>{if(e.target.checked)startWake();else stopWake()}; async function speakLocal(text){if(!(state.system?.voice?.ttsReady))return;speaking=true;setCoreMode('SPEAKING');try{const res=await fetch('/api/voice/speak',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text})});if(!res.ok)throw new Error((await res.text()).trim()||`TTS HTTP ${res.status}`);const blob=await res.blob(),url=URL.createObjectURL(blob),audio=new Audio(url);await new Promise((resolve,reject)=>{audio.onended=resolve;audio.onerror=reject;audio.play().catch(reject)});URL.revokeObjectURL(url)}catch(e){toast('TTS: '+e.message)}finally{speaking=false;setCoreMode(wakeEnabled?'WAKE ARMED':'STANDBY')}} diff --git a/internal/voice/voice.go b/internal/voice/voice.go index a25b0f2..a646959 100644 --- a/internal/voice/voice.go +++ b/internal/voice/voice.go @@ -20,35 +20,85 @@ type Pipeline struct { } type Status struct { - STTReady bool `json:"sttReady"` - TTSReady bool `json:"ttsReady"` - FFmpegReady bool `json:"ffmpegReady"` - WhisperReady bool `json:"whisperReady"` - PiperReady bool `json:"piperReady"` - WakeWord string `json:"wakeWord"` + STTReady bool `json:"sttReady"` + TTSReady bool `json:"ttsReady"` + FFmpegReady bool `json:"ffmpegReady"` + WhisperReady bool `json:"whisperReady"` + PiperReady bool `json:"piperReady"` + WhisperBinReady bool `json:"whisperBinReady"` + WhisperModelReady bool `json:"whisperModelReady"` + PiperBinReady bool `json:"piperBinReady"` + PiperModelReady bool `json:"piperModelReady"` + FFmpegBin string `json:"ffmpegBin,omitempty"` + WhisperBin string `json:"whisperBin,omitempty"` + WhisperModel string `json:"whisperModel,omitempty"` + PiperBin string `json:"piperBin,omitempty"` + PiperModel string `json:"piperModel,omitempty"` + FFmpegError string `json:"ffmpegError,omitempty"` + WhisperError string `json:"whisperError,omitempty"` + WhisperModelError string `json:"whisperModelError,omitempty"` + PiperError string `json:"piperError,omitempty"` + PiperModelError string `json:"piperModelError,omitempty"` + WakeWord string `json:"wakeWord"` } func (p *Pipeline) Status(wakeWord string) Status { - _, fe := exec.LookPath(p.FFmpegBin) - _, we := exec.LookPath(p.WhisperBin) - _, pe := exec.LookPath(p.PiperBin) - wm := fileExists(p.WhisperModel) - pm := fileExists(p.PiperModel) - return Status{STTReady: fe == nil && we == nil && wm, TTSReady: pe == nil && pm, FFmpegReady: fe == nil, WhisperReady: we == nil && wm, PiperReady: pe == nil && pm, WakeWord: wakeWord} + ffmpegBin, fe := resolveExecutable(p.FFmpegBin, "ffmpeg") + whisperBin, we := resolveExecutable(p.WhisperBin, "/usr/local/bin/whisper-cli", "/tools/whisper-cli", "whisper-cli") + piperBin, pe := resolveExecutable(p.PiperBin, "/tools/piper", "/usr/local/bin/piper", "piper") + whisperModel, wme := resolveFile(p.WhisperModel, "/models/ggml-small.bin", "/app/models/ggml-small.bin") + piperModel, pme := resolveFile(p.PiperModel, "/models/de_DE-thorsten-medium.onnx", "/app/models/de_DE-thorsten-medium.onnx") + + st := Status{ + FFmpegReady: fe == nil, + WhisperBinReady: we == nil, + WhisperModelReady: wme == nil, + PiperBinReady: pe == nil, + PiperModelReady: pme == nil, + FFmpegBin: firstNonEmpty(ffmpegBin, p.FFmpegBin), + WhisperBin: firstNonEmpty(whisperBin, p.WhisperBin), + WhisperModel: firstNonEmpty(whisperModel, p.WhisperModel), + PiperBin: firstNonEmpty(piperBin, p.PiperBin), + PiperModel: firstNonEmpty(piperModel, p.PiperModel), + WakeWord: wakeWord, + } + st.WhisperReady = st.WhisperBinReady && st.WhisperModelReady + st.PiperReady = st.PiperBinReady && st.PiperModelReady + st.STTReady = st.FFmpegReady && st.WhisperReady + st.TTSReady = st.PiperReady + if fe != nil { + st.FFmpegError = fe.Error() + } + if we != nil { + st.WhisperError = we.Error() + } + if wme != nil { + st.WhisperModelError = wme.Error() + } + if pe != nil { + st.PiperError = pe.Error() + } + if pme != nil { + st.PiperModelError = pme.Error() + } + return st } func (p *Pipeline) Transcribe(ctx context.Context, audio []byte, extension string) (string, error) { if len(audio) == 0 { return "", fmt.Errorf("leere Audiodatei") } - if _, err := exec.LookPath(p.FFmpegBin); err != nil { + ffmpegBin, err := resolveExecutable(p.FFmpegBin, "ffmpeg") + if err != nil { return "", fmt.Errorf("ffmpeg fehlt: %w", err) } - if _, err := exec.LookPath(p.WhisperBin); err != nil { + whisperBin, err := resolveExecutable(p.WhisperBin, "/usr/local/bin/whisper-cli", "/tools/whisper-cli", "whisper-cli") + if err != nil { return "", fmt.Errorf("whisper-cli fehlt: %w", err) } - if !fileExists(p.WhisperModel) { - return "", fmt.Errorf("Whisper-Modell fehlt: %s", p.WhisperModel) + whisperModel, err := resolveFile(p.WhisperModel, "/models/ggml-small.bin", "/app/models/ggml-small.bin") + if err != nil { + return "", fmt.Errorf("Whisper-Modell fehlt: %w", err) } if extension == "" { extension = ".webm" @@ -73,11 +123,11 @@ func (p *Pipeline) Transcribe(ctx context.Context, audio []byte, extension strin wavPath := wav.Name() wav.Close() defer os.Remove(wavPath) - cmd := exec.CommandContext(ctx, p.FFmpegBin, "-hide_banner", "-loglevel", "error", "-y", "-i", inPath, "-ar", "16000", "-ac", "1", "-c:a", "pcm_s16le", wavPath) + cmd := exec.CommandContext(ctx, ffmpegBin, "-hide_banner", "-loglevel", "error", "-y", "-i", inPath, "-ar", "16000", "-ac", "1", "-c:a", "pcm_s16le", wavPath) if out, err := cmd.CombinedOutput(); err != nil { return "", fmt.Errorf("ffmpeg: %w: %s", err, strings.TrimSpace(string(out))) } - cmd = exec.CommandContext(ctx, p.WhisperBin, "-m", p.WhisperModel, "-f", wavPath, "-l", p.WhisperLanguage, "-nt", "-np") + cmd = exec.CommandContext(ctx, whisperBin, "-m", whisperModel, "-f", wavPath, "-l", p.WhisperLanguage, "-nt", "-np") out, err := cmd.CombinedOutput() if err != nil { return "", fmt.Errorf("whisper: %w: %s", err, strings.TrimSpace(string(out))) @@ -90,11 +140,13 @@ func (p *Pipeline) Speak(ctx context.Context, text string) ([]byte, error) { if text == "" { return nil, fmt.Errorf("leerer Text") } - if _, err := exec.LookPath(p.PiperBin); err != nil { + piperBin, err := resolveExecutable(p.PiperBin, "/tools/piper", "/usr/local/bin/piper", "piper") + if err != nil { return nil, fmt.Errorf("piper fehlt: %w", err) } - if !fileExists(p.PiperModel) { - return nil, fmt.Errorf("Piper-Modell fehlt: %s", p.PiperModel) + piperModel, err := resolveFile(p.PiperModel, "/models/de_DE-thorsten-medium.onnx", "/app/models/de_DE-thorsten-medium.onnx") + if err != nil { + return nil, fmt.Errorf("Piper-Modell fehlt: %w", err) } outFile, err := os.CreateTemp("", "jarvis-tts-*.wav") if err != nil { @@ -103,7 +155,7 @@ func (p *Pipeline) Speak(ctx context.Context, text string) ([]byte, error) { outPath := outFile.Name() outFile.Close() defer os.Remove(outPath) - cmd := exec.CommandContext(ctx, p.PiperBin, "--model", p.PiperModel, "--output_file", outPath) + cmd := exec.CommandContext(ctx, piperBin, "--model", piperModel, "--output_file", outPath) cmd.Stdin = bytes.NewBufferString(text) if out, err := cmd.CombinedOutput(); err != nil { return nil, fmt.Errorf("piper: %w: %s", err, strings.TrimSpace(string(out))) @@ -111,6 +163,51 @@ func (p *Pipeline) Speak(ctx context.Context, text string) ([]byte, error) { return os.ReadFile(outPath) } +func resolveExecutable(configured string, fallbacks ...string) (string, error) { + candidates := append([]string{strings.TrimSpace(configured)}, fallbacks...) + seen := map[string]bool{} + var tried []string + for _, candidate := range candidates { + candidate = strings.TrimSpace(candidate) + if candidate == "" || seen[candidate] { + continue + } + seen[candidate] = true + tried = append(tried, candidate) + if path, err := exec.LookPath(candidate); err == nil { + return path, nil + } + } + return "", fmt.Errorf("nicht ausführbar/auffindbar (geprüft: %s)", strings.Join(tried, ", ")) +} + +func resolveFile(configured string, fallbacks ...string) (string, error) { + candidates := append([]string{strings.TrimSpace(configured)}, fallbacks...) + seen := map[string]bool{} + var tried []string + for _, candidate := range candidates { + candidate = strings.TrimSpace(candidate) + if candidate == "" || seen[candidate] { + continue + } + seen[candidate] = true + tried = append(tried, candidate) + if fileExists(candidate) { + return filepath.Clean(candidate), nil + } + } + return "", fmt.Errorf("Datei nicht gefunden (geprüft: %s)", strings.Join(tried, ", ")) +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + func fileExists(path string) bool { if strings.TrimSpace(path) == "" { return false diff --git a/internal/voice/voice_test.go b/internal/voice/voice_test.go new file mode 100644 index 0000000..334eb28 --- /dev/null +++ b/internal/voice/voice_test.go @@ -0,0 +1,65 @@ +package voice + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func makeExecutable(t *testing.T, dir, name string) string { + t.Helper() + path := filepath.Join(dir, name) + body := []byte("#!/bin/sh\nexit 0\n") + if runtime.GOOS == "windows" { + body = []byte("@exit /b 0\r\n") + } + if err := os.WriteFile(path, body, 0755); err != nil { + t.Fatal(err) + } + return path +} + +func TestStatusReportsDockerVoiceComponentsPrecisely(t *testing.T) { + dir := t.TempDir() + ffmpeg := makeExecutable(t, dir, "ffmpeg") + whisper := makeExecutable(t, dir, "whisper-cli") + piper := makeExecutable(t, dir, "piper") + whisperModel := filepath.Join(dir, "ggml-small.bin") + piperModel := filepath.Join(dir, "voice.onnx") + if err := os.WriteFile(whisperModel, []byte("model"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(piperModel, []byte("model"), 0644); err != nil { + t.Fatal(err) + } + + p := &Pipeline{FFmpegBin: ffmpeg, WhisperBin: whisper, WhisperModel: whisperModel, PiperBin: piper, PiperModel: piperModel} + st := p.Status("jarvis") + if !st.STTReady || !st.TTSReady || !st.WhisperBinReady || !st.WhisperModelReady { + t.Fatalf("unexpected status: %+v", st) + } + if st.WhisperBin != whisper || st.WhisperModel != whisperModel { + t.Fatalf("resolved paths missing from status: %+v", st) + } +} + +func TestStatusDistinguishesMissingWhisperModelFromBinary(t *testing.T) { + dir := t.TempDir() + ffmpeg := makeExecutable(t, dir, "ffmpeg") + whisper := makeExecutable(t, dir, "whisper-cli") + p := &Pipeline{FFmpegBin: ffmpeg, WhisperBin: whisper, WhisperModel: filepath.Join(dir, "missing.bin")} + st := p.Status("jarvis") + if st.STTReady { + t.Fatalf("STT must not be ready: %+v", st) + } + if !st.WhisperBinReady { + t.Fatalf("binary should be ready: %+v", st) + } + if st.WhisperModelReady { + t.Fatalf("model should be missing: %+v", st) + } + if st.WhisperModelError == "" { + t.Fatalf("expected model diagnostic: %+v", st) + } +} diff --git a/scripts/doctor.sh b/scripts/doctor.sh index 7086686..780e893 100644 --- a/scripts/doctor.sh +++ b/scripts/doctor.sh @@ -1,22 +1,37 @@ #!/usr/bin/env sh set -u -ok=1 -check_bin(){ if command -v "$1" >/dev/null 2>&1; then printf '[OK] %s\n' "$1"; else printf '[MISS] %s\n' "$1"; ok=0; fi; } -check_file(){ if [ -f "$1" ]; then printf '[OK] %s\n' "$1"; else printf '[MISS] %s\n' "$1"; ok=0; fi; } -check_bin go -check_bin ollama -check_bin "${VOICE_FFMPEG_BIN:-ffmpeg}" -check_bin "${VOICE_WHISPER_BIN:-whisper-cli}" -check_bin "${VOICE_PIPER_BIN:-piper}" -check_file "${VOICE_WHISPER_MODEL:-./models/ggml-small.bin}" -check_file "${VOICE_PIPER_MODEL:-./models/de_DE-thorsten-medium.onnx}" -printf '\nOllama models:\n' -if command -v ollama >/dev/null 2>&1; then - ollama list 2>/dev/null || printf '[WARN] Ollama daemon nicht erreichbar.\n' - printf '\nJARVIS nutzt OLLAMA_MODEL=%s und OLLAMA_EMBED_MODEL=%s.\n' "${OLLAMA_MODEL:-auto}" "${OLLAMA_EMBED_MODEL:-auto}" - printf 'Bei "auto" werden vorhandene Modelle selbständig erkannt.\n' - printf 'Context: num_ctx=%s · output=%s · planner_ctx=%s · history=%s · RAG=%s\n' "${OLLAMA_NUM_CTX:-16384}" "${OLLAMA_NUM_PREDICT:-1200}" "${OLLAMA_PLANNER_NUM_CTX:-8192}" "${JARVIS_HISTORY_TOKENS:-3200}" "${JARVIS_RAG_TOKENS:-4200}" -fi -printf '\nDebug trace: enabled=%s · dir=%s · max=%sMB · keep=%s\n' "${JARVIS_DEBUG_TRACE:-true}" "${JARVIS_DEBUG_DIR:-./data/debug}" "${JARVIS_DEBUG_MAX_MB:-25}" "${JARVIS_DEBUG_KEEP_FILES:-4}" -if [ "$ok" -eq 1 ]; then printf '\nJARVIS local stack ready.\n'; else printf '\nEinige Voice-Abhängigkeiten fehlen. Text/Home-Core kann trotzdem laufen.\n'; fi -exit 0 + +ok(){ printf '[OK] %s\n' "$*"; } +warn(){ printf '[!!] %s\n' "$*"; } + +check_bin(){ + label="$1"; bin="$2" + if command -v "$bin" >/dev/null 2>&1; then + resolved="$(command -v "$bin")" + ok "$label binary: $resolved" + ls -l "$resolved" 2>/dev/null || true + elif [ -x "$bin" ]; then + ok "$label binary: $bin" + ls -l "$bin" 2>/dev/null || true + else + warn "$label binary fehlt/nicht ausführbar: $bin" + fi +} +check_file(){ + label="$1"; f="$2" + if [ -f "$f" ]; then + ok "$label: $f ($(wc -c < "$f" 2>/dev/null || echo '?') bytes)" + else + warn "$label fehlt: $f" + fi +} + +printf '%s\n' '--- JARVIS Voice Doctor ---' +check_bin "FFmpeg" "${VOICE_FFMPEG_BIN:-ffmpeg}" +check_bin "Whisper" "${VOICE_WHISPER_BIN:-/usr/local/bin/whisper-cli}" +check_file "Whisper model" "${VOICE_WHISPER_MODEL:-/models/ggml-small.bin}" +check_bin "Piper" "${VOICE_PIPER_BIN:-/tools/piper}" +check_file "Piper model" "${VOICE_PIPER_MODEL:-/models/de_DE-thorsten-medium.onnx}" +check_file "Piper model config" "${VOICE_PIPER_MODEL:-/models/de_DE-thorsten-medium.onnx}.json" +printf 'LD_LIBRARY_PATH=%s\n' "${LD_LIBRARY_PATH:-}" +printf 'PATH=%s\n' "$PATH" diff --git a/scripts/setup-docker-voice.sh b/scripts/setup-docker-voice.sh new file mode 100644 index 0000000..b7c9bc3 --- /dev/null +++ b/scripts/setup-docker-voice.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +MODELS="$ROOT/models" +TOOLS="$ROOT/local-tools" +mkdir -p "$MODELS" "$TOOLS" + +download() { + local url="$1" out="$2" + if [[ -f "$out" && -s "$out" ]]; then + echo "[ok] vorhanden: $out" + return + fi + echo "[download] $url" + if command -v curl >/dev/null 2>&1; then + curl -fL --retry 3 --progress-bar "$url" -o "$out" + elif command -v wget >/dev/null 2>&1; then + wget -O "$out" "$url" + else + echo "Fehler: curl oder wget wird benötigt." >&2 + exit 1 + fi +} + +# Whisper model. whisper-cli itself is built into the Docker image since v7.6. +download "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin" "$MODELS/ggml-small.bin" + +# German Piper voice + sidecar config. +download "https://huggingface.co/rhasspy/piper-voices/resolve/main/de/de_DE/thorsten/medium/de_DE-thorsten-medium.onnx" "$MODELS/de_DE-thorsten-medium.onnx" +download "https://huggingface.co/rhasspy/piper-voices/resolve/main/de/de_DE/thorsten/medium/de_DE-thorsten-medium.onnx.json" "$MODELS/de_DE-thorsten-medium.onnx.json" + +if [[ ! -x "$TOOLS/piper" ]]; then + case "$(uname -m)" in + x86_64|amd64) asset="piper_linux_x86_64.tar.gz" ;; + aarch64|arm64) asset="piper_linux_aarch64.tar.gz" ;; + armv7l) asset="piper_linux_armv7l.tar.gz" ;; + *) echo "Piper-Binary für Architektur $(uname -m) bitte manuell unter $TOOLS/piper ablegen." >&2; exit 1 ;; + esac + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + archive="$tmp/piper.tar.gz" + download "https://github.com/rhasspy/piper/releases/download/2023.11.14-2/$asset" "$archive" + mkdir -p "$tmp/extract" + tar -xzf "$archive" -C "$tmp/extract" + piper_exec="$(find "$tmp/extract" -type f -name piper -perm -u+x | head -n 1 || true)" + if [[ -z "$piper_exec" ]]; then + echo "Piper konnte im Release-Archiv nicht gefunden werden." >&2 + exit 1 + fi + piper_dir="$(dirname "$piper_exec")" + cp -a "$piper_dir"/. "$TOOLS"/ + chmod +x "$TOOLS/piper" + echo "[ok] Piper installiert: $TOOLS/piper" +else + echo "[ok] Piper vorhanden: $TOOLS/piper" +fi + +cat <