Skip to content

Commit d908bc4

Browse files
alichherawallaUserclaude
authored
feat: on-device runtime (residency + modality queue), dictation, HF-download fix, image gen (#39)
* perf(imagegen): persistent sd-server + taesd fast-VAE + shared defaults Keep the stable-diffusion.cpp model resident across images (sd-server) so 2nd+ generations skip the ~13s Metal shader warmup + ~5s model reload, and add opt-in TAESD fast VAE decode (verified 1.47s vs ~10-16s full VAE at 768). Quality/steps are unchanged. Pure per-model defaults extracted to one shared source of truth (image-defaults.ts) used by both the resident-server and one-shot sd-cli paths. See docs/IMAGE_GEN_OPTIMIZATION.md for the full benchmark study (incl. ruled-out levers: upstream rebuild = no speedup, Winograd fork = unavailable, conv-direct = 9x slower). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(imagegen): record bake attempt status — runtime LoRA broken, merge works, GGUF handoff blocked Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(imagegen): karras schedule + correct few-step defaults for distilled models The crispness bug: distilled (Turbo/Lightning/DMD2) models were run on the default 'discrete' sigma schedule, which undercooks few-step sigmas -> smeared output; and turbo defaulted to 4 steps/cfg1 -> artifacts. Fix: distilled models now default to 8 steps / cfg 2 / dpm++2m / KARRAS / 512, taesd on; full models keep 28 steps / cfg 7 / discrete. Scheduler threaded through both the persistent sd-server (sample_params.scheduler) and one-shot sd-cli (--scheduler). Measured: crisp 5-star SDXL in ~12s warm at 512 on an M4. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(imagegen): finalize distilled default to 512/10/karras/full-VAE 512² (approved), 10 steps, cfg 2, dpm++2m, KARRAS, FULL VAE — ~30s warm at good quality on an M4. taesd is now OFF by default (it softens detail and BLANKS at 1024 — the tiny decoder overflows); it stays strictly opt-in via fastVae for a fast low-res draft. Only the two distilled models (dreamshaper-turbo, realvis-lightning) get this fast path; the 5 non-distilled models still run 28 steps until they get distilled variants. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(models): surface 'Fast' distilled models first + emerald tag Distilled few-step image models (tagged 'Fast' in the catalog: dreamshaper-turbo, realvis-lightning, z-image-turbo, sdxl-lightning) render in ~30s vs ~100s for the non-distilled ones. Sort them to the top of the model list (featureRank) and style the Fast tag in the emerald brand accent so it reads as the recommended quick pick. The 5 non-distilled models stay listed (get distilled variants later). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(imagegen): Fast/Quality tier toggle + single shared source of truth Adds a Fast/Quality tier toggle to the image composer: Fast = 512/~30s (default), Quality = model's native resolution (crisper, ~3x slower). Live progress bar + forming-preview already existed. Fixes a SOLID violation that was silently breaking the quality fix: the renderer had a DUPLICATE, stale copy of the per-model defaults (defaulting turbo to 4 steps -> rainbow artifacts) that overrode the main process. Collapsed both layers onto one shared module (src/shared/image-defaults.ts, imported by main + renderer), with a tier-aware resolveTier(). Production build verified (bundles into both). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(imagegen): address PR review — on-demand LLM resume, poll timeout, seed, port-kill, docs - CRITICAL (qodo): after a persistent-server image gen, llm.pause() left chat dead until 60s idle eviction. llm.init() now evicts the resident image server on-demand (via a hook, no layering violation) and never silently no-ops. - sd-server poll loop: add a stall watchdog (abort if no progress for 180s) so a hung server can't wedge generation forever. - sd-server: surface the server-resolved seed so a random (-1) request stays reproducible; fall back to the requested seed. Tested. - killOrphanOnPort: match our bundled binary's full path, not the bare name, so a user's own sd-server is never killed. - docs: remove em dashes (brand voice rule). - remove orphan duplicate src/main/image-defaults.ts (moved to src/shared). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(transcription): add Parakeet STT engine behind the transcription seam Add NVIDIA Parakeet (sherpa-onnx offline transducer, ONNX) as a second TranscriptionService alongside whisper.cpp, selected through a generic engine-selection abstraction. Whisper stays the default; Parakeet is opt-in and only used when its runtime (binary + model) is actually installed, otherwise the selection falls back to whisper so a missing engine can never break dictation or file transcription. Core, engine-agnostic paths (voice:transcribe IPC, RAG/file audio ingest) route through select.ts (getActiveTranscription) rather than importing whisper-cli directly, so a Parakeet model selection is honored. whisper-cli guards the shared active-transcription slot so a Parakeet ONNX id is never handed to whisper. - src/main/transcription/parakeet-cli.ts — ParakeetCliTranscription engine - src/main/transcription/select.ts — pure engine-selection + whisper fallback - src/main/transcription/whisper-cli.ts — ggml-only guard on the shared active slot - @offgrid/models — ModelEntry.engine field + two Parakeet catalog entries (v2/v3) - src/main/ipc.ts, src/main/rag/extractors.ts — route via the active engine - scripts/fetch-parakeet.sh + release.yml step — CI-stage the runtime (no-op until SHERPA_ONNX_URL/PARAKEET_MODEL_URL are set), .gitignore the staged dir - unit tests: parakeet-cli (arg/parse/match/availability) + select (fallback logic) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(models): don't tag Z-Image Turbo as 'Fast' Despite the 'Turbo' name, Z-Image is a FLUX-class diffusion transformer (DiT + Qwen3-4B text encoder + FLUX VAE) — heavy and slow on Apple Silicon via ggml, not a few-step SDXL distill. Reserve the 'Fast' tag for models verified fast on-device (dreamshaper-turbo, realvis-lightning). Fixed in source + the committed dist build. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(imagegen): auto-generate when chat asks for an image ('draw a dog') A natural-language image request in chat (draw/sketch/paint/render X, or generate/create an image/picture of X) now routes to the image generator instead of the text model refusing 'I can't draw images'. Conservative heuristic (pure + tested): bare visual verbs count; weak verbs need an image noun; non-visual idioms ('draw a conclusion/attention') are excluded. Explicit image-mode is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(imagegen): show image progress UI on auto-routed 'draw a dog' requests When a chat message auto-routes to image generation, the global mode stays 'ask', so the image progress/warm-up UI (gated on mode==='image') didn't show — the user saw a chat-style loading state instead. Add a generatingImage flag so the progress bar, forming preview, and Stop button engage whenever an image is generating, regardless of mode. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(imagegen): composer defaults to the ACTIVE image model (not a name heuristic) The image composer's model picker defaulted via a name heuristic that matched the parked Core ML dir (coreml-sdxl-base) on 'sdxl' — so it mismatched the active model (DreamShaper), and because Core ML is non-distilled, FAST showed 28 steps and the FAST/QUALITY toggle did nothing. Now imageGenStatus() returns the active model and the composer defaults its picker to it, matching the Active-models panel; the fallback skips Core ML. FAST/QUALITY now behave (512/10 vs 1024/12 on a distilled model like DreamShaper). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(imagegen): remove redundant FAST/QUALITY toggle Size and Steps are already exposed as their own controls, so a FAST/QUALITY tier toggle that just sets those same two values was redundant and confusing (and made non-distilled models show '28 steps in FAST'). Removed the toggle + the resolveTier helper/tests. The model's sensible defaults (512/10 for distilled) still seed the Size/Steps controls; the user adjusts them directly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(imagegen): hide number-input spinner on the Steps field Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(imagegen): route txt2img through one-shot sd-cli, drop resident server The persistent sd-server held ~4GB of image weights resident alongside the ~5GB chat model -> memory contention on 16GB machines -> laptop hangs + corrupted (near-blank) output, and it was never verified end-to-end in-app. Route all full-checkpoint txt2img through the one-shot sd-cli path (same karras/defaults), which frees the model on exit. Verified: DreamShaper 512/10/karras produces a crisp image in ~29s with no resident memory pressure. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(imagegen): RAM-aware image models — Light Q4 DreamShaper + Recommended badge + smart default Offer both DreamShaper XL v2 Turbo quants as selectable models and make the image experience RAM-aware: - Catalog: add the Q4_K "Light" DreamShaper entry (distinct id + filename, ~2.8GB) alongside the full Q8 one; tag Q8 [Versatile,Fast], Q4 [Versatile,Fast,Light]. Distinct ids + filenames track independently through download/listInstalled/active-tracking (active keys on id OR primary filename). - 'Light' tag rendered in the model list (emerald-300 outline, distinct from the emerald 'Fast' tag). - "Recommended for you" badge (filled emerald) on the image model that best fits this machine's RAM, sorted to the very top (above 'Fast'). Pure helper recommendedImageModelId(models, ramGb) in @offgrid/models — keys off the 'Light' tag + kind==='image', not a model name; Light on <=16GB, full above. - Smart RAM-aware default in resolveModel(): prefers DreamShaper and picks Q4 on <=~17GB else Q8, only when the user hasn't set an explicit pick. Pure helper defaultImageModelFilename() in src/main/image-default.ts. - Rebuilt packages/models/dist (tsup) with the new exports. Unit tests: recommend-image.test.ts (9) + image-default.test.ts (4), both under src/main/__tests__ so the app's vitest gate runs them. Gates: tsc node+web clean, 182 tests pass, production build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(models): Light (Q4) variant for every image model + versatile-first recommend Adds a Light (Q4_K) sibling for all 7 offgrid image models (realvis, realvis- lightning, juggernaut, animagine, illustrious, pony — dreamshaper Q4 already added), each ~35% less memory so it runs on a 16GB Mac. Every model is now listed as both full (Q8) and Light (Q4), both selectable. recommendedImageModelId now prefers the 'Versatile' all-rounder (DreamShaper) so the Recommended badge is stable regardless of how many Light variants exist / their catalog order. Rebuilt @offgrid/models dist. Tests: versatile-first pick + every-family-has-Light guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(models): 'Fast' only on the distilled Light (Q4) variants 'Fast' now means few-step AND memory-safe. Removed it from the full Q8 distilled models (dreamshaper/realvis-lightning Q8) — they're few-step but ~4.2GB pegs a 16GB Mac, so advertising them as Fast is misleading. Kept off the non-distilled Light models (juggernaut/animagine/etc Q4) — those are low-memory but still 28-step slow. Net: Fast = the Q4 distilled ones (dreamshaper-Q4, realvis-lightning-Q4). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(imagegen): add Light-model sample images + Storage tab download counts Sample generations (512, app defaults) for the Light (Q4) models — dreamshaper, realvis, realvis-lightning, juggernaut, illustrious — verified crisp, for the PR showcase. Also: Storage tab label now shows live installed count + downloading/ failed badges. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(dictation): use native audio rate, not forced 16kHz (fixes stretched/garbled recordings) Forcing new AudioContext({ sampleRate: 16000 }) made ctx.sampleRate report 16000 while the worklet was fed native-rate (44.1kHz) frames in this Electron build, so PCM got mislabeled 16k: audio played ~2.75x too slow (14s clip showed 39s) and transcription heard garbled slow-motion -> 'No speech detected'. Use the native context rate reported honestly; the main process already resamples to 16k via ffmpeg before transcription (alreadyWav16k=false when rate != 16000). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(dictation): resample to 16kHz IN the worklet — fixes stretched audio, keeps live transcription Root cause: the recorder streamed raw PCM labeled with ctx.sampleRate, but on headphones (48/88.2kHz) the reported rate didn't match the delivered samples, so recordings were saved at the wrong rate -> 2-3x time-stretch -> garbled -> 'No speech'. Instead of trusting the (lying) context rate, the AudioWorklet now downsamples every frame to a FIXED 16kHz using its authoritative global sampleRate (linear interp, fractional position carried across blocks). We always stream + label 16kHz, so it's immune to any device rate — and LIVE interim transcription is preserved (still streaming PCM, just correct-rate at the source). Verified the resampler preserves duration exactly (48k->16k = 3:1, no stretch). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(dictation): revert in-worklet resampling; capture native + ffmpeg-resample The in-worklet 16k downsampler had no anti-aliasing filter, which degraded the audio (harsh/muffled) and wrecked ASR (sparse/garbled transcripts) even though the duration was correct. Revert to native-rate passthrough + honest ctx.sampleRate; main resamples to 16k with ffmpeg's proper anti-alias filter (the path that transcribes cleanly). Saved WAV stays native rate (correct duration + full quality). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(transcription): decode 16k PCM WAV to Float32 (decodeWavPcm16) Lets the dictation VAD/speech-ratio gate run on ffmpeg-decoded audio without a second pass. Round-trip + edge-case tests (header-only, odd trailing byte). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(dictation): capture with MediaRecorder, ship blob + interim to main Renderer records via MediaRecorder (correct rate by construction) instead of a raw-PCM AudioWorklet. On stop it ships the full webm/opus blob for the final transcript; between 1s timeslices it ships the growing recording for live interim. AudioContext now drives only the level meter (AnalyserNode). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(modality-queue): priority lock + memory-admission (Phase 0) A single owner that serializes heavy model work by priority so two big models never sit resident on 16GB unified memory (the swap/beachball fix). Pure policy (tiers, FIFO, tier-3-yields, tier-1-coexists) is unit-tested; the async lock + model-eviction hooks are the thin IO layer. Engines self-register as evictable; the queue never imports them. 21 tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(imagegen): route image gen through the ModalityQueue (evicts LLM) Replaces the ad-hoc inline llm.pause()/resume() bracketing with modalityQueue.run({ tier: 2, evicts: ['llm'] }, ...). The LLM registers itself as evictable at startup; warm-back happens once in the generateImage wrapper. Behavior-preserving with the queue enabled (default). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(transcription): resident whisper-server engine behind the seam A resident STT server (model stays warm) so live-interim ticks don't reload the model each call. Added as 'whisper-resident' in select.ts, degrading to one-shot whisper-cli when the binary isn't built (same pattern as Parakeet). Includes build-whisper.sh mirroring build-llama.sh's gates (minos/rpath/foreign-dep). NOT yet verified working: the binary needs a real CI build + on-device model-load check before use (see the agent's remaining-to-verify list). Pure helpers tested. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(runtime): residency contract (dynamic vs resident) per modality Shared foundation for per-engine on-demand/in-memory mode: settings key + typed accessor + pure normalize (defaults match today's behavior so nothing changes until toggled). Engines read getResidencyMode('llm'|'image'|'stt'|'tts') to decide whether to stay warm (queue evicts on contention + re-warms) or free themselves after each job. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(llm): dynamic vs resident mode via queue evict + mode-aware re-warm The ModalityQueue now re-warms an evicted engine when its job finishes, and the re-warm is mode-aware (per runtime-residency): resident reloads immediately (warm next chat); on-demand just releases the pause block (llm.releasePause()) so the server stays down and lazily respawns on next use, freeing its RAM meanwhile. Correctness: the queue no longer tracks exact residency — it ALWAYS evicts a declared id (evict is idempotent when down), so an on-demand engine that lazily reloaded can't slip through and leave two models resident (the beachball this whole system prevents). Image gen's inline llm.resume() is removed (queue owns it). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(runtime): single ManagedRuntime seam for residency (SOLID/DRY) One place turns residency mode into behavior (runtime-manager.registerRuntime + warmActionForMode); every engine implements the same ManagedRuntime interface and registers identically. If residency works for one engine it works for all; if it breaks, all break — no per-engine drift. LLM is the first onto the seam (llm.runtime), replacing its bespoke queue closure in index.ts. Mode-reader is injectable so the wiring is tested against the REAL queue with a REAL runtime (no logic mocks). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(tts): dynamic vs resident via the shared ManagedRuntime seam Adds a persistent 'serve' worker mode (loads Kokoro once, answers many synth requests over NDJSON stdin/stdout) for resident mode; on-demand keeps the one-shot per-call worker. synthesize() routes by getResidencyMode('tts'); tts joins the queue through registerRuntime(ttsRuntime) exactly like the LLM — evict kills the warm worker, warm preloads it, release ensures none lingers. NOT yet verified on-device (Kokoro audio + persistent-worker IPC need a real run); typechecks clean, worker syntax checked, existing tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(image): dynamic vs resident via the shared ManagedRuntime seam Resident mode re-enables the warm sd-server fast path (~45s cold -> ~7s warm) for eligible full-checkpoint txt2img (no LoRA/init-image/Z-Image/CoreML). It was removed before for 16GB memory contention; now SAFE because the queue evicts the LLM before image gen AND image registers as a ManagedRuntime (evict stops the server so another modality can reclaim RAM). Default stays on-demand = the exact one-shot sd-cli path as today, so nothing changes unless the user opts in. NOT yet verified on-device (resident sd-server output quality on 16GB is the exact thing flagged when it was removed); typechecks + build + tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(stt): dynamic vs resident via the shared ManagedRuntime seam STT joins the seam like every other engine. residentAwareEngine() maps the chosen dictation engine + STT residency mode to the engine to request: resident routes whisper to the warm whisper-server (degrading to one-shot whisper-cli when the binary isn't built), on-demand uses whisper-cli; Parakeet stays one-shot CLI in both modes (no resident server — honest, no false 'resident'). The controller resolves this once (sttEngine()) for all three transcribe sites. sttRuntime.evict stops the whisper-server so another modality can reclaim RAM. Resident STT only truly stays warm once whisper-server is CI-built + verified (tracked separately); until then it degrades to on-demand. Typechecks+build+tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(settings): per-engine residency UI + residency integration tests Adds a core 'Model memory' Settings section with an in-memory/on-demand toggle per engine (chat, image, dictation, TTS), wired through runtime:residency:get/set IPC + preload. Backend already reads these (runtime-residency); this lets the user set them. Tests (real objects, no logic mocks): - residency-integration.test.ts drives the REAL ModalityQueue + REAL registerRuntime seam with real in-memory engines: asserts resident reloads / on-demand stays down / runtime mode-flip / the always-evict correctness guard / strict heavy-job serialization. - Verified the resident TTS 'serve' worker end-to-end out-of-band (real subprocess, real NDJSON, real WAV) — protocol confirmed. 469 tests pass, 3 typechecks + production build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(models): register free-form HuggingFace downloads as installed MiniCPM (and any HF search-bar model) downloaded fine but landed in 'unused files' and never became a usable option: downloadModel streamed files to disk but, unlike importLocalModel, never RECORDED the model, so listInstalled + the orphan sweep never knew about it (and the card reset to Download on completion). Fix: a downloaded-models registry keyed by the HF repo id (so the existing 'CATALOG.find(id) ?? resolveHuggingFaceModel(id)' lookups re-resolve it for activate/delete with zero new branching). downloadModel records a non-catalog download on success; listInstalled, getStorageInfo (known + models list), getCatalog, and deleteModel all consult it; the ModelsScreen card drops the !isHf gate so a downloaded HF model flips Download -> Use. Verified against a real temp dir with real files (downloaded-models.test.ts, 7 tests) INCLUDING a reproduction of the pre-fix bug (unregistered files = not installed + unprotected -> orphaned). Not yet run against a live HF fetch of the specific minicpm5 repo (needs the app + network). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(residency): lock the chat model to in-memory (screen replay depends on it) The chat LLM can no longer be set on-demand: the always-on screen-replay/capture pipeline distills frames through it continuously, so on-demand would leave it evicted after any image gen and thrash-reload the ~5GB model. Enforced in the contract (normalizeResidency coerces + setResidencyMode ignores a locked modality, so a stale/hand-edited on-demand can never take effect) AND in the UI (the Chat model toggle is disabled, shows 'in-memory (required)', with a why tooltip). The queue still evicts it MOMENTARILY during a competing heavy job then re-warms it - that's contention handling, not an on-demand choice. Other engines stay togglable. 478 tests pass (added lock coverage; integration on-demand cases moved to an unlocked modality since llm-on-demand is now correctly impossible), build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(queue): interactive chat runs as Tier 2 (outranks background replay) Route the user-facing rag:chat generation turn through modalityQueue.run({tier:2, label:'chat'}) so foreground chat registers as foreground and the Tier-3 background screen-replay distill yields to it. Chat evicts nothing (it runs ON the 'llm' engine); it's a peer of image gen (also tier 2) - during image gen the LLM is evicted so chat waits anyway, which is consistent. The abort controller now registers BEFORE queuing so a cancel while the turn is still waiting is honored. Internal/background llm.chat calls (summaries, extraction, intent) stay unqueued. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(chat): always-visible Image toggle + 256 image size - Image toggle chip now lives in the composer row next to Thinking (enabled by default), so image mode no longer requires the + dropdown - clicking it forces image generation for the next prompt; the × / re-click returns to chat. - Add 256 to the image Size options (fast low-res draft; valid multiple-of-64 dimension, no downstream clamp). Rough on SDXL models, fine for quick drafts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(review): WAV data-chunk scan, chat evicts resident image, TTS lifecycle, em dash Review findings addressed: - decodeWavPcm16 scanned the RIFF chunk list for 'data' instead of assuming byte 44. The bundled ffmpeg writes a LIST/INFO (encoder tag) chunk before 'data' (PCM starts at byte 78), so the fixed-44 read fed the VAD/silence gate loud garbage and could drop a real transcript. Regression test added for the LIST-chunk layout. - Chat jobs now evict 'image': a resident image server was never freed before a chat turn (nothing declared evicts:['image']), so a resident image + the LLM could coexist on 16GB - the exact OOM the queue exists to prevent. - Resident TTS worker gained idle-evict (5m) + onHostQuit + a per-request timeout; it previously lived for the whole session (nothing evicts 'tts') and a hung worker wedged 'busy' forever. - Settings residency tooltip: em dash -> ' - ' (CLAUDE.md copy rule). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: User <user@Users-MacBook-Pro-174.local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6aee58a commit d908bc4

75 files changed

Lines changed: 4700 additions & 951 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/release.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,15 @@ jobs:
129129
mkdir -p resources/bin
130130
cp scripts/dictation-hotkey/dictation-hotkey resources/bin/dictation-hotkey
131131
chmod +x resources/bin/dictation-hotkey
132+
# Stage the Parakeet STT runtime (sherpa-onnx CLI + ONNX model) into
133+
# resources/bin/parakeet. Additive: with SHERPA_ONNX_URL / PARAKEET_MODEL_URL
134+
# unset this is a no-op and transcription stays on whisper, so it can't break a
135+
# release. Set those vars once the exact sherpa-onnx build + Parakeet export are pinned.
136+
- name: Stage Parakeet STT runtime (optional)
137+
env:
138+
SHERPA_ONNX_URL: ${{ vars.SHERPA_ONNX_URL }}
139+
PARAKEET_MODEL_URL: ${{ vars.PARAKEET_MODEL_URL }}
140+
run: bash scripts/fetch-parakeet.sh
132141
# Private pro source into pro/ → __OFFGRID_PRO__ true. The checkout action
133142
# injects the token as an http.extraheader (never in a clone URL) and scrubs
134143
# it afterwards — safer than embedding ${TOKEN} in a git URL.

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ electron/accessibility/ocr
2626
# them. Only the heavy optional runtimes we don't ship are excluded.
2727
resources/bin/coreml-sd/
2828
resources/bin/mflux/
29+
# Parakeet STT runtime — CI-staged via scripts/fetch-parakeet.sh (like mflux/coreml-sd),
30+
# not committed. Devs stage it locally with the same script.
31+
resources/bin/parakeet/
2932
# Gateway probe scratch output
3033
.gateway-probe/
3134
# TS build cache

docs/IMAGE_GEN_OPTIMIZATION.md

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# Image generation performance - findings & roadmap (M4, animagine-xl-4.0 Q8_0)
2+
3+
All numbers measured on an Apple M4, 16GB, generating with the bundled
4+
stable-diffusion.cpp (`sd-cli` / `sd-server`), model `animagine-xl-4.0-Q8_0.gguf`
5+
(a full, non-distilled SDXL checkpoint). Prompt: an anime portrait, cfg 7,
6+
dpm++2m, `--diffusion-fa`.
7+
8+
## The core problem
9+
10+
Two independent costs stack up, and only the second is quality-optional:
11+
12+
| Steps | Quality | Wall clock (768²) |
13+
|------:|---------|-------------------|
14+
| 4 | blank / blob (unusable) | ~19s |
15+
| 8 | garbage (rainbow banding) | ~47s |
16+
| 12 | usable | ~64s |
17+
| 20 | good (the target) | ~105s |
18+
19+
- **Sampling (UNet) dominates:** ~4.2-4.6 s/step at 768² cfg 7. 20 steps ≈ 92s.
20+
This is the wall. It's the `ggml_conv_2d` operator - the 2D convolutions in the
21+
UNet are the primary bottleneck (see arXiv 2412.05781).
22+
- **VAE decode:** ~10-16s at 768² with the full VAE (it falls off the fast Metal
23+
path at ≥768; at 512² it's ~0.2s).
24+
25+
## Metal IS engaged (not the problem)
26+
27+
Proven from `sd-server`'s own log (`ggml_metal_init: picking default device:
28+
Apple M4`, weights loaded as `VRAM 3996 MB`), and independently: a 20-step run
29+
reported `real 105s` but `user 1.16s` - the CPU was idle-waiting on the GPU. If
30+
this were CPU compute, user time would be in the hundreds of seconds. The
31+
bottleneck is **ggml's generic Metal conv kernels**, ~10× slower than Apple's
32+
hand-tuned kernels (Draw Things) or the ANE - NOT a missing Metal path.
33+
34+
## Why mobile feels faster
35+
36+
iOS does SDXL at ~0.4 s/step on the ANE via Core ML; desktop sd.cpp Metal is
37+
~4.2-4.6 s/step. ~10× gap, and it's kernel quality, not the compute unit. (A
38+
second-opinion analysis noted that on desktop-class M-chips a well-tuned GPU path
39+
actually beats the ANE - so ANE is not the desktop ceiling either.)
40+
41+
## Levers evaluated
42+
43+
| Lever | Result | Status |
44+
|-------|--------|--------|
45+
| **Persistent `sd-server`** (keep model resident) | Warm images skip the ~13s Metal shader warmup + ~5s model reload | **DONE** - `src/main/sd-server.ts`, wired into `imagegen.ts`, tested |
46+
| **taesd fast VAE** (`--taesd taesdxl`) | VAE decode 1.47s vs ~10-16s (verified live, non-black) | **DONE** - opt-in `fastVae` param, wired both paths, tested. Needs `taesdxl.safetensors` in models dir |
47+
| **Rebuild from latest upstream** (6314af4 vs bundled 92a3b73) | 4.15-4.5 s/step - NO speedup. Same generic conv kernels | **Ruled out** for perf (would still bring newer model support) |
48+
| **Winograd conv fork** (arXiv 2412.05781, `SealAILab/stable-diffusion-cpp`, claimed 3-4.79× SDXL on Metal) | Repo is 404 / org gone - not publicly available | **Unavailable**; implementing Winograd in ggml ourselves is a major effort |
49+
| **`--conv-direct`** flags | 9× SLOWER (33 s/step) - ggml's direct conv is worse | **Rejected** |
50+
| **f16 instead of q8_0** | Untested (needs ~13GB f16 GGUF, not in our repo) | **Open** - may cut per-step (no dequant); worth a benchmark if a file is produced |
51+
| **Fewer steps** on the full model | Unusable below ~12 steps | **Rejected** (quality) |
52+
| **Distilled model** (Lightning/DMD2) | Not yet produced | **Open - the real quality-preserving speed answer** |
53+
54+
## Net effect of what shipped tonight
55+
56+
A warm 20-step 768² image: ~105s → persistent server removes ~5-18s (load +
57+
warmup on 2nd+ images) and taesd removes ~9-14s (VAE) → **roughly ~80s warm.**
58+
Meaningful, but NOT iOS-class. The ~90s of UNet sampling is a hard floor with
59+
ggml's Metal kernels.
60+
61+
## The honest path to iOS-class (<10s)
62+
63+
The data points to one conclusion: **full animagine at full quality will not hit
64+
<10s on this engine.** To get there without changing engines you need a
65+
**distilled SDXL model** (SDXL-Lightning / DMD2 / a Lightning-merged animagine)
66+
that looks good at 4-8 steps, run at **512²** (cfg 1, euler ≈ 0.86 s/step) with
67+
**taesd** and the **warm persistent server**:
68+
69+
- 4-step distilled @ 512² ≈ 4 × 0.86s + ~0.3s taesd + warm ≈ **~5s**.
70+
71+
That is the same shape as the mobile pipeline (few-step model, small size, fast
72+
decode). The remaining work is producing/hosting a distilled GGUF (a Lightning
73+
LoRA can't be merged into our quantized weights - needs an f16 base to merge then
74+
re-quantize) and wiring it as the "Fast" image model, keeping full animagine as a
75+
"Max quality" option.
76+
77+
## Follow-ups
78+
79+
1. Produce a distilled anime SDXL GGUF (Lightning/DMD2) → ship as the Fast default.
80+
2. Add `taesdxl.safetensors` / `taesd.safetensors` to the model download catalog,
81+
and a "Fast VAE" toggle in image settings that sets `fastVae`.
82+
3. Benchmark an f16 animagine GGUF for per-step (only if a file is produced).
83+
4. Validate taesd decode fidelity at a proper step count (20) vs the full VAE.
84+
85+
## Bake attempt - status (blocked on a format handoff, NOT on the concept)
86+
87+
Tried to bake SDXL-Lightning into animagine to ship a few-step q8 Fast model:
88+
89+
- **sd.cpp's runtime LoRA for SDXL is broken.** LCM *and* Lightning both report
90+
`2364/2364 tensors applied` but produce corrupted (banded) output - proven across
91+
q8 AND f16-GGUF, Metal AND CPU, with/without flash-attn. Same models with no LoRA
92+
are clean. So a runtime LoRA is a dead end here; distillation must be baked into the
93+
weights.
94+
- **The merge works:** diffusers `fuse_lora` correctly bakes Lightning into animagine.
95+
- **Blocker = merged weights → sd.cpp-loadable GGUF.** Both routes failed:
96+
(a) diffusers→`convert_diffusers_to_original_sdxl.py` single-file → sd.cpp rejects
97+
the tensor layout (`... not in model metadata`); (b) sd.cpp `-M convert` on the
98+
diffusers folder writes a GGUF but then fails `get sd version from file` on load
99+
(even on the build that wrote it).
100+
- **Next step (highest confidence):** kohya-ss `sdxl_merge_lora.py` merges into the
101+
ORIGINAL single-file checkpoint (preserves exact tensor keys the working q8 uses) →
102+
sd.cpp converts that → loadable. Needs re-downloading the f16 base (~6.9GB, deleted
103+
to reclaim disk). Kept: `loras/{sdxl-lightning-8step,lcm-lora-sdxl}.safetensors`,
104+
`taesdxl.safetensors`.
105+
- **Parallel bet:** MLX-for-SDXL spike - MLX (MIT, already bundled as mflux) may be
106+
faster AND apply LoRAs correctly, sidestepping both the slow kernels and the sd.cpp
107+
LoRA bug. Verdict pending.
599 KB
Loading
593 KB
Loading
595 KB
Loading
498 KB
Loading
506 KB
Loading

packages/models/dist/adapters/node.d.mts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { D as DownloadBridge } from '../types-C3JiELTS.mjs';
1+
import { D as DownloadBridge } from '../types-Dbo7SGxu.mjs';
22

33
declare class NodeDownloadBridge implements DownloadBridge {
44
private readonly modelsDir;

packages/models/dist/adapters/node.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { D as DownloadBridge } from '../types-C3JiELTS.js';
1+
import { D as DownloadBridge } from '../types-Dbo7SGxu.js';
22

33
declare class NodeDownloadBridge implements DownloadBridge {
44
private readonly modelsDir;

0 commit comments

Comments
 (0)