@@ -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.
|
||||
|
||||
+24
-3
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
+4
-16
@@ -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:
|
||||
|
||||
@@ -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,'<strong>$1</strong>').replace(/`([^`]+)`/g,'<code>$1</code>').replace(/\n/g,'<br>');
|
||||
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=`<i></i> ${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=`<i></i> ${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?`<strong>${esc(ev.title)}</strong><p>${esc(fmtDay(parseDate(ev.start)))} · ${esc(ev.allDay?'Ganztägig':fmtClock(ev.start))}${ev.location?' · '+esc(ev.location):''}</p>`:'<strong>Keine Termine</strong><p>Dein Kalender ist frei.</p>';
|
||||
const task=tasks[0],taskEl=$('#cleanNextTask');if(taskEl)taskEl.innerHTML=task?`<strong>${esc(task.title)}</strong><p>${task.priority==='high'?'Hohe Priorität':'Offen'}${task.due?' · '+esc(fmtTime(task.due)):''}</p>`:'<strong>Keine offenen Aufgaben</strong><p>Du bist auf dem aktuellen Stand.</p>';
|
||||
const meal=nextMeal,mealEl=$('#cleanNextMeal');if(mealEl)mealEl.innerHTML=meal?`<strong>${esc(meal.title||meal.recipeTitle||'Geplante Mahlzeit')}</strong><p>${esc(fmtDay(parseDate(meal.date)))} · ${esc(mealName(meal.meal))}${meal.servings?` · ${meal.servings} Portionen`:''}</p>`:'<strong>Noch nichts geplant</strong><p>Plane eine Mahlzeit für heute oder morgen.</p>';
|
||||
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&&d<until}).length;$('#briefTasks').textContent=(state.tasks||[]).filter(t=>t.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(`<div class="day-cell ${i===0?'today':''}"><span>${d.toLocaleDateString('de-DE',{weekday:'short'}).toUpperCase()}</span><strong>${d.getDate()}</strong>${count?`<b>${count}</b>`:''}</div>`)}$('#weekRail').innerHTML=days.join('');const visible=events.filter(e=>{const d=parseDate(e.start);return d&&d>=today&&d<end}).slice(0,18);$('#agenda').innerHTML=visible.length?visible.map(e=>`<div class="agenda-item"><div class="agenda-time">${esc(e.allDay?'GANZTÄGIG':fmtClock(e.start))}</div><div><strong>${esc(e.title)}</strong><span>${esc(fmtDay(parseDate(e.start)))}${e.location?' · '+esc(e.location):''} · ${e.aiManaged?'JARVIS':'MANUAL'}</span></div><button class="icon-delete" data-delete-event="${esc(e.id)}">×</button></div>`).join(''):'<div class="empty">KEINE TERMINE IN DEN NÄCHSTEN 7 TAGEN</div>';$$('[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()<wakeArmedUntil&&text.length>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')}}
|
||||
|
||||
+119
-22
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+35
-20
@@ -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"
|
||||
|
||||
@@ -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 <<MSG
|
||||
|
||||
Voice-Dateien sind vorbereitet.
|
||||
Jetzt neu bauen/starten:
|
||||
docker compose build --no-cache jarvis
|
||||
docker compose up -d
|
||||
|
||||
Diagnose:
|
||||
docker compose exec jarvis /app/scripts/doctor.sh # falls Scripts gemountet/kopiert sind
|
||||
oder:
|
||||
docker compose exec jarvis sh -lc 'command -v whisper-cli; ls -lh /models/ggml-small.bin /tools/piper /models/de_DE-thorsten-medium.onnx'
|
||||
MSG
|
||||
Reference in New Issue
Block a user