Skip to content

Release v3.0.0: installer, UI rework, Qwen3, Audio2Face - #23

Merged
fagenorn merged 25 commits into
mainfrom
dev
Apr 20, 2026
Merged

fagenorn merged 25 commits into
mainfrom
dev

Conversation

@fagenorn

Copy link
Copy Markdown
Collaborator

Summary

Merges dev into main for the v3.0.0 release.

Rollup of:

Plus direct-to-dev commits: self-contained publish, OpenAI-compat endpoint validation, ConversationSession / PhonemizerG2P decomposition, CSharpier v1.0.0 formatting, test expansion.

Full changelog goes on the GitHub release after the tag workflow lands.

Test plan

  • All four source PRs green on their own
  • dotnet test src/PersonaEngine/PersonaEngine.sln --configuration Release passes on dev
  • Release workflow smoke-tested end-to-end on dev
  • Tag v3.0.0 on main after merge → release workflow produces zip

fagenorn added 25 commits April 22, 2025 12:45
Able to get around 50% performance gain using IOBindings and just generally reducing allocations.

BEFORE
Average: 553ms
Min:     486ms
Max:     775ms
Median:  529ms

AFTER
Average: 358ms
Min:     303ms
Max:     455ms
Median:  349ms
…only taking into account first LLM token

Now the LLM tracking stops when the first applicable sentences to start TTS (In audio mode) has been generated.
…borator delegation

- Extract event processing loops to ConversationSession.EventProcessing.cs
- Extract state action methods to ConversationSession.StateActions.cs
- Rewrite core to slim fields/ctor/lifecycle only
- Wire up TurnMetricsTracker and TurnPipelineCoordinator collaborators
- Update ConfigureStateMachine to use _pipeline.CurrentTurnId
- Fix bug: ProcessOutputEventsAsync now completes _outputChannelWriter (was _inputChannelWriter)
…/Synthesis directory

Extract 5 focused collaborators from PhonemizerG2P (840 → 68 lines):
- TextPreprocessor: markdown-link parsing, feature extraction, subtokenization
- TokenAligner: bidirectional alignment and feature application
- TokenRestructurer: token conversion, folding, retokenization, merging
- PhonemeResolver: phoneme lookup, subtoken resolution, stress processing
- RetokenizedItem: type-safe discriminated union replacing List<object>

Reorganize flat Synthesis/ directory (52 files) into 4 subdirectories:
- Engine/: TTS orchestration and caching
- Audio/: Kokoro synthesis, voice data, audio filters
- TextProcessing/: normalization and sentence segmentation
- Phonemization/: G2P pipeline, lexicon, POS tagger, fallback

Add test project with 44 characterization tests to verify behavior preservation.
…ory layout

Bundle CUDA/cuDNN runtime DLLs with the application so users no longer
need to manually install CUDA Toolkit and copy cuDNN files. Native DLLs
are organized into a native/ subfolder via a post-publish MSBuild target,
with SetDllDirectory used at startup to ensure Windows finds them.

- Add CUDA/cuDNN DLL bundling via Resources/native/cuda/ (gitignored)
- Enable single-file self-contained publish for Release config
- Add post-publish target to move loose native DLLs into native/
- Use kernel32 SetDllDirectory to add native/ to DLL search path
- Place custom native libs (Live2D, Rust, etc.) directly into native/
- Add StartupValidator that runs before DI container build, checking
  GPU, CUDA, required models, espeak-ng, LLM config, Live2D, and
  prompt files with actionable error messages via Serilog
- Initialize Serilog and suppress ONNX Runtime warnings before any
  sessions are created (move OrtEnv log level to Program.cs)
- Change KokoroAudioSynthesizer ONNX session log level from WARNING
  to ERROR to suppress noisy Memcpy node warnings
- Add SelfContained and RuntimeIdentifier to Release publish config
- Apply CSharpier formatting to touched files
- StartupValidator now hits /models endpoint to verify reachability
- Checks configured model exists and lists available models on mismatch
- Validates both Text and Vision endpoints with API key auth
- Console waits for keypress on validation failure so errors are readable
- Fix VisionEndpoint passed as model name in AddLLM (copy-paste bug)
- Guard against empty VisionEndpoint in DI registration
* feat: add SentenceId property to AudioSegment for streaming chunk grouping

* feat: stamp SentenceId on AudioSegments in TtsOrchestrator

* fix: propagate SentenceId through AudioFilterPipeline

* test: add sentence-scoped lip-sync tracking tests

* test: add failing TDD tests for sentence-scoped lip-sync tracking

* fix: sentence-scoped phoneme tracking for streaming TTS lip-sync

* fix: restore _isStarted guards and make testable via internal

* style: format with CSharpier

* test: add integration tests for Qwen3 streaming CTC + phoneme enrichment + lip-sync pipeline

* feat: cliff detection in ConvertCtcToTokens to truncate unspoken words

* refactor: remove proportional estimate, use CTC-only timing path

* fix: only replace lip-sync phonemes when CTC covers more time

* test: add time-coverage guard tests for lip-sync

* style: format with CSharpier

* chore: update NuGet packages

* refactor: replace ModelType enum with type-safe nested ModelId system

* refactor: introduce TTS engine abstraction layer

* refactor: migrate Kokoro to ISentenceSynthesizer

* feat: add Qwen3-TTS sentence synthesizer and GGUF engine

* refactor: enhance RVC with RMVPE pitch estimation and audio preprocessing

* refactor: extract IForcedAligner interface and enhance subtitle system

* feat: update UI for new Qwen3 TTS + updated imGUI

* refactor: wire up multi-engine DI, text processing, and audio filters

* feat: update app config and DLL loading

* test: clean up Qwen3 tts test

* chore: delete stale refactor planning docs

* test: add subtitle tests

* feat: add top-P (nucleus sampling) to Qwen3 sampler

The TopP config was plumbed through options and UI but never applied
during sampling. Add ApplyTopP after top-K filtering. Guarded by TopP < 1.0 so default
behavior (TopP=1.0) is unchanged.

* refactor: internalize Kokoro audio synthesizer and clarify type names

Delete IAudioSynthesizer interface (single implementation, Kokoro-specific).
KokoroAudioSynthesizer is now internal and owned by KokoroSentenceSynthesizer
rather than registered in DI, matching how Qwen3 owns its engine internally.
Rename AudioData → KokoroSynthesisResult and VoiceData → KokoroVoiceEmbedding
to eliminate generic naming for Kokoro-specific types.

* fix: extract KokoroTokenConverter — fix buffer corruption and off-by-one EOS bug

* fix: extract AudioSilenceTrimmer to Audio namespace — use MathF.Abs in hot loop

* fix: correct ArrayPool lifetime, dispose OrtValues, thread-safe session locking

- Move ArrayPool.Return after ONNX inference completes so the token
  buffer isn't recycled while OrtValue still references it
- Add using declarations for inputIdsOrtValue and styleOrtValue to
  prevent resource leaks on both happy and exception paths
- Replace lock(_session) with a dedicated _sessionLock object to avoid
  locking on a mutable nullable reference
- Add _disposed guard with ObjectDisposedException checks and
  idempotent DisposeAsync
- Replace _session! null-forgiving with explicit ObjectDisposedException

* perf: cache speed shape, pin backing array, readonly phonemeMap

* chore: add .worktrees to gitignore

* feat: extract ArgMax as SpanMathExtensions utility

Add SpanMathExtensions.ArgMax extension method on ReadOnlySpan<float>,
consolidating a pattern duplicated 5+ times across the codebase. Includes
full xUnit test coverage for edge cases (single element, duplicates,
negatives, NegativeInfinity).

* refactor: add Reset() to AudioFilterPipeline, narrow IList to List

* feat: add LogSoftmaxInPlace to SpanMathExtensions

Adds a numerically stable log-softmax implementation (max-subtraction trick) to the shared SpanMathExtensions utility, with five covering tests. Extracts the pattern duplicated in CtcForcedAligner.cs and Qwen3TtsGgufEngine.cs.

* feat: extract StringDistanceExtensions utility

Adds Levenshtein distance (O(m) space, stackalloc for short strings),
normalized similarity, and KeepOnlyLetters as shared utilities extracted
from CtcForcedAligner, with full unit test coverage.

* feat: add TextExtensions.TrimPunctuation

* feat: add AlignmentResult.Empty to avoid needless pool rental

* feat: add IncrementalSentenceAccumulator with cheap punctuation pre-check

* feat: add TtsEventEmitter with lifecycle event protocol

* refactor: replace private duplicates with shared SpanMathExtensions and TextExtensions

* feat: add SentenceProcessor with reusable state and GeneratedRegex

* refactor: fix CtcForcedAligner allocations, use shared extensions, modernize C#

* fix: synchronize subtitle Words list access to prevent concurrent modification crash

UpdateSegment on the background command-loop thread mutated SubtitleLine.Words
while the render thread iterated it in Render/PositionLines, causing
InvalidOperationException. Expose RunLocked on SubtitleTimeline and use it to
guard both the UpdateSegment call and the render iteration. Move PositionLines
inside the existing lock in GetVisibleLinesAndPosition.

* refactor: rewrite TtsOrchestrator as thin coordinator

* refactor: update DI, remove dead ITextProcessor/TextProcessor/ProcessedText

* fix: correct ArrayPool lifetime safety in CtcForcedAligner

* refactor: add TrimPunctuationSpan overload, DRY TrimPunctuation, replace SentenceProcessor duplicate

* style: apply CSharpier formatting

* feat: route llama.cpp logs through Serilog, suppress below error

* fix: make SentenceProcessor and TtsOrchestrator constructor public for DI resolution

internal constructor prevented Microsoft.Extensions.DependencyInjection
from resolving TtsOrchestrator — DI requires public constructors.

* chore: update package references for Microsoft.Extensions.Options and Microsoft.SemanticKernel to latest versions

* refactor: update SentenceProcessorTests for PhonemeResult Token[] constructor

* refactor: remove ProvidesPhonemes capability flag, phonemizer is now always core

* refactor: add PhonemeResult parameter to ISynthesisSession.SynthesizeAsync

* refactor: move phonemizer to SentenceProcessor core, remove from Kokoro

* test: update SentenceProcessor tests for new pipeline (always phonemize, no capabilities)

* refactor: Qwen3 uses phonemizer tokens with ArraySegment slicing, drops SplitIntoWords/BuildSliceTokens

* refactor: EmotionProcessor uses character-offset resolution, removes marker system

* refactor: change PhonemeResult.Tokens from IReadOnlyList<Token> to Token[] for zero-alloc slicing

* fix: map CTC word indices to phonemizer token ranges for correct Qwen3 lip-sync timing
* feat: add LipSyncTimeline, ILipSyncProcessor, and provider data structures

* feat: add BVLS blendshape solver for Audio2Face

* feat: add Audio2Face ONNX inference wrapper and model IDs

* feat: extract VBridger phoneme logic into VBridgerLipSyncProcessor

* feat: integrate ILipSyncProcessor into SentenceProcessor pipeline

* feat: add LipSyncAnimationService as unified Live2D consumer

* feat: add Audio2Face streaming lip sync processor with sync compensation

- Audio2FaceLipSyncProcessor: sentence-level audio buffer with sliding
  window inference, silence/carryover pre-fill for GRU warmup, deferred
  full-audio re-runs to preserve temporal continuity across right-padded
  windows, frame timestamp offset (1.55s) for ~200ms sync lag
- LipSyncAnimationService: drives Live2D params from pre-computed
  timeline with chunk-relative playback, smooth-to-neutral on idle
- ARKitToLive2DMapper, ResponseCurves, ParamSmoother: VBridger
  AdvancedARKit_V3.0 mapping with per-parameter EMA smoothing
- BlendshapeData: NPZ loader for skin basis, frontal mask, model data
- AnimatorSkinConfig: per-identity vertex composition parameters
- Audio2FaceInference: noise seed 0 (matching Python reference)
- LipSyncTimeline: binary-search frame interpolation
- ILipSyncProcessor: BeginSentence + Process + Reset interface
- SentenceProcessor: enriches TTS chunks with lip sync timelines

* feat: extract IBlendshapeSolver with PGD and BVLS implementations

- IBlendshapeSolver: strategy interface for solver hot-swap via config
- PgdBlendshapeSolver: renamed from BlendshapeSolver, eigenvalue-based
  step size (power iteration) replacing Gershgorin bound
- BvlsBlendshapeSolver: active-set bounded variable least squares
  matching scipy lsq_linear(method="bvls"), Cholesky sub-system solve,
  ~3x more accurate blendshape weights than PGD
- SolverType config ("BVLS" default, "PGD" for lower latency)

* test: add Audio2Face solver, mapper, smoother, and response curve tests

- BlendshapeSolverTests: PGD + BVLS identity recovery, clamping,
  zero-delta, and temporal smoothing tests
- ARKitToLive2DMapperTests, ParamSmootherTests, ResponseCurvesTests,
  BlendshapeDataTests

* fix: ReadFloat32Into writes into destination span instead of discarded copy

* fix: dispose SessionOptions after InferenceSession creation, null cached noise on dispose

* perf: pre-allocate inference tensors and output buffers, eliminate ~8 MB per window

* perf: pre-allocate frame processing buffers, reuse static zero-padding

* refactor: extract SolverMath, pre-allocate all solver working buffers

* refactor: merge identity switches, downgrade lip-sync null log to debug

* fix: always register both lip-sync processors for hot-reload support

* fix: add thread safety to LipSyncAnimationService, const strings, remove dead handler

* refactor: use MathF.Clamp consistently, extract shared Hermite helper

* fix: replace silence-influenced lipsync frames during deferred re-run

Right-padded inference windows commit frames where the model sees
zeros (silence) instead of future audio, causing periodic dips toward
neutral mouth pose. The deferred re-run previously only advanced
internal state—now it also overwrites those frames in-place with
correct ones computed from the actual audio, eliminating lip jumps
at chunk boundaries.

* perf: enable CUDA execution provider for CTC forced aligner

* perf: eliminate redundant ConcatAudio allocations in streaming timing loop

Replace List<float[]> + ConcatAudio (full-buffer copy every alignment
interval) with a flat List<float> and CollectionsMarshal.AsSpan for
zero-allocation access. Removes 4-5 full-buffer copies per sentence.
Delete the now-dead ConcatAudio helper method.

* perf: migrate Audio2Face to IoBinding API with Span-based extraction

* fix: ignore stale pipeline events in Idle, ProcessingInput, and WaitingForLlm states

After barge-in interruption, the previous turn's TTS/LLM pipeline can
still have events queued in the output channel since cancellation is
cooperative. Stale triggers like TtsStreamChunkReceived arriving in
states that don't handle them caused an unhandled trigger error that
killed the session. Added ignore directives matching the pattern
already established by the Interrupted state.

* merge: integrate refactor/dry-cleanup into feature/audio2face-lipsync

Merge DRY utilities (OnnxSessionFactory, PooledArray, AnimationServiceBase,
WavFileHandler, AudioValidation) and reorganized Utils/ structure from
refactor/dry-cleanup. Update Audio2Face code to use new shared utilities.
Resolve conflicts in CtcForcedAligner (CUDA+factory) and NpyReader (zero-copy).

* fix: skip lip-sync chunks with shorter time coverage than existing data

Prevents partial CTC results from replacing higher-quality alignment
data already loaded for the same sentence.
…t-reload, health probes (#21)

* feat(ui): add LayoutContext space-tracking stack

* feat(ui): add WindowScope, RowScope, and Ui entry points

* feat(ui): add SplitScope with draggable divider

* feat(ui): rewrite control panel to use declarative layout system

ControlPanelComponent.Render uses Ui.Window/Row/HSplit scopes.
StatusBar, Navigation, ControlBar are now pure content renderers.

* feat(ui): migrate Dashboard to layout system, add child flags to RowScope

Dashboard uses Ui.Row for vertical section tiling — eliminates magic
120px height reservation. Transcript fills remaining space automatically.
RowScope now accepts ImGuiChildFlags for bordered/scrollable regions.

* fix(ui): fix Dashboard layout gaps and health card spacing

Push zero ItemSpacing around Row tiling so the LayoutContext height
math is exact — no hidden 8px gaps between children. Use explicit
8px cardGap constant for health cards instead of reading ItemSpacing
(which is zero in the Row context).

* fix(ui): RowScope owns zero-spacing, add EqualCols, simplify Dashboard

RowScope now pushes zero ItemSpacing on the parent before BeginChild
and pops after EndChild — rows always tile correctly regardless of
parent context. No consumer needs to manage ItemSpacing manually.

Added Ui.EqualCols(count, height, gap, childFlags) for evenly-spaced
horizontal columns. Dashboard uses it for health cards — no manual
width math or SameLine gap calculations.

Dashboard.Render is now pure layout declarations: 3 Rows + 1 EqualCols.

* fix(ui): add Ui.Rows for pre-resolved sizing, fix two-phase style push

Ui.Rows(params Sz[]) pre-resolves all row sizes using Sz.Resolve so
Fill rows correctly account for sibling Fixed rows. Previously
Ui.Row(Sz.Fill()) greedily consumed all remaining height, leaving 0
for subsequent Fixed rows — causing overflow and scroll.

RowScope and SplitChildScope now use two-phase style push: container
style (padding, bg) before BeginChild, content style (item spacing)
after BeginChild. Default style inherits parent's spacing instead of
zeroing it.

Style.IsDefault property added to detect zero-initialized structs.

* fix(ui): prevent WindowPadding leaking between layout scopes

Every scope now explicitly sets WP before BeginChild (zero for default
style, style value for explicit) — never relying on inheritance.
After BeginChild, WP is reset to zero so nested children start clean.

This fixes: mini scrollbars on health cards (parent WP(16,16) leaked
into 48px-tall cards leaving only 16px content), conversation overlap,
and session section being pushed offscreen.

Dashboard restored to use Ui.Rows + Ui.EqualCols with the fixed scopes.

* fix(ui): move Conversation label above border, fix stats overflow

Transcript: section header renders above the bordered area. Messages
are in an inner bordered child that fills the remaining row space.

Stats: increased StatsSectionHeight from 70 to 100 to fit the section
header (~36px) + table (~40px) without triggering a scrollbar.

* feat(ui): add Ui.FillChild scope for remaining-space children

FillChildScope creates a child window filling GetContentRegionAvail()
with explicit padding and WP isolation. Useful after a header to fill
the rest of a row with bordered/scrollable content.

Dashboard transcript uses Ui.FillChild instead of manual push/pop.

* feat(ui): update color palette to 4-level surface hierarchy and increase spacing

* feat(ui): increase structural sizes — sidebar, bars, divider, column gaps

* feat(ui): restyle section headers, nav items, and dashboard cards

* fix(ui): rename all old Theme constant references to new surface hierarchy

* feat(ui): darken status bar background and increase height

* feat(ui): add optional glow halo to StatusDot helper

* feat(ui): restyle status bar with pulsing glow, accent border, and typography hierarchy

* refactor(ui): remove legacy GUI system, add grid layout, and consolidate into ControlPanel

Delete 26 unused files from UI/GUI/ (old config editors, theme manager,
notification system, state management). Move ImGuiController into
ControlPanel. Add GridScope for auto-height column grids and padding
support to EqualColsScope. Refactor Avatar, Streaming, and
RouletteWheelPanel to use grid layout for dimension inputs.

* feat(ui): restyle status bar and fix layout padding system

- Status bar: Surface2 background, 46px height, pulsing glow dot,
  accent bottom border, explicit TextPrimary/TextTertiary typography
- Fix layout padding: WindowPadding not applied by ImGui to child
  windows in Hexa.NET binding; use Indent/Dummy for left/top padding
- SplitChildScope: dual-child mode (right panel, true 4-side padding)
  vs indent mode (sidebar, allows draw-list access to window edge)
- RowGroupScope: add gap parameter for spacing between rows
- FillChildScope: manual padding via Indent/Dummy
- Dashboard: 12px row gaps, conversation label spacing
- Navigation: accent bar drawn at sidebar outer edge

* feat(ui): wire up animation system — toggle switches, panel transitions

Propagate deltaTime through the entire ControlPanel render pipeline so
animation helpers (AnimatedFloat, OneShotAnimation, Easing) actually
receive frame timing.  Replace all eight ImGui.Checkbox calls with the
animated ToggleSwitch helper and add a fade+slide-up transition when
switching between navigation panels.

* fix(ui): enforce minimum window size via GLFW and fix ImGui resize desync

Use glfwSetWindowSizeLimits to prevent the OS from allowing the window
below the configured minimum (800x600 default), eliminating visual
artifacts during drag. Remove ImGuiController's cached window size in
favour of reading _view.Size each frame so ImGui always sees the
post-clamp dimensions. Add defensive clamping to SplitScope so the
sidebar/content split stays valid at boundary sizes.

* feat(ui): add SineOscillator and EaseOutBack to animation system

* feat(audio): add IAudioAmplitudeProvider for SRP-compliant amplitude exposure

Extracts audio amplitude computation from the UI domain into the audio domain layer. UI reads a single float from IAudioAmplitudeProvider.CurrentAmplitude instead of handling raw PCM samples directly.

* feat(ui): add PersonaStateProvider to bridge conversation state for UI effects

* feat(ui): add ParticleSystem for ambient floating particle effects

* feat(ui): add AmbientRenderer with gradient glows, particles, and gradient dividers

* feat(ui): add breathing gradient, pulsing border, and hover warmth to sidebar nav

* feat(ui): add springy toggle, slider linger, button press feedback

- ToggleSwitch: EaseOutBack overshoot + ripple ring while settling
- SliderGlow: lingers ~300ms after interaction via exponential decay (dt param, backward-compatible default)
- PrimaryButtonWithFeedback: non-linear press darkening + warm glow pulse on click
- LerpColor: promoted from private to public

* feat(ui): add staggered transitions, ambient wiring, save pop animation

* feat(ui): add audio-reactive border, warmth overlay, and dot flare to status bar

* feat(ui): add sound event hooks and wire all companion space services in DI

Registers AudioAmplitudeProvider, PersonaStateProvider, AmbientRenderer,
and IUiSoundEmitter (NoOpUiSoundEmitter) in the DI container so the
ControlPanelComponent can resolve all dependencies at runtime.

* fix(ui): polish nav hover, fix ambient visibility, redesign save toast, bump status warmth

* fix(ui): center save toast in status bar and soften ambient rendering

* feat(ui): expose outer drawlist on SplitChildScope; render ambient edge-to-edge

Capture outer child draw list, pos and size in SplitChildScope before the
inner padded child opens, exposing OuterDrawList/OuterPos/OuterSize properties.
ControlPanelComponent now passes these to AmbientRenderer so glows fill the
full content area instead of respecting inner padding.

* refactor(ui): use pre-rendered gradient texture for ambient glows with smooth state transitions

Replace ~40 AddCircleFilled rings per glow (visible banding) with a single
256x256 RGBA8 radial gradient texture uploaded once at Initialize. Each frame
draws two textured quads via ImGui.AddImage, letting GPU bilinear filtering
produce smooth falloff. AnimatedColor (exponential decay on Vector4) added to
Animation.cs for seamless persona-state color transitions. RadialGradientTexture
owns its GL handle and disposes cleanly through ControlPanelComponent.Dispose.

* feat(ui): companion space iteration — presence orb, voice envelope, dithered glows

Major polish pass on the companion-space ambient layer:

- HAL-style PresenceOrb: replaces the basic StatusDot in the status bar.
  Three layers (core/inner/outer) react to amplitude with staggered
  smoothing so energy ripples outward instead of scaling in lockstep.
  Spawns randomized expanding rings on syllable peaks (variable cooldown,
  spawn delay, duration, radius, thickness) for organic sound-wave look.
  Soft-knee compression curve so quiet sounds get amplified more, peaks
  taper toward (but rarely hit) the ceiling.

- AmplitudeEnvelope: pre-computes per-window RMS across a chunk's audio
  data; AudioAmplitudeProvider now subscribes to PlaybackProgress and
  samples the envelope, so the orb sees real per-syllable amplitude
  instead of a single per-sentence average.

- BayerDither + ImGuiShaderSources: extracts inline ImGui shader strings
  into reusable composable sources; injects 4×4 Bayer ordered dither into
  the fragment shader (~±1/255 amplitude) to suppress visible banding in
  low-alpha gradients.

- AmbientRenderer rewritten on top of a shared RadialGradientTexture:
  drifting corner glows drawn via AddImage with animated tint, edge-to-
  edge in the content area (SplitChildScope now exposes outer drawlist/
  bounds). Particle simulation moved into RenderBackground where panel
  bounds are known.

- Save indicator → toast card: rounded bg + accent border + dot, slides
  in from right, vertically centered in the status bar; rapid successive
  saves pulse the existing toast instead of restarting.

- Smaller polish: nav hover → subtle rect tint (was a giant circle),
  status bar warmth alpha bumped, springy toggle ripples, slider glow
  linger, smooth state-to-state color transitions on glows, finer
  ImGui circle tessellation for small dots.

* feat(ui): voice panel redesign — mode-first UX, audition pipeline, layout system extensions

Redesign the Voice panel around an honest Clear vs Expressive mode selector
that surfaces the real trade-offs between Kokoro (phoneme-accurate, light GPU)
and Qwen3 (context-aware emotion, heavy GPU). Voice audition lets users hear
any voice via a dedicated PortAudio stream without disturbing the live
conversation pipeline.

Layout system:
- Ui.Card (auto-height via AutoResizeY, optional fixed height/width)
- Ui.Chip (pill toggle, interactive or display-only)
- Ui.CollapsibleSection (animated expand/collapse, inline hint text)
- Ui.LabeledSlider (perceptual labels, float + int overloads)
- Ui.IconButton, Ui.PreviewButton (draw-list shapes, no font dependency)
- Ui.WrapGrid (auto-column tile grid)
- UniformHeightTracker (cross-frame max-height enforcement)
- SettingLabel + SettingEndRow (consistent row spacing + vertical alignment)

Voice panel sections:
- VoiceModeSelector — two trade-off cards (Clear / Expressive)
- VoiceCard — selected voice summary with preview + gender/cloned chips
- VoiceGallery — horizontal scroll strip with filter chips + uniform tiles
- CloneLayerSection — RVC with mode-sensitive hint + animated collapse
- AdvancedSection — all engine knobs including new Qwen3 sampling params

Audition pipeline:
- IVoiceAuditionService with per-request ID tracking + StopAsync
- OneShotAudioPlayer (callback-based PortAudio, independent stream)
- ISentenceSynthesizer.CreateSession(voiceName) for instant preview
- RvcOverride for per-call voice/pitch override

Infrastructure fixes:
- RVCFilter thread-safety: ReaderWriterLockSlim around model swap (P4)
- RVCFilter async-void crash: try/catch in OnOptionsChanged
- ITtsEngineProvider.Get(engineId) for per-engine lookup
- PhonemeResult.Empty static field
- EqualColsScope uses GetContentRegionAvail (scrollbar-aware)
- Dashboard health cards migrated to Ui.Card

* feat(dashboard): add SessionStatsCollector with MeterListener-backed stats accumulation

* refactor(dashboard): extract SystemHealthSection

* feat(dashboard): add SessionStatsSection wired to SessionStatsCollector

* refactor(dashboard): extract TranscriptSection

* refactor(dashboard): replace monolithic Dashboard with thin orchestrator

Extract the 220-line Dashboard into a slim orchestrator that delegates
to SystemHealthSection, TranscriptSection, and SessionStatsSection.

* refactor(dashboard): wire new Dashboard sections and collector into DI

Register SessionStatsCollector, SystemHealthSection, TranscriptSection,
and SessionStatsSection in the DI container. Update imports in
ServiceCollectionExtensions and ControlPanelComponent to resolve the
Dashboard class via a using alias (avoids class/namespace ambiguity).
Includes incidental CSharpier formatting fixes.

* feat(metrics): add GetTurnElapsedMs() to TurnMetricsTracker

* fix(metrics): use end-to-end turn elapsed for first_audio_latency histogram

* fix(ui): status bar showing Listening after session ends

Expose ConversationState via IConversationSession.CurrentState so the
StatusBar and PersonaStateProvider check the actual state machine state
instead of inferring from PendingTurn alone. Sessions in Ended, Error,
or Initial states now correctly display as No Session.

Also refactor Dashboard to use three-row layout (health/transcript/stats)
and simplify TranscriptSection to fill available space.

* refactor(ui): redesign Personality panel with section-based architecture

Replace monolithic Personality.cs with thin orchestrator + three focused
sections (PromptSource, CurrentVibe, Topics). Add explicit UseCustomPrompt
bool to ConversationContextOptions for clear file/custom prompt switching.
Topics now use chip-based input with add/remove instead of comma-separated
text.

* feat(ui): show hand cursor on hover for all clickable elements

Add HandCursorOnHover() helper to ImGuiHelpers and apply it across all
interactive widgets: buttons, combos, toggles, chips, preview buttons,
collapsible headers, radio buttons, selectable nav items, voice tiles,
mode selector cards, and removable topic chips.

* fix(dashboard): exclude first turn warmup from avg latency calculation

* feat(ui): add TitleBarBg color and TitleBar style for custom title bar

* feat(ui): add Win32WindowHelper for borderless window chrome

* feat(ui): make window borderless and expose Win32WindowHelper

Switch WindowBorder from Resizable to Hidden for custom chrome support.
Create Win32WindowHelper in OnLoad (after HWND is available) and dispose
it in OnClose. Expose it via public Win32Helper property for TitleBar.

* refactor(di): register WindowManager as DI singleton

Extract WindowManager construction out of AvatarApp into the DI
container via AddUI, so downstream components like TitleBar can take
it as a constructor dependency. AvatarApp now receives it injected.

* feat(ui): add TitleBar layout component with window controls

* feat(ui): wire TitleBar into control panel layout and DI

* style: format custom title bar files with CSharpier

* fix(ui): fix title bar HWND, draw list icons, rounding, and centering

* fix(ui): enable edge resize, double-click maximize, and right-click system menu

* fix(ui): handle double-click maximize and enforce min window size during resize

* fix(ui): explicitly set max track size to allow window resize beyond min size

* fix(ui): call original WndProc before overriding MINMAXINFO constraints

* fix(ui): use unsafe pointer for MINMAXINFO to avoid marshaling issues

* fix(ui): write MINMAXINFO via raw pointer offsets to fix resize constraints

* fix(ui): use WM_SIZING for min size enforcement instead of MINMAXINFO

* refactor(ui): remove unsafe from Win32WindowHelper, use Marshal.Read/WriteInt32

* feat(ui): add rounded window corners and animated state-reactive frame glow

* fix(ui): clamp the dragged edge during resize to prevent window moving at min size

* style(ui): reduce window frame glow outer thickness to 2px

* feat(ui): add floating per-pixel-alpha overlay for avatar and subtitles

* feat(utils): add FloatRingBuffer circular buffer primitive

* feat(audio): add SamplesAvailable event to IMicrophone for live amplitude taps

* feat(audio): add MicrophoneAmplitudeProvider with smoothed RMS + history ring

* feat(asr): expose Silero VAD per-batch probability via IVadDetector.ProbabilityObserved

* feat(asr): add VadProbabilityProvider with latest-value + history ring

* feat(di): register IMicrophoneAmplitudeProvider and IVadProbabilityProvider singletons

* perf(audio): reuse AmplitudeEnvelope buffer and sample event-provided elapsed time

- AudioAmplitudeProvider.OnPlaybackProgress reads e.CurrentPlaybackTime instead of
  computing UtcNow - _chunkStartUtc (correctness + ~100 ns/tick x 240/sec saved)
- AmplitudeEnvelope converts its static factory into an instance with RebuildFrom
  that reuses the internal buffer via Array.Resize - drops one heap allocation
  per TTS chunk to ~1 for the process lifetime
- Existing tests pass unchanged (From() retained as a convenience wrapper)

* perf(audio): direct multi-cast invoke for high-frequency PlaybackProgress

Eliminates per-raise Delegate[] allocation from GetInvocationList() at the ~240 Hz
playback-progress tick rate. Chunk started/ended keep per-handler isolation.

* feat(ui): scaffold Listening panel folder with stub sections + widget

* refactor(ui): remove old Listening panel monolith

Resolves namespace collision between the old Panels.Listening class and
the new Panels.Listening.* namespace introduced by the scaffold in the
preceding commit. Clearing DI refs + constructor param here; the new
ListeningPanel + sections will be wired in a follow-up.

* feat(ui): MicrophoneDeviceSection — device picker + Test Microphone round-trip

* feat(ui): add LiveMeterWidget with amplitude bar + VAD probability sparkline

* feat(ui): SpeechDetectionSection with live meter + three VAD sliders

* feat(ui): RecognitionSection with quality chips + custom vocabulary editor

* feat(ui): InterruptionSection two-axis composer + pure Compose/Decompose helpers

* feat(ui): wire ListeningPanel + sections into DI and ControlPanelComponent

* fix(build): resolve clean-checkout build for the Listening rework

Three fixes that should have landed with the Listening panel redesign but
were masked by concurrent local refactoring on the branch:

- MicrophoneInputPortAudioSource (dead code, still implements IMicrophone)
  now implements the SamplesAvailable event added to IMicrophone in 3ee3b6d.
  Without this, PersonaEngine.Lib fails to build on a clean checkout.

- Remove accidental "using PersonaEngine.Lib.UI.ControlPanel.Visuals" and
  "using PersonaEngine.Lib.UI.Host" added to ServiceCollectionExtensions.cs
  and ControlPanelComponent.cs during DI wiring. Those namespaces do not
  exist in the committed tree (they're part of unrelated WIP overlay
  refactoring). The types resolve via the existing "PersonaEngine.Lib.UI"
  and "PersonaEngine.Lib.UI.ControlPanel" usings at HEAD.

* feat(conversation): add IConversationInputGate with hold-count scope composition

* feat(conversation): expose Pause/Resume on session + gate input loop

* feat(conversation): add PauseAll/ResumeAll on orchestrator

* feat(ui): rewrite overlay on D3D11 + DirectComposition, reorganize UI folders

The floating overlay now renders via a D3D11 swap chain composed through
DirectComposition instead of the old OpenGL + glReadPixels + UpdateLayeredWindow
path. The legacy chain spent 3-5 ms/frame on CPU readback and GDI blitting;
the new path is pure GPU with sub-millisecond overlay-thread cost. A DXGI
frame-latency waitable + 60 FPS stopwatch cap keeps GPU usage bounded so
high-refresh displays don't pin the card at 240 Hz.

Overlay rendering stack:
* Raw Win32 HWND with WS_EX_NOREDIRECTIONBITMAP (required at CreateWindowEx
  time) hosts a composition-mode swap chain with premultiplied alpha.
* SpoutD3D11Source opens the Spout sender's NT handle directly via
  ID3D11Device.OpenSharedResource - no GL interop, no SpoutDX P/Invoke.
  The shared texture is cached by handle; reopened only when the sender
  restarts or resizes.
* Four focused SDF/rect pipelines compose the hover chrome:
  - ButtonPipeline: rounded-rect button (fill + accent border + dark halo)
  - IconPipeline: rect-batched drag glyph (cardinal-axis four-way arrow)
  - OutlinePipeline: rounded-rect window outline with interior dark halo
  - ArrowPipeline: SDF diagonal arrow (sdSegment + 2x sdTriangleIsosceles,
    rotated so axis-aligned arithmetic produces clean antialiased 45°
    geometry that pixel-art rectangles can't match)
  ChromeRenderer coordinates them and owns shared blend + rasterizer state.
* ShaderCompiler and FullscreenQuad helpers deduplicate the formerly
  copy-pasted HLSL compile path and clip-space triangle-strip vertex buffer.

Interaction:
* Drag and resize buttons cluster in the top-right. Resize pivots on the
  bottom-left corner (NE<->SW diagonal) with matching IDC_SIZENESW cursor.
* SetCapture/ReleaseCapture keeps the gesture live when the cursor leaves
  the overlay bounds; WM_CAPTURECHANGED dispatches a synthetic
  LeftButtonUp so state never stays stuck if another app steals capture.
* Aspect ratio is always locked; LockAspect and ChromeFadeSeconds knobs
  removed from OverlayConfiguration and the control panel.
* OverlayHost now closes the running overlay window (via thread-safe
  PostMessage WM_CLOSE) when Enabled flips off in config, instead of only
  setting the stop flag.

UI folder reorganization:
* UI/Native/Win32.cs - shared P/Invoke + POINT/RECT/MSG/WNDCLASSEX,
  consumed by both the overlay and host subsystems. Replaces duplicated
  DllImport declarations previously scattered across four files.
* UI/Host/ - Win32WindowHelper and WindowManager moved out of UI/ root.
* UI/ControlPanel/Visuals/ - PresenceOrb, ParticleSystem, AmbientRenderer,
  and WindowFrameGlow moved out of ControlPanel/ root.
* UI/Overlay/Native/ and UI/Overlay/Rendering/ - internal overlay split.

Misc cleanup:
* Removed unreachable cleanup after early return in RouletteWheel.Dispose.
* Fixed four pre-existing PersonaStateProvider test failures; the session
  CurrentState mock was returning the enum default (Initial), which the
  provider treats as not-running.
* appsettings.json excluded from git (holds API keys and local model paths).

All 329 tests pass.

* feat(ui): add IActivatablePanel lifecycle and dispatch on nav transitions

* feat(ui): Listening panel closes input gate while active for calibration

* feat(ui): wire ControlBar pause button to orchestrator PauseAll/ResumeAll

* feat(di): register IConversationInputGate singleton

* perf(audio): sub-window RMS sampling for smooth mic amplitude meter

NAudio's 100 ms default buffer gave only 10 Hz amplitude history updates —
visibly staccato on 60+ FPS displays. Splitting each buffer into 16 sub-windows
lifts the effective rate to ~160 Hz. History capacity raised to 128 entries
(~0.8 s visible window) to compensate for faster advance.

* feat(ui): redesign LiveMeterWidget — single-trace, draggable threshold, smooth scroll

Replaces the dual-row meter with a single VAD-probability trace, above-threshold
shading, a state banner ('Silent' / 'Picking up sound' / 'Listening to you'), a
compact mic-alive amplitude pip, and a draggable sensitivity threshold line.

Adds sub-slot scroll interpolation to eliminate the staccato feel that sub-window
sampling alone could not fix — the trace scrolls smoothly between pushes at pixel
resolution, using an EMA-estimated push interval so it adapts whether the source
updates at 31 Hz (VAD) or 160 Hz (amplitude).

* fix(ui): eliminate scroll jitter in LiveMeterWidget

Three causes rolled into one fix:

1. EMA interval estimate oscillated on frame-quantized observations. The
   VAD pushes at 32 ms but the UI ticks at ~16 ms, so observedInterval
   alternated between 16/32/48 ms each push, causing the estimate — and
   thus scroll speed — to visibly wobble. Replaced with a fixed constant
   derived from Silero's known rate (512 samples / 16 kHz = 32 ms).

2. Offset was overwritten each frame with elapsed/interval, instantly
   discarding the "decrement by slotsAdvanced on push" bookkeeping.
   Result was a sawtooth reset at every push instead of a continuous
   phase advance. Advance offset continuously; only decrement on head
   change; let the safety clamp catch long pauses.

3. New data points popped into existence at the right edge at each push.
   Shifting the whole trace right by one step width makes fresh samples
   slide in from off-screen right over their first interval of life,
   then scroll past plotMax.X and exit naturally on the left. Added a
   PushClipRect/PopClipRect around the plot region so the off-screen
   portion is cleanly clipped.

* feat(ui): overhaul Listening panel — smooth meter, UX polish

LiveMeterWidget: rewrite from ring-buffer to time-based rendering.
Sample VAD probability once per frame with wall-clock timestamps for
smooth scrolling. Apply asymmetric envelope (5 ms attack / 250 ms
release) so speech onset is sharp while decay flows like a VU meter.

MicrophoneDeviceSection: replace ambiguous recording countdown with
"Speak now" label + progress bar. Draw play-back triangle via draw
list for consistent sizing.

SpeechDetectionSection: add "Reset defaults" button (enabled only
when values differ from defaults). Update ASR defaults to match
appsettings.json (MinSpeech 150 ms, MinSilence 450 ms).

RecognitionSection: add "Add" button to vocabulary input, matching
the Topics section pattern.

InterruptionSection: shorten "Allow while avatar is speaking" to
"During responses" to prevent toggle overlap. Reorder so the toggle
sits above the slider.

ImGuiHelpers: add SubtleButton with enabled/disabled state.
FillChildScope/Ui: add windowFlags parameter; ListeningPanel uses
AlwaysVerticalScrollbar for stable layout width.

* refactor(tts): extract LlamaTtsContext from LlamaTtsInference

Per-session inference context owning its own LLamaContext pair
and native batch. Preparation for concurrency fix.

* refactor(tts): add LlamaTtsModel with bounded context pool

Shared model weights + ConcurrentBag pool (max 2) with SemaphoreSlim
gate. Enables concurrent TTS generation without context races.

* refactor(tts): update Qwen3TtsGgufEngine to use LlamaTtsModel context pool

Replace direct LlamaTtsInference usage with LlamaTtsModel/LlamaTtsContext
rent/return pattern. A context is rented before TalkerPrefill, threaded
through GenerateCodeFrames, and returned in the producer task's finally
block. On error the context is marked corrupted so the pool disposes it
instead of reusing stale CUDA state.

* refactor(tts): remove LlamaTtsInference, replaced by LlamaTtsModel + LlamaTtsContext

* fix(tts): prevent context leak if TalkerPrefill throws before producer task

Wrap TalkerPrefill in try/catch that returns the rented context as
corrupted on failure. Without this, a prefill exception would permanently
consume a pool slot since the producer task's finally never runs.

* refactor(config): convert Live2D/LipSync options to records

* feat(ui): add AvatarPanel thin orchestrator

* feat(ui): add ModelSection with scanned character picker

* fix(ui): handle ModelSection stale (not found) entry + enforce ordinal compare

* feat(ui): add LipSyncSection with Style/Quality chips and GPU toggle

* refactor(ui): wire AvatarPanel into DI and remove old Avatar

* style(ui): csharpier format Avatar panel redesign

* refactor(overlay): move resize handle to bottom-right corner

The handle now sits in the grow-direction corner, so the cursor stays
on it as the window expands toward the SE. Resize gesture pins the
top-left (natural anchor) instead of the bottom-left; cursor swaps to
IDC_SIZENWSE and the SDF arrow rotates to the NW-SE diagonal.

* feat(ui): refine Avatar panel UX

- Replace ResolutionPicker with stream-friendly ResolutionChips
  (orientation row + 4 size chips: 720p/1080p/1440p/4K and portrait
  /square siblings). Nearest-by-pixel-count when flipping orientation.
- Remove Models Folder input from UI (ModelPath stays in config with
  its default, same treatment as Audio2Face.Identity).
- Apply consistent SettingEndRow spacing to Style/Quality/UseGpu rows,
  drop the ad-hoc ImGui.Spacing() — matches the Listening panel.
- Reorder LipSync rows: Style -> Quality -> Use GPU. Quality is the
  user-facing fidelity knob; Use GPU is the performance lever.
- Fix Chip to strip "##id" suffix from displayed text, matching ImGui
  native widget convention. Fixes visible ids in the new chip labels.

* feat(ui): swap LipSync Quality chip order — Fast before Accurate

Match the Style row convention (Simple before Realistic) — cheaper/
faster option on the left, higher-fidelity option on the right.
Tooltip text reordered to match.

* refactor(config): convert SubtitleOptions to record

* feat(ui): promote hex color helpers to Theme

* feat(ui): extract ScannedNamePicker + ImGuiHelpers.ScannedCombo

* feat(ui): add ImGuiHelpers.DrawOutlinedText for preview rendering

* refactor(ui): ModelSection uses ScannedNamePicker helper

* feat(subtitles): hot-reload config edits without restart

* feat(ui): SubtitlesPanel orchestrator and 4 standard sections

* feat(ui): PreviewSection with ImDrawList-simulated outline

* refactor(ui): wire SubtitlesPanel into DI and remove old Subtitles

* feat(ui): add OffscreenBuffer FBO primitive for reusable off-screen rendering

* fix(ui): address OffscreenBuffer review issues (split read/draw FBO restore, disposed guard, Span overload)

* feat(ui): add SubtitlePreviewRenderer for WYSIWYG TTF-based preview baking

* fix(ui): align SubtitlePreviewRenderer color parsing with live renderer and guard Dispose

* refactor(ui): rewrite Subtitles PreviewSection as WYSIWYG FBO consumer

* feat(ui): wire Subtitle preview FBO renderer through ControlPanel lifecycle

* chore(ui): remove unused DrawOutlinedText helper (dead after preview rewrite)

* refactor(ui): render subtitle preview at fixed 55pt

Configured FontSize doesn't translate meaningfully to the preview because
the live OBS output is scaled and resized independently. Decouple the
preview font from _opts.FontSize and bake at a constant 55pt, with a
fixed canvas height. Caption continues to show the configured size so
the user can still confirm their pick.

* feat(ui): add Reset defaults button to Subtitle Placement section

Mirrors the SpeechDetection header-action pattern: a right-aligned
"Reset defaults" button that only enables when MaxVisibleLines,
BottomMargin, SideMargin, InterSegmentSpacing, or AnimationDuration
deviate from SubtitleOptions defaults. Resets only those five fields
via a record `with` expression, leaving font, colors, stroke, and
canvas dimensions untouched.

* chore(config): align in-code defaults with current appsettings.json

Bring the compiled-in defaults back in step with the shipping
appsettings.json so fresh installs render identically to the reference
config:

- SubtitleOptions: FontSize 72→125, Color→#FFf8f6f7, Highlight→#FFc4251e,
  BottomMargin 50→250, SideMargin 100→30, InterSegmentSpacing 16→10,
  MaxVisibleLines 6→2, canvas 1920x1080→1080x1920, Font→DynaPuff.ttf.
- RouletteWheelOptions: Font→DynaPuff.ttf.
- AvatarApp: load Montserrat.ttf and Seguiemj.ttf (current Resources/Fonts
  filenames, previously referenced by stale names).

* feat(overlay): add OverlayStatus enum for live lifecycle state

Introduces Off/Starting/Active/Stopping/Failed — the real-world thread
state, distinct from OverlayConfiguration.Enabled (the user's desired
state). Lets the UI honestly reflect work in flight without lying about
intent.

* feat(overlay): add Stateless-based OverlayStateMachine with full transition coverage

Wraps Stateless.StateMachine<OverlayStatus, OverlayTrigger> with callback-
driven entry actions so the transition topology is testable without
Win32, D3D11, or OverlayHost. Matches the existing pattern used by
ConversationSession.

Transition table covers Off/Starting/Active/Stopping/Failed with the
reverse-intent-mid-Stopping reconcile: TurnOn during Stopping is ignored
but DesiredEnabled flips; Off.OnEntry then re-fires TurnOn when the old
thread exits.

14 unit tests cover basic transitions, reverse-intent sequences, external
close, Failed/retry, and idempotent trigger handling.

* feat(overlay): drive lifecycle directly from SetEnabled, decouple from config watcher

Adds an instant-feeling public API to OverlayHost that replaces the old
file-watcher round-trip path:

- New public surface: SetEnabled(bool), DesiredEnabled, Status, LastError,
  StatusChanged event. UI binds to DesiredEnabled so the checkbox flips
  within one frame; Status reflects the real thread state via the
  Stateless machine.
- SetEnabled fires a trigger immediately (no file write in the live path)
  and schedules persistence in a debounced background task (200 ms), so
  rapid toggles don't thrash appsettings.json.
- Machine entry actions handle thread lifecycle: Starting.OnEntry spawns
  the overlay thread; Stopping.OnEntry posts WM_CLOSE; Off.OnEntry
  reconciles reverse-intent by re-firing TurnOn if desired flipped back
  on mid-Stopping.
- RunOverlayThread fires Ready right before overlay.Run() (Starting →
  Active), Failed in the catch, and ThreadExited in the finally (with
  external-close detection flipping DesiredEnabled=false for UI honesty).
- ApplyConfig's Enabled branch becomes idempotent — external edits to
  appsettings.json still sync into the machine, but the live UI path no
  longer rides on the file watcher.

* feat(overlay): add ResetPosition on OverlayHost and live MoveTo on OverlayWindow

* feat(ui): add reusable StatusPill for overlay status indicators

* refactor(ui): reorder nav sections and add Overlay entry

* feat(ui): add OverlayPanel with live status pill and reset-position

* feat(ui): wire OverlayPanel into DI and ControlPanel dispatch

* refactor(ui): remove Floating Overlay from Streaming panel (moved to OverlayPanel)

* feat(ui): add Overlay quick-toggle card to Dashboard System Health row

* feat(overlay): add ResetSize on OverlayHost and live ResizeTo on OverlayWindow

Mirrors the existing ResetPosition path. OverlayNativeWindow gains a
WM_APP+2 cross-thread resize message handled via SetWindowPos with
SWP_NOMOVE. OverlayWindow exposes ResizeTo(w, h). OverlayHost.ResetSize
pulls Width/Height defaults from a fresh OverlayConfiguration record so
there are no magic numbers — persists and live-resizes under the same
_lifecycleLock as ResetPosition. Position is preserved.

* feat(ui): add Reset size button to OverlayPanel alongside Reset position

Inline button pair on the geometry row, matching the existing Reset
position affordance. Calls OverlayHost.ResetSize which writes the
OverlayConfiguration record defaults (360x640) and live-resizes the
overlay window if it is currently running.

* feat(ui): upgrade StatusPill with broadcast-style live ring and one-shot transitions

Adds a second motion language on top of the existing transitional dot-alpha
pulse: an Active-state ring halo that expands 1x->3x with fading opacity
on a 1.6s cycle — the convention used by streaming apps for 'live now'
indicators. Three one-shot transitions are exposed via PillTransition so
callers can drive stateful choreography:

  - Ignite   (400ms outward boom) — entering Active
  - Extinguish (200ms inward collapse) — leaving Active
  - Alert    (300ms white->red flash) — entering Failed

StatusPillStyle bundles the broadcast knobs (background tint, live pulse,
transitions) with a .Broadcast preset for the Dashboard badge. The
original Render(status, elapsed, lastError) overload is preserved so the
Overlay panel's existing call site is unchanged. TransitionFor(prev, curr)
and DurationOf(...) helpers keep the choreography logic in one place.

* feat(ui): promote overlay toggle to Dashboard presence strip, quiet overlay logs

- New PresenceStripSection: full-width, always-visible click-anywhere strip
  with status-tinted plate, pulse ring on Live, and action verb
  (Show/Hide/Retry/Cancel). Replaces the old health-row card.
- SystemHealthSection reduced to a pure 4-card health grid (no overlay dep).
- OverlayPanel: dropped the status pill; just the enable checkbox plus
  geometry readout and Reset position/size.
- Unify OverlayInteractionController's PositionCommitted + SizeCommitted
  into a single BoundsCommitted event. Two back-to-back writes for the same
  config type were being clobbered by ConfigWriter's debounce on resize
  gestures, which left the OverlayPanel showing stale Width x Height.
- Downgrade overlay init / first-frame / thread-start / FPS chatter from
  Information to Debug. Logs are now quiet at the default level.

* fix(ui): release GL resources on WindowManager.Close instead of Dispose

Silk.NET's GL wrapper resolves entrypoints lazily via glfwGetProcAddress,
which throws GlfwException(NoContext) once the window's context is gone.
The DI container disposes singletons after WindowManager.Run returns —
i.e. after the context is already torn down — so SubtitlePreviewRenderer
now hooks WindowManager.Close to free GL resources while the context is
still current, with Dispose as a best-effort fallback.

The lower-level GL wrappers (OffscreenBuffer, BufferObject,
VertexArrayObject) drop their try/catch+CheckError dance — glDelete* is
spec'd to no-op on unknown/zero handles and throwing from Dispose would
abort the rest of the DI disposal chain.

* refactor(config): move Vision enable flag into LlmOptions

VisionConfig.Enabled removed; LlmOptions.VisionEnabled takes its place so
everything about the vision LLM lives in one panel. VisualQAService now
reads the new flag.

* refactor(vision): drop unused _visionConfig field in VisualQAService

VisualQAService only ever read the vision-enabled flag; now that the flag
lives on LlmOptions, VisionConfig is not read here at all. Dropping the
now-dead field.

* feat(ui): StatusPill accepts optional overrideLabel

Keeps the colour / pulse / ring coming from OverlayStatus, lets callers
supply domain-specific text. Existing callers pass null and get the old
label. Used by the upcoming LLM Connection panel for 'Ready',
'Model not found', 'Auth failed', etc.

* docs(ui): clarify StatusPill overrideLabel semantics + theory tests

Adds the <param> tag to the convenience overload, documents that empty
strings are a legitimate dot-only label on ResolveLabel, and collapses
the three label-resolution tests into one [Theory] to match the rest of
the UI test suite.

* feat(health): add ISubsystemHealthProbe + NavRequestBus scaffolding

* docs(health,nav): document SubsystemStatus params + probe bootstrap contract

Clarifies the Label/Detail split so probe authors don't each invent their
own convention. ISubsystemHealthProbe.Current is now called out as the
bootstrap surface after subscribing to StatusChanged. NavRequestBus gains
a one-line summary noting the single-thread assumption.

* feat(llm): live LLM connection probe with /models reachability

* refactor(llm): harden LlmConnectionProbe + document public surface

Replaces the no-op FireAndForget helper with a ContinueWith(OnlyOnFaulted)
log observer so background re-probe exceptions surface instead of vanishing.
Caller-cancellation mid-probe now resets the channel to Unknown so the
Probing state is never leaked. Adds a lock around _text / _vision to
prevent torn reads of the ~32-byte LlmProbeResult struct from the UI
thread. Documents all four public surfaces with XML summaries.

* feat(llm): hot-reloadable Kernel gated by conversation-idle

LlmKernelProvider owns the Kernel and rebuilds it when LlmOptions change.
KernelReloadCoordinator fires SafeToReload on transitions into
ConversationState.Idle so in-flight turns finish against the kernel they
started with. Swap is Volatile.Write — no lock on the hot read path.

Adds minimal StateChanged event to IConversationSession (raised from the
Stateless OnTransitioned callback) and an aggregating StateChanged on
IConversationOrchestrator that forwards every session's transitions.

* fix(llm): harden kernel hot-reload against threading races

KernelReloadCoordinator: guard state with lock; fire SafeToReload edges
once per transition into Idle.

LlmKernelProvider: serialise ApplyPending behind a CompareExchange gate
and drain pending in a loop so edits arriving mid-build still coalesce
into a single rebuild.

ConversationSession: dispatch StateChanged subscribers via Task.Run so
the FSM callback thread is immune to subscriber-thrown triggers or
exceptions (previous sync invocation would deadlock Stateless if a
subscriber fired another trigger).

* fix(conversation): serialise StateChanged dispatch in FSM order

Task.Run dispatch decoupled subscribers from the FSM callback thread
but dropped ordering, which could misfire edge-triggered listeners
(e.g. KernelReloadCoordinator firing SafeToReload mid-turn after a
rapid Idle→Listening→Idle churn). Chain subscriber invocations onto
a single continuation tail so they run sequentially in emission
order while still off the FSM thread.

* refactor(llm): chat engines consume ILlmKernelProvider

IChatEngine / IVisualChatEngine implementations now capture
_kernelProvider.Current.GetRequiredService<IChatCompletionService>
at the top of each streaming call instead of holding a DI-injected
Kernel singleton. AddLLM registers the provider + coordinator +
HttpClient + ILlmConnectionProbe and drops the Kernel singleton.

* docs(llm): clarify why kernel capture sits outside the retry loop

* feat(health): LlmHealthProbe adapts ILlmConnectionProbe for Dashboard

* fix(health): harden LlmHealthProbe against concurrent OnChanged

Inner LlmConnectionProbe has independent per-channel gates, so text and
vision StatusChanged can fire on different threads simultaneously.
Guard _current with a lock, make Dispose idempotent, and invoke
StatusChanged outside the lock to avoid subscriber re-entrancy.

Add missing test coverage for Probing transitions, vision ModelMissing
/ InvalidUrl, and the defensive text-Disabled fall-through.

* feat(health): MicrophoneHealthProbe observes device presence

Adds minimal CurrentDeviceName / AvailableDevices read-only surface on
IMicrophone so the probe can adapt to SubsystemStatus without the
probe doing any device enumeration itself.

MicrophoneHealthProbe polls on a 1-second timer plus OnChange, using
the same lock-guarded current-status pattern as LlmHealthProbe.

* fix(health): reconcile MicrophoneHealthProbe Compute with spec (missing device -> Degraded)

* feat(health): TtsHealthProbe observes ITtsEngine readiness

* feat(health): SpoutHealthProbe reports configured vs active senders

* feat(ui): dashboard health cards driven by ISubsystemHealthProbe

Replaces the hard-coded OK array with one card per registered probe.
Cards are clickable - clicking navigates to the probe's TargetPanel via
INavRequestBus.

* fix(ui): claim health card hit region before drawing text

GetItemRectMin/Max returned stale rect inside the Card child scope,
mis-routing clicks and tooltips. Claim the hit region with
InvisibleButton at the captured origin first, matching the
PresenceStripSection pattern.

* feat(ui): TextLlmSection with live probe + preset chips + scanned model combo

* feat(ui): VisionLlmSection with ToggleSwitch header and collapsed off-state

* feat(ui): LlmConnectionPanel orchestrator + DI wiring

Replaces the flat legacy LlmConnection panel with a thin orchestrator
over TextLlmSection + VisionLlmSection. Registers the new sections
and panel in DI; drops the legacy class.

* refactor(ui): ScreenAwareness links to LLM panel for vision toggle

The enable-vision flag now lives solely on LlmOptions.VisionEnabled, set
from the LLM Connection panel. Screen Awareness shows a muted hint + a
button that jumps to that panel via INavRequestBus. Subscribes to
IOptionsMonitor.OnChange so the flip is reflected immediately; class
becomes IDisposable to release the subscription.

* refactor(startup): drop LLM validation from startup

Moved live into the LLM Connection panel via ILlmConnectionProbe. The
other startup checks (GPU/CUDA/models/espeak/Live2D/prompt) stay — those
are environmental and still the right place to hard-fail.

* fix(di): break LLM kernel provider resolution cycle with Lazy<T>

Singleton resolution deadlocked silently on startup because
ILlmKernelProvider sat in a cycle it couldn't see at validation time:
LlmKernelProvider -> IKernelReloadCoordinator -> IConversationOrchestrator
-> IConversationSessionFactory -> IChatEngine -> ILlmKernelProvider.

The factory-lambda registration hid the inner edges from MS DI's cycle
detector, so instead of throwing it blocked the resolver thread and the
UI never came up.

Break the cycle by having the chat engines take Lazy<ILlmKernelProvider>
and defer the .Value access to first use (after the graph is built).

* fix(ui): guard zero-size InvisibleButton in health cards

ImGui asserts on zero-size InvisibleButton. SystemHealthSection.RenderCard
called GetContentRegionAvail() inside the card scope, which returns (0, 0)
on the first frame before column layout has settled, tripping
imgui_widgets.cpp:834 and crashing the app immediately after DI resolved.

Skip the hit region and tooltip when the available size is zero; the card
still draws and picks up interactivity on the next frame.

* feat(ui): add IUiThreadDispatcher for marshalling off-thread callbacks

* fix(ui): restore UiThreadDispatcher MaxPerFrame=64 and clarify docs

* feat(conversation): add Cancelled state and cancel/retry triggers

* fix(conversation): ignore input + duplicate cancel triggers in Cancelled state

* feat(conversation): allow Error -> Idle via RetryRequested trigger

* feat(conversation): add CancelAsync and RetryAsync on IConversationSession

* feat(conversation): add CancelActiveTurnsAsync and RetryErroredSessionsAsync orchestrator APIs

* feat(conversation): add IMicMuteController over IConversationInputGate

Add Muted variant to SubsystemHealth enum, define IMicMuteController interface,
implement MicMuteController wrapping IConversationInputGate scope for idempotent
mute toggling, and add 4 unit tests covering scope acquisition, disposal, event
firing, and thread safety.

* feat(health): MicrophoneHealthProbe reports Muted when user has muted input

Register IMicMuteController as a singleton in AddConversationPipeline.
Inject IMicMuteController into MicrophoneHealthProbe; short-circuit status to
SubsystemHealth.Muted on mute toggle via OnMuteChanged, and unsubscribe in Dispose.
Update existing probe tests to pass the new parameter; add one Muted scenario test.

* chore(health): remove unused SpoutHealthProbe and related references

Deletes SpoutHealthProbe.cs and its test file, removes the TODO comment
from ServiceCollectionExtensions.cs, and updates the SystemHealthSection
doc comment (Mic → LLM → TTS). Layout is dynamically driven by the
ISubsystemHealthProbe enumerable so no column-count adjustment was needed.

* feat(ui): add Dashboard ControlsSection (cancel, retry, mute)

* feat(ui): integrate ControlsSection into Dashboard as 5-row layout

* fix(ui): propagate Dispose from ControlPanelComponent to owned panels

* fix(ui): marshal LLM section probe/options callbacks via UI dispatcher

* feat(ui): add SubsystemStatusChip shared widget

Introduces SubsystemStatusChip, a static widget that centralises the
SubsystemHealth → OverlayStatus mapping and delegates rendering to
StatusPill with broadcast-style visuals. Extends OverlayStatus with
three new values (Degraded, Muted, Unknown) and adds corresponding
StatusPill.StatusVisuals cases. Dashboard health cards and LLM section
headers (Tasks 14–15) will call this widget instead of duplicating the
mapping inline.

* refactor(ui): route health cards through SubsystemStatusChip

* refactor(ui): route LLM section pills through SubsystemStatusChip

* feat(ui): add EndpointPickerRow shared widget

* refactor(ui): consume EndpointPickerRow from LLM sections

* feat(ui): add ScannedModelPicker shared widget

* refactor(ui): consume ScannedModelPicker from LLM sections

* fix(ui): keep vision toggle visible and interactive when section is disabled

Always render the header status chip, synthesising a Disabled/Off status
when VisionEnabled is false so the toggle has a visual anchor. Previously
the chip was hidden in the off state and the toggle sat alone at the far
right edge of the card, easy to miss. The probe does not run while the
channel is disabled, so relying on _probe.VisionStatus for the off state
would show an ambiguous '?' — synthesising the status explicitly is the
source of truth here.

* fix(session): keep Error state durable so Retry works

HandleErrorAsync used to auto-fire StopRequested, collapsing Error to Ended
in the same tick — so the Retry card could never observe the Error state
and remained non-interactive. Error is a durable recovery state the user
exits via RetryRequested (configured in ConfigureStateMachine); removing
the auto-fire restores that contract.

* fix(startup): flush pre-abort llama logs + unhandled exception sink

llama.cpp/ggml call abort() shortly after emitting an error, so the managed
Console buffer was being discarded and the diagnostic line never reached
the log. Flush Console.Out/Error synchronously on the Error path so the
last words survive the abort.

Also move CreateLogger ahead of the NativeLibraryConfig callback so the
Serilog sink exists before llama.cpp can emit anything, and install
AppDomain.UnhandledException + ProcessExit sinks that Log.Fatal the
exception and CloseAndFlush so crashes always hit disk.

* fix(health): keep LLM card Healthy when vision is unprobed

Text channel is load-bearing — if text is Reachable but vision is Unknown
(channel disabled in config, or still in flight on the first frame) the
card rendered as a grey 'Ready' which reads as 'something is broken.'
Treat (Healthy, Unknown) as overall Healthy; vision's own panel still
surfaces its real status. Added two InlineData rows covering the
regression plus a comment explaining the 'Ready but grey' failure mode.

* feat(ui-layout): add Sz.Auto row mode with measured-height caching

Layouts can now mix three row modes in one group: Fixed (explicit px),
Auto (open an AutoResizeY child and let content dictate the height), and
Fill (claim whatever remains). RowGroupScope caches each auto row's
measured height per group id so Fill rows can reserve accurate space
across frames without needing callers to hard-code section heights.

* refactor(ui-dashboard): size sections to content, cards signal affordance

Dashboard drops the hard-coded HealthSectionHeight / ControlsSectionHeight
/ StatsSectionHeight constants and lets every section except the chat
transcript use Sz.Auto; the transcript claims remaining space via Fill.
Health strip and transcript share a nested gap-0 row group so they still
read as one conversation surface.

Control cards now use semantic color: disabled falls back to flat Surface2
neutral, enabled carries an accent-tinted fill + border (thicker on
hover/active) so clickability is obvious at a glance rather than a 2%
alpha delta. Guards zero-size frames defensively the same way the health
cards do.

* refactor(ui-llm): unify preset picker on BeginCombo, fix panel cutoffs

EndpointPickerRow now uses the same BeginCombo + Selectable widget pattern
as ScannedModelPicker (one idiom for both dropdowns). The 'Presets' label
is kept as the combo's preview string — there is no canonical 'selected
preset' to show since users may edit the URL freely. Presets are shared
via EndpointPickerRow.DefaultPresets so Text and Vision sections stop
duplicating the OpenAI / LM Studio / Ollama list.

Also fixes a set of right-edge cutoffs: InputText now reserves explicit
width for every trailing button/combo (presets combo, Hide/Show key
button) instead of relying on SetNextItemWidth(-1), and the Vision header
captures GetContentRegionAvail AFTER SameLine so its toggle + chip layout
math matches the working TextLlm pattern. Toggle is pulled up by
FramePadding.Y so its self-centered track visually aligns with the chip's
midline, without pushing the chip around.

ScannedModelPicker uses '##model' (hidden id) to stop ImGui painting a
visible label to the right of the combo past the card's edge, and drops
the 'advanced custom model name' free-text fallback — the dropdown is the
only path.

* perf(rvc): own destination buffer, single-pass chunk loop, cache audition models

ProcessChunk now writes into a caller-owned Memory<float> destination and
returns the produced sample count, eliminating the per-segment new float[]
allocation. ProcessInChunks computes the total upper bound in one pass,
allocates a single backing array, and writes successive chunks at offsets,
removing the List<Memory<float>>, the LINQ Sum, the CombineChunks helper,
and the redundant Array.Copy.

The voice-audition path used to construct a fresh RmvpeOnnx + OnnxRVC pair
on every call (multi-second ONNX session init). Added a bounded LRU cache
(capacity 2) keyed by voice name + hop size, drained on dispose.

Closed a Dispose race in OnOptionsChanged: the new predictor/model pair is
disposed if _disposed flips to true between background load and write-lock
acquisition, instead of leaking the freshly-built sessions.

Marked _currentOptions volatile so MinimumSampleCount's lock-free read sees
the published reference rather than a cached register value.

* fix(qwen3): observe cancellationToken in GenerateCodeFrames

The decode loop now accepts a CancellationToken and checks it at the top
of every step. Previously a cancelled turn would keep generating until
maxSteps or natural EOS, wasting GPU time and delaying context release.
Reset() in the finally still clears the talker KV cache, so cooperative
cancellation is safe.

* perf(audio): pool mic scratch, publish amplitude with Volatile, drain on dispose

MicrophoneInputNAudioSource: replaced the single _samplesScratch field
(grown on the capture thread via Array.Resize, racing against any UI
reader of the prior buffer) with an ArrayPool<float> rental per
DataAvailable callback. The IMicrophone.AudioSamplesHandler contract
already says the span is only valid for the call, so no lifetime change.

MicrophoneAmplitudeProvider: _smoothed is written from the capture
callback thread and read from the UI thread; the read/write are now
Volatile so the UI sees a recently-published value rather than a cached
register. Added a volatile _disposed flag and an early-out in
OnSamplesAvailable, since NAudio may invoke subscribers for an in-flight
buffer after unsubscribe.

* fix(state-machine): ignore Cancel/Retry triggers in no-op states

Adds .Ignore(CancelRequested) and .Ignore(RetryRequested) to every state where
firing the trigger is a benign no-op: Initial, Initializing, Idle, Listening
(substate of Idle), Interrupted, Paused, Cancelled (both), plus RetryRequested
on ActiveTurn (covers all turn substates). Also extends Cancelled with
.Ignore(PauseRequested).

With the FSM now the single source of truth, the TOCTOU pre-checks in
ConversationSession.CancelAsync and RetryAsync (IsInState(...) → FireAsync)
are removed. Previously a turn completing between the two calls would fire
Cancel/Retry into Idle, trip OnUnhandledTriggerAsync, and push the session
into Error.

* fix(session): keep Cancelled state durable if teardown throws

HandleCancelledAsync now wraps the pipeline teardown in try/finally and fires
CancelComplete unconditionally. If the teardown throws, the exception is
logged but the FSM still lands back in Idle.

Rationale: Cancel is user-initiated. Leaking a pipeline fault into Error
would punish the user for an internal problem on a path they explicitly
asked to abort. Getting stuck in Cancelled (the prior behaviour) was worse —
no trigger out of Cancelled except CancelComplete/StopRequested/Error, and
the user has no UI affordance to force them.

* fix(conversation): subscribe to session.StateChanged before RunAsync

The orchestrator previously called session.RunAsync(...) and started
HandleSessionCompletionAsync before wiring up StateChanged, so the initial
Initializing → Idle transition could fire before the subscription existed
on fast-init paths (the UI then missed the first state edge).

Subscription now happens immediately after session creation, before
RunAsync is invoked. All failure paths (TryAdd collision, OperationCanceled,
generic exception) unsubscribe before disposing, keeping the
subscribe/unsubscribe pair symmetric with HandleSessionCompletionAsync.

* fix(conversation): invoke MicMuteController.MutedChanged under the lock

Two near-simultaneous SetMuted calls could serialise the scope change but
deliver the corresponding events in reverse order, because the event was
raised after the lock released. The invocation now happens inside the lock.

Subscribers therefore run on the caller's thread while the lock is held —
acceptable here because (a) mute toggles are rare, (b) current subscribers
are cheap (MicrophoneHealthProbe.Refresh uses its own lock and doesn't
re-enter the mute controller; ControlsSection marshals via IUiThreadDispatcher).

* fix(llm): close hot-reload races, dedup probe events, pool HTTP via factory

- LlmKernelProvider.ApplyPending now re-checks IKernelReloadCoordinator.IsSafeToReloadNow
  at the top of each drain iteration AND immediately before publishing the rebuilt
  Kernel. SafeToReload fires outside the coordinator's lock, so state can flip
  Idle->Busy in the gap; the second-gate check prevents a kernel swap landing
  mid-turn. Pending options are restored when the post-Build gate closes so the
  next idle transition retries from the same baseline.
- LlmKernelProvider now disposes the previous Kernel.Services after a 30s grace
  period (covers the 60s Polly LLM timeout with margin). Kernel itself is not
  IDisposable, but Kernel.Services is the DI ServiceProvider that owns
  OpenAIChatCompletionService -> OpenAIClient -> HttpClient. Each replaced kernel
  schedules its own disposal independently — no shared slot to coordinate.
- LlmConnectionProbe.OnOptionsChanged holds _syncRoot across the snapshot
  read+swap so concurrent change-callbacks can no longer both observe the same
  prev and skip a re-probe.
- Switch from a singleton HttpClient (DNS pinned forever) to
  IHttpClientFactory.CreateClient("llm-probe"). DI registers AddHttpClient with
  the same 5s timeout; SocketsHttpHandler rotation is now the factory default
  (2 min). Constructor signature changes from HttpClient to IHttpClientFactory;
  test stub updated.
- LlmConnectionProbe.Store() dedupes by (Status, AvailableModels) so re-probes
  that yield the same surface no longer fire StatusChanged. ILlmConnectionProbe
  XML doc updated to match.
- On OperationCanceledException mid-probe, no longer overwrite the stored result
  with Unknown — leaves the prior status visible instead of flapping the UI grey
  to "Not tested" before the next probe lands.

* fix(health): dedup on surface, drain mic timer before unsubscribe

- LlmHealthProbe.OnChanged now compares (Health, Label) only via SameSurface,
  ignoring Detail. Detail is built fresh per probe and can carry probe-local
  information; gating dedup on it would silently break and storm StatusChanged.
  SubsystemStatus.Equals (record-struct full equality) is left intact for
  callers that want it.
- MicrophoneHealthProbe.Dispose now stops the timer with Change(Infinite),
  blocks on Timer.Dispose(WaitHandle) until any in-flight callback returns,
  THEN unsubscribes from MutedChanged and disposes the options subscription.
  A callback already running Refresh() would otherwise read a stale
  muteController if unsubscribe came first.

* fix(ui): guard SystemHealthSection.Dispose against double-dispose

Add _disposed flag matching the pattern in ControlsSection so the
section can be torn down multiple times without unsubscribing from
probe.StatusChanged twice. Required so the next change — disposing the
section from Dashboard.Dispose AND from the panel-registration teardown
— is safe by construction.

* refactor(ui): make ControlPanel registration disposal-aware

Extend PanelRegistration with an optional IDisposable so RegisterPanel
can declare both lifecycle-aware and disposable panels in a single call.
ControlPanelComponent.Dispose now iterates the registration dictionary
and disposes any non-null Disposable, replacing the previous hard-coded
field forwarding to Dashboard / LlmConnectionPanel / ScreenAwareness.

This makes future panels disposal-safe by construction: opting in is one
named argument on RegisterPanel rather than a field + Dispose() update
in three places.

All currently-disposable panels (Dashboard, LlmConnectionPanel,
ScreenAwareness, Personality, Listening, Voice, Avatar, Subtitles) are
re-registered through the new overload. Listening continues to declare
its IActivatablePanel role too — disposal and activation are orthogonal
axes.

* fix(ui): propagate Dispose to AvatarPanel children

ModelSection and LipSyncSection both register IOptionsMonitor.OnChange
callbacks (Live2DOptions and LipSyncOptions respectively); when the
control panel was torn down those subscriptions stayed live, so option
changes kept invoking handlers on a logically-gone panel.

AvatarPanel now implements IDisposable with a _disposed guard and
forwards Dispose() to both child sections. The control panel's
disposal-aware registration calls it on shutdown.

* fix(ui): propagate Dispose to SubtitlesPanel children

All five subtitle sections are IDisposable: TextStyleSection,
ColorsSection, PlacementSection, and CanvasSection register
IOptionsMonitor.OnChange callbacks, and PreviewSection additionally
owns a SubtitlePreviewRenderer with a GL framebuffer object. When the
control panel was torn down none of these were released — handlers
kept firing and the FBO leaked.

SubtitlesPanel now implements IDisposable with a _disposed guard and
forwards Dispose() to all five sections. The control panel's
disposal-aware registration calls it on shutdown.

* fix(ui): propagate Dispose to ListeningPanel children

All four listening sections (MicrophoneDeviceSection,
SpeechDetectionSection, RecognitionSection, InterruptionSection) hold
IOptionsMonitor.OnChange subscriptions; when the control panel was
torn down those handlers stayed live.

ListeningPanel now implements IDisposable in addition to its existing
IActivatablePanel role, with a _disposed guard, and forwards Dispose()
to all four child sections. The activation-lifecycle gate scope in
OnActivated/OnDeactivated stays as-is — it covers a different axis
(panel-active vs panel-alive).

* fix(ui): dispose SystemHealthSection from Dashboard teardown

Dashboard.Dispose only tore down ControlsSection, so SystemHealthSection
kept its probe.StatusChanged subscriptions live after shutdown.

Dispose SystemHealthSection alongside ControlsSection and guard Dispose
with a _disposed flag for idempotence. The remaining three sections
(PresenceStripSection, TranscriptSection, SessionStatsSection) are not
IDisposable — audited; nothing to release.

* fix(ui): propagate Dispose to PersonalityPanel and VoicePanel children

Same leak class as the Avatar/Subtitles/Listening panels: every section
under PersonalityPanel (PromptSourceSection, CurrentVibeSection,
TopicsSection) and the disposable sections under VoicePanel
(VoiceModeSelector, VoiceGallery, CloneLayerSection, AdvancedSection)
register IOptionsMonitor.OnChange callbacks that stayed live after the
control panel was torn down.

Both orchestrators now implement IDisposable with _disposed guards and
forward Dispose() to each disposable child. VoiceCard is intentionally
not disposed — it reads option values via CurrentValue only and never
subscribes to OnChange.

* style: csharpier format Wave-1 changes

* perf(ui-layout): remove per-frame allocations in row/grid scopes

RowGroupScope previously allocated a Sz[] snapshot and float[] auto-heights
array on every frame, and the shared AutoHeightCache was an unbounded
ConcurrentDictionary with no eviction. Per-row BeginChild ids were built via
interpolation using the cursor-Y/height hash, which is per-row-per-frame
string work.

- Hold ReadOnlySpan<Sz> directly in the ref struct (caller's stack).
- Reuse the cached auto-heights array in place via a single lookup; writes
  land in the cache slot so there is no copy-back on Dispose.
- Replace ConcurrentDictionary with a Dictionary + lock and bound it at
  256 entries via drop-oldest insertion-order eviction (LinkedList<string>
  + node map for O(1) removal). The layout system only runs on the UI
  thread so the lock is trivial; the bound exists to catch misuse (varying
  ids) rather than as a design point.
- Pre-compute RowIds[0..15] and use the row index for stable BeginChild
  ids; fall back to i…
…ease (#22)

* chore: remove dead files and clean up stale comments

- Delete fully commented-out IStreamingAudioPlayer.cs (unused interfaces)
- Delete fully commented-out AdapterManagerService.cs (P2 dead code)
- EmotionAnimationService: drop stale TODO and speculative self-musing comments
- IdleBlinkingAnimationService: replace dead-code block with single factual note,
  remove "redundant checks, but safe" self-deprecation

* chore(bootstrapper): scaffold PersonaEngine.Lib.Bootstrapper project

* test(bootstrapper): scaffold test project with smoke test

* feat(assets): add manifest enums + AssetId/FeatureId record structs

* feat(bootstrapper): AssetSource discriminated union + JSON converter

* feat(bootstrapper): AssetEntry + AssetCapability + InstallManifest records

* feat(bootstrapper): InstalledAssetRecord + InstallStateLock records

* feat(bootstrapper): ManifestLoader with embedded resource + schema-version guard

* feat(bootstrapper): atomic InstallStateLockStore with corruption recovery

* feat(bootstrapper): Sha256VerifyingStream + IntegrityException

* feat(bootstrapper): IAssetSource abstraction + AssetDownload record

* feat(bootstrapper): ZipExtractor with selective extraction + zip-slip guard

* feat(bootstrapper): NvidiaRedistManifest parser with size-as-string-or-number support

* feat(bootstrapper): NvidiaRedistClient with per-instance manifest cache

* feat(bootstrapper): HuggingFaceClient with HF_ENDPOINT override + tag-only revisions

* feat(bootstrapper): AssetDownloader with Range-resume + atomic rename + verify

* feat(bootstrapper): named AssetDownload HttpClient with Polly retry policy

* feat(bootstrapper): AssetAction + AssetPlanItem + AssetPlan types

* feat(bootstrapper): AssetPlanner with profile/exclusion/mode rules

* feat(assets): IAssetCatalog interface + FeatureIds constants + UserAsset

* feat(assets): debounced UserContentWatcher

* feat(assets): AssetCatalog with FrozenDictionary feature cache + user-content enumeration

* test(assets): build-time manifest validation + FeatureIds coverage

* feat(bootstrap): mode/options/result carriers

* feat(bootstrap): IBootstrapUserInterface abstraction

* feat(bootstrap): profile picker copy as static catalog

* feat(bootstrap): BootstrapRunner orchestrating manifest→plan→download→catalog

* fix(bootstrap): align Phase 7 with spec verbatim

- BootstrapMode: replace doc comments with spec-exact text
- BootstrapResult/BootstrapOptions: add per-property doc comments per spec
- IBootstrapUserInterface: replace redesigned interface with spec contract
  (PickProfileAsync+choices list, ShowPlanSummary, RunWithProgressAsync,
   ShowResult); move ProfileChoice record here, replacing ProfileChoiceEntry
- ProfileChoiceCatalog: use ProfileChoice record, spec-verbatim copy for all
  three profiles (titles/sizes/taglines/bullets exactly as spec)
- BootstrapRunner: remove extra resourceRoot ctor param; use spec's
  RunWithProgressAsync/ShowPlanSummary/ShowResult UI calls; extract
  ResolveProfileAsync and BuildUpdatedLock helpers per spec
- BootstrapRunnerTests: replace with 4 spec scenarios (no_lock_runs_picker,
  complete_lock_skips, offline_failure, preselected_overrides_picker)
- ProfileChoiceCatalogTests: rename to spec-exact method names, assert
  choice.Profile (not .Tier), include SizeLabel.Contain("GB")

* fix(bootstrap): add missing test assertions per spec

* fix(bootstrap): filter plan.Items by action when writing lock and dispatching downloads

- BootstrapRunner.RunAsync now pre-filters plan to only Download/Redownload items
  before calling RunWithProgressAsync, so Skip/Remove/Reverify items are never
  dispatched to the downloader (Critical #1).
- BuildUpdatedLock now uses a switch on AssetAction: writes new records for
  Download/Redownload/Reverify, removes entries for Remove, and leaves Skip
  entries untouched — fixing the silent re-write-on-Remove and skip-bump bugs
  (Critical #2).
- Offline error message now filters to non-Skip items only (Important #3).
- Added deliberate-fallback comment in ResolveProfileAsync for Verify/Repair
  with no existing lock (Important #4).
- Documented _catalog field (kept for DI shape) and ResourceRootOverride
  (consumed by Phase 9 host wiring) (Important #5).
- Restored class-level XML doc on BootstrapRunner (Important #6).
- Added doc comment to IBootstrapUserInterface.RunWithProgressAsync clarifying
  pre-filtered items contract (part of Critical #1 fix).
- Strengthened AutoIfMissing_with_no_lock test with lock-content assertions (Test #8).
- Added BuildUpdatedLock_removes_out_of_profile_asset_and_writes_downloaded_asset
  regression test (Test #7). All 49 tests pass.

* feat(bootstrap): CommandLineArgs parser for installer flags

* feat(bootstrap): Spectre.Console picker + progress UI

* feat(bootstrap): non-interactive UI implementation

* fix(bootstrap): rewrite Phase 8 to match spec

* fix(bootstrap): propagate cancellation and assert pass-through ordering

* feat(app): run bootstrapper before DI, register IAssetCatalog as singleton

* feat(di): register IAssetCatalog in AddApp via AssetCatalogFactory

* feat(di): gate RVC/Vision/Audio2Face on IAssetCatalog.IsFeatureEnabled

Refactor AddConversation to probe IAssetCatalog before registering optional
subsystems whose ONNX models / runtime DLLs are owned by the bootstrapper:
RVC voice cloning (FeatureIds.VoiceCloning), Audio2FaceLipSyncProcessor
(FeatureIds.Audio2Face), and the visual chat engine + screen capture
(FeatureIds.LlmVision). Splits AddChatEngineSystem so the always-required
text engine stays unconditional while IVisualChatEngine, IVisualQAService,
and WindowCaptureService are extracted into a new AddVisualChatEngine
extension that only registers when the vision tier was installed. Removes
the unconditional Audio2FaceLipSyncProcessor registration from
AddTTSSystem; LipSyncProcessorProvider remains so the empty-list case
still resolves cleanly. Uses a temporary BuildServiceProvider() probe
disposed before the real graph is built so its singletons do not leak.

* feat(ui): voice/avatar pickers source from IAssetCatalog with live refresh

ModelSection (Live2D) and CloneLayerSection (RVC) now enumerate available
characters/voices via IAssetCatalog.GetUserAssets instead of poking the
disk directly or asking IRVCVoiceProvider. Both sections subscribe to the
catalog's Changed event so dropping a new model into live2d/ or a new RVC
model into rvc/voices/ refreshes the picker without restarting the UI —
the catalog's debounced FileSystemWatcher already covers that signalling.
ModelSection no longer scans Live2DOptions.ModelPath; the catalog owns the
canonical user-content roots derived from the install manifest. Kokoro
voice gallery is unchanged: it is driven by the embedded VoiceMetadataCatalog
(static metadata), not disk enumeration.

* refactor(startup): drop model-existence probes (now owned by IAssetCatalog)

CheckModels and CheckLive2D duplicated work the bootstrapper already does
before this validator runs: it provisions every required asset and the
runtime IAssetCatalog reports authoritative state via GetAssetState /
IsFeatureEnabled. Removes both probes plus the now-unused ModelsDir field
and updates the type doc comment to call out the new division of labour.
Retains GPU/CUDA/espeak-ng/prompt checks: those validate environment
prerequisites the bootstrapper does not (and cannot) install.

* fix(bootstrap): eliminate temp provider probe and marshal catalog events to UI thread

* chore: stage local-only asset source tree

* tools: hash-manifest.ps1 to refresh manifest SHA256 from assets-source/

* tools: upload-hf-assets.ps1 for tagged HuggingFace publish

* feat(manifest): populate install-manifest.json with all required assets

* refactor: unify runtime asset layout under <BaseDir>/Resources/<subsystem>

ModelUtils, TtsConfiguration.ModelDirectory, and Live2DOptions.ModelPath
now resolve under AppContext.BaseDirectory/Resources/<lowercase-subsystem>
to match the canonical layout the installer writes via install-manifest.json.

Lexicon now reads from kokoro/lexicons/ so its files live under the kokoro
subsystem rather than at the ModelDirectory root.  ModelType enum
descriptions carry their subsystem prefix (silero-vad/, whisper/,
profanity/) so each consumer resolves to the same canonical path.

This fixes the divergence where bootstrapper-installed assets landed at
lowercase paths but runtime consumers still read from legacy mixed-case
Resources/Models and Resources/Live2D/Avatars locations.

* refactor: align install-manifest.json with runtime asset layout

Rewrite installPaths to drop the models/ and runtime/ prefixes so they
resolve under <BaseDir>/Resources/<subsystem>/ matching the unified
runtime layout.

Add missing asset entries the runtime requires for a clean install:
  - silero-vad         (gates Asr.WhisperTiny + Asr.WhisperTurbo)
  - profanity-bundle   (gates Tts.ProfanityBeep)
  - opennlp-bundle     (gates Tts.Kokoro + Tts.Qwen3)
  - kokoro-lexicons    (gates Tts.Kokoro)
  - rvc-rmvpe          (gates Tts.Rvc)
  - mdx-bundle         (gates Music.SourceSeparation)
  - mel-band-roformer  (gates Music.SourceSeparation)

Add corresponding FeatureIds.MusicSourceSeparation and Live2DAvatar so
the FeatureIdsCoverageTests assertion still passes.  Fix whisper-tiny
filename to ggml-tiny.en.bin (the actual file the runtime expects).

* chore: polish asset helper scripts and document assets-source layout

hash-manifest.ps1
  - Validate manifest + assets-source paths up front
  - Refuse to write the manifest while any HuggingFace asset is missing
    under assets-source/ (lists each missing entry by id)
  - Skip NVIDIA assets silently
  - Add -WhatIf preview support
  - Print summary: number of assets hashed, total size, files updated

upload-hf-assets.ps1
  - Validate huggingface-cli presence with a clear remediation message
  - Echo repo + revision + source path before uploading
  - After tagging, print the resulting tree URL
    (https://huggingface.co/<repo>/tree/<revision>) for verification

assets-source/README.md
  - Document why the directory is gitignored
  - Add a full layout tree mapping local paths to manifest entry ids
  - Walk through the contributor workflow: drop -> hash -> commit -> upload
  - Note the no-overwriting-tags rule for reproducible installs

* fix(live2d): resolve ModelPath against BaseDirectory + align default to aria

C1: Live2DManager.Initialize() now joins relative ModelPath against
AppContext.BaseDirectory before handing it to LAppLive2DManager, matching
the BaseDirectory-relative resolution that TtsConfiguration.ModelDirectory
and StartupValidator.CheckPrompt already use. Without this fix the path
resolved against Environment.CurrentDirectory, which differs from the
install root when launched outside the bin folder.

C2: Live2DOptions.ModelName default changes from 'angel' to 'aria' to
match the live2d-aria asset bundle the installer drops at
Resources/live2d/aria. The shipped appsettings.json already used 'aria';
this aligns the code default so a clean install boots without the loader
failing on a missing 'angel' folder.

I4: FeatureIds.Live2DAvatar and FeatureIds.MusicSourceSeparation are
manifest-only sentinels (no conditional DI). Documented intent so future
readers don't wire them or treat them as dead code.

* fix(build): align dev-tree Resources layout with installer canonical paths

C3: csproj copy-to-output globs now mirror the per-subsystem layout the
bootstrapper writes to <BaseDir>/Resources/ (silero-vad, whisper, kokoro,
qwen3-tts, wav2vec2, opennlp, rvc, audio2face, mdx, mel_band_roformer,
profanity, live2d). Previously the globs targeted Resources/Models/** and
Resources/Live2D/Avatars/**, which copied to bin/.../Resources/Models/...
and bin/.../Resources/Live2D/Avatars/... — paths the runtime hadn't read
from since commit a3bb102 (where ModelUtils, TtsConfiguration, Lexicon
all moved to lowercase per-subsystem roots). Result: F5/dotnet run loaded
the previous build's cached files and broke after any Resources change.

The dev tree under src/.../Resources/ is gitignored so this commit only
touches the csproj globs; the matching folder rename
(Resources/Models/<sub> → Resources/<sub>, Resources/Live2D/Avatars/<m>
→ Resources/live2d/<m>, plus loose files lifted into silero-vad/,
whisper/, profanity/, kokoro/lexicons/) has to be done in the local
working copy. Rebuild verified bin/Debug/net9.0/Resources/ now contains
the same subsystem dirs the install-manifest.json declares.

I3 (folded in to keep the test suite green after the layout change):
CtcConfidenceAnalysisTests no longer hard-codes a developer-machine path.
It now derives the model base directory from AppContext.BaseDirectory
(overridable via TTS_MODEL_BASE_DIR) and skips cleanly when the optional
Qwen3/CTC models aren't present, instead of throwing in
FileModelProvider's directory-existence check.

* fix(bootstrap): preserve in-zip directory structure on extract

C4: ZipExtractor was calling Path.GetFileName(entry.FullName) and writing
every entry flat into the target directory. That broke bundles whose
runtime consumers read from subdirectories — qwen3-tts (embeddings/,
speakers/), wav2vec2 (onnx/model.onnx). Extracted assets landed at the
bundle root and FileModelProvider.GetModelPath() pointed at non-existent
nested paths.

Now the extractor preserves entry.FullName under the target directory,
recursively creates intermediate directories, and keeps both path-traversal
guards (raw '..' segment check on the unresolved name + post-resolve
StartsWith check on the absolute destination) so the zip-slip mitigation
is unchanged.

Updated Extract_only_writes_requested_entries to assert the bin/ prefix
is preserved (was previously asserting flat filenames). Added
Extract_preserves_nested_directory_structure as the regression test for
the qwen3-tts / wav2vec2 bundle shape.

* fix(bootstrap): anchor archive extraction at caller-resolved install path (I1)

Source clients used to receive the raw AssetEntry.InstallPath (a relative
path) and forward it directly to ZipExtractor, which resolves against
Environment.CurrentDirectory. That meant archives could land anywhere
depending on where the bootstrapper was launched from instead of under
the configured resource root.

Pass the absolute, resource-root-anchored install path down through
IAssetSource.ResolveAsync. PlanItemAssetDownloader now computes the
destination once and hands it to both the source client (for extraction
targets) and the inner AssetDownloader (for non-archive moves), making
the resource root the single source of truth for install location.

Tests updated to pass the new argument; added a coverage test that
proves HuggingFaceClient extracts to the resolved absolute path even
when the entry's InstallPath would otherwise resolve against CWD.

* docs(installer): deprecate manual install guide and fix hash-manifest WhatIf counter (I2+I5)

INSTALLATION.md: prepend a deprecation callout pointing users at the
in-app bootstrapper (which handles model download, SHA-256 verification,
and native-runtime install on first launch). The manual flow is kept
for reference / air-gapped installs only.

hash-manifest.ps1: $updated was being incremented unconditionally, so
in -WhatIf preview runs it counted assets that were not actually
mutated. Move the increment inside ShouldProcess and track preview
counts in a separate $wouldUpdate variable so the summary line
accurately reflects what was (or would be) written.

* tools: build-release.ps1 with bootstrap-aware Resources stripping

Strips only the subdirectories that the bootstrapper installs at first run
(whisper, kokoro, rvc, etc.), preserving baked-in shaders, fonts, prompts,
and native libraries. Hard ceiling at 1.8 GB.

* ci: release workflow that publishes, strips bootstrap dirs, zips, asserts size

* docs: rewrite install section around bootstrapper + flags

* refactor: stop bundling CUDA DLLs; pre-load bootstrapped copies at startup

Removes the ~1.7 GB Content Include for CUDA/cuDNN DLLs from
PersonaEngine.Lib.csproj. The bootstrapper already installs the same
libraries via NvidiaRedist manifest entries on first run.

Adds an explicit pre-load loop in Program.cs that walks the bootstrapped
Resources/cuda/{cudart,cublas,cufft} and Resources/cudnn directories and
LoadLibraryEx's each DLL in cudart-first order so ONNX Runtime GPU and
Whisper.net's CUDA backend resolve their imports against the pre-loaded
copies. This works around the single-directory limit of SetDllDirectory.

Reduces the release zip from ~2.1 GB to ~705 MB.

* fix: register AssetPlanner with ResourceRoot factory; implement Remove action

AssetPlanner ctor takes (string installRoot) but was registered without a
factory, causing DI to fail at startup with "Unable to resolve service for
type 'System.String'". Centralize the resource-root path as a single source
of truth used by AssetPlanner, PlanItemAssetDownloader, and the new
BootstrapRunner factory.

Implement BootstrapRunner.ApplyRemovals to delete on-disk files and
directories for plan items with AssetAction.Remove, with absolute-path and
path-traversal guards rooted at the resource root. Best-effort per-asset
error handling so a single failure does not abort bootstrap.

Add ServiceCollectionExtensionsTests that builds the full DI graph with
ValidateOnBuild=true and resolves BootstrapRunner end-to-end, preventing
regression of the original DI defect.

* ci: gate releases against placeholder all-zero SHA256 hashes

Tighten ManifestValidationTests.Every_asset_has_sha256_or_is_NativeRuntime
to also reject the all-zero placeholder hashes left in install-manifest.json
before scripts/hash-manifest.ps1 has been run. Suppress with the
PERSONA_ALLOW_PLACEHOLDER_HASHES=1 env var for local pre-release dev so the
build stays green until the real hashes are populated.

Add an explicit "Verify manifest hashes are populated" step to the release
workflow that fails fast on tag push if any placeholder hashes remain.
This ensures a v* tag cannot ship a manifest where every download fails
SHA verification.

* fix: bootstrapper hardening — lock dir creation, error logging, extract cleanup

InstallStateLockStore.Write now ensures the lockfile parent directory
exists before opening the temp file.

NoOpBootstrapUserInterface now logs per-asset download failures with the
full exception, so non-interactive callers (CI, scripts) can diagnose
"One or more assets failed to download" without having to enable
interactive mode.

ZipExtractor wraps per-entry writes in a try/catch that deletes the
partially-written destination file on cancellation or I/O failure before
rethrowing, preventing orphaned partials from accumulating across
repeated Ctrl-C runs.

* chore: replace dingbat glyphs with ASCII; drop unused PassThrough arg

SpectreBootstrapUserInterface now uses [OK]/[FAIL] instead of the Unicode
check/cross dingbats that violated the project's no-emoji-in-code rule.

CommandLineArgs no longer parses or exposes PassThrough — the property was
never read anywhere downstream. Updated the corresponding test to assert
the silent-ignore behavior that replaces it.

* docs: note that pre-installer Resources/ trees are ignored on upgrade

Adds a brief upgrade callout under the install section so users coming
from a pre-bootstrapper build know their existing Resources/Models and
Resources/Live2D/Avatars contents will be ignored and re-downloaded into
the new layout.

* feat(installer): profile-aware DI gating and CUDA 13 preload

Register ASR/TTS services based on installed feature set — BuildWithIt
gets Whisper Turbo + Qwen3, lower tiers skip the gated model factories
so DI build doesn't crash on missing .bin files. Preload cudart-v13 /
cublas-v13 alongside the existing CUDA 12 stack for Whisper.net's
ggml-cuda-whisper, which links against CUDA 13. Route bootstrap-time
Serilog through the DI logging pipeline so operators see per-asset
download failures.

- Add EspeakResolver to locate bundled espeak-ng + phontab/dict
- Add FeatureProfileMap + LockedAffordances widget for gated UI panels
- Bump install-manifest with cudart-v13 / cublas-v13 entries
- Drop bundled libsodium/opus DLLs (now delivered via asset download)

* feat(bootstrap): GPU preflight, UI polish, and asset pipeline hardening

Run nvidia-smi + nvcuda.dll probe before the profile picker so users on
unsupported hardware (driver < 580.65 or compute < Pascal 6.0) get a
warning instead of downloading ~9 GB of CUDA redistributables. Abort in
non-interactive mode; interactive users can override with C/Y/Enter or
bypass via --skip-gpu-check. Replace the standard figlet banner with a
curvy script-style PersonaEngine title and pink sparkle garland.

- Rework profile picker with keyboard tab-nav + per-profile detail panel
- Harden asset pipeline: safer zip extraction, NVIDIA redist manifest
  parsing, per-item download retries, PlanItemAssetDownloader
- Add 28 tests covering GPU preflight decision matrix + UI rendering
  (SpectreBootstrapUserInterfacePickerTests, GpuWarningTests,
  NvidiaGpuPreflightCheckTests)

* chore(release): bootstrap-aware build scripts and CI workflow

Publish now strips the pre-downloaded Resources/ tree before zipping so
the installer archive only ships binaries + manifest; the bootstrapper
fetches assets on first run. Replace the PowerShell hash-manifest with
a Python port that runs on the CI Linux runners.

- scripts/verify-imports.py: static PE import audit for self-contained
- scripts/verify-self-contained.ps1: runtime self-contained assertion
- scripts/stage-hf-assets.ps1: pre-upload staging for HF buckets
- .github/workflows/release.yml: wire the new scripts into the pipeline

* refactor(bootstrap): address code-review findings

- Drop redundant StartupValidator.CheckGpu; bootstrap preflight already
  validates driver + compute capability before this runs.
- BootstrapResult.Ok / Fail factories replace 7 duplicated new{} blocks
  in BootstrapRunner.
- AssetPlan caches IsEmpty + RequiredBytes at construction instead of
  re-walking the item list on every read.
- AssetCatalog publishes State + Feature caches atomically via
  Volatile.Write over a single Snapshot record; GetAssetState now hits
  the cache instead of re-stat'ing the filesystem on every call.
- AssetCatalogChangedEventArgs dropped — the payload was always empty
  and no subscriber used it; Changed is now plain EventHandler.
- AssetPlanner: log-and-keep orphan lock entries instead of silently
  dropping them, honor UserExclusions uniformly so a toggled exclusion
  produces a Remove against an already-locked asset, and split
  profile/exclusion/action selection into focused helpers.
- AssetDownloader: detect 200-on-Range (server ignored the header) and
  restart from scratch before appending wrong-offset bytes; File.Move
  uses overwrite:true for atomic rename.
- UserContentWatcher: dispose superseded CancellationTokenSources so
  long-running processes don't leak one CTS per filesystem event.
- SpectreBootstrapUserInterface: number-key picker shortcut via a
  single range check (TryNumberKeyToIndex) so adding a 4th profile
  doesn't require another switch arm.
- FeatureProfileMap: VisionCapture mapped to TryItOut (Rust
  screen-capture lib ships in every release — no install gate); added
  FeatureProfileMapDriftTests that caught the misleading BuildWithIt.
- BootstrapRunner takes TimeProvider for testable lock timestamps.

* refactor: finish deferred cleanup — split UI partials, extract GpuFloor, primary ctors

- SpectreBootstrapUserInterface split into 5 partial files (Theme, Picker,
  GpuWarning, Progress + main) with a new IsInteractiveTerminal() helper that
  consolidates the duplicate redirected-stdin/interactive-capability check.
- Extract GpuFloor with MinDriver / MinCompute / CompareVersion / TryParseVersion
  so the UI and tests can read the floors without pulling in the probe services.
  NvidiaGpuPreflightCheck keeps re-exports of MinDriver/MinCompute for callers
  that already referenced them.
- Lock gate in UserContentWatcher uses System.Threading.Lock (.NET 9) for a
  cheaper hot-path and compiler-enforced scoped usage.
- Primary constructors for AssetDownloader, AssetPlanner, NvidiaSmiRunner,
  NvidiaRedistClient, HuggingFaceClient, PlanItemAssetDownloader,
  InstallStateLockStore where the ctor was plain parameter-to-field wiring.
- ArgumentNullException.ThrowIfNull on the IBootstrapUserInterface public
  surface (picker, GPU warning, progress, result).

Not addressed, with rationale:
- JsonSerializerContext source-gen for ManifestLoader: the custom
  AssetSourceJsonConverter doubles the `type` field as both polymorphic
  discriminator and the record's Type property, which STJ's [JsonPolymorphic]
  does not support. Migrating would require a wire-format change that could
  invalidate existing install-state.lock.json files. The app is not trimmed or
  AOT-published, so the reflection cost is negligible (~5 ms at startup).
- Shared PartialFileWriter: AssetDownloader (HTTP stream with resume + SHA256
  verify + optional PostProcess) and ZipExtractor (per-entry copy loop) differ
  enough that a shared abstraction would be either leaky or trivial.

All 571 tests (113 bootstrapper + 458 lib) pass. CSharpier clean.

* refactor(assets): migrate manifest JSON to source-gen polymorphism

Replaces the handwritten AssetSourceJsonConverter with System.Text.Json
source-gen polymorphism. The base AssetSource record now carries
[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] with one
[JsonDerivedType] per concrete source, and a new ManifestJsonContext
(JsonSerializerContext) drives all manifest + install-lock (de)serialization.

ManifestLoader.JsonOptions is replaced by ManifestLoader.JsonContext; the
runtime Type record property is marked [JsonIgnore] so STJ writes the
discriminator exactly once. Wire format is unchanged: {"type":"NvidiaRedist", ...}.

Benefits: trim/AOT safe, no reflection-based serialization at startup,
one source of truth for the JSON options used by ManifestLoader and
InstallStateLockStore. Test coverage preserved (three roundtrip/negative
cases) and retargeted at the source-gen context.

* test(config): poll for ConfigWriter flush instead of fixed delay

AutoDiscovery_FindsAllNestedOptionsTypes was flaky on CI: six sequential
Write() calls each reset the 50ms debounce Timer, then the test slept
DebounceMs * 4 = 200ms. Under ThreadPool contention from parallel tests
the Timer callback landed outside that window, leaving LastSaveTime null
and failing the Assert.NotNull. Observed in release.yml run 24679117765
where the test wall-clock took 6s — clear sign of starved ThreadPool.

Replace the fixed sleep with a 5s polling loop on LastSaveTime. Fast path
still completes in ~50ms; CI-slow path doesn't false-fail.

* fix(config): race in ConfigWriter.Dispose crashes process on shutdown

Timer.Dispose() without a WaitHandle does not block for in-flight
callbacks. If a callback was inside the try { FlushToDisk(); } block at
the moment Dispose ran, the race was:

  1. Callback acquired _flushLock.Wait(0), ran FlushToDisk, drained
     _pendingSections, but hadn't reached finally { _flushLock.Release() }
     yet.
  2. Dispose saw _pendingSections.IsEmpty, took the else branch, called
     _flushLock.Dispose().
  3. Callback's Release() fired on a disposed SemaphoreSlim, throwing
     ObjectDisposedException on a ThreadPool thread — an unhandled
     exception that crashed the host.

Surfaced in release.yml run 24679371862 where Write_SetsLastSaveTime
aborted the test runner mid-suite.

Fix: switch to the Timer.Dispose(WaitHandle) overload so Dispose blocks
until every pending callback has actually returned, then it's safe to
Wait / Release / Dispose _flushLock unconditionally. Collapses the two
Dispose branches into one.

* test(assets): widen FileSystemWatcher first-event timeout to 10s

Fires_Changed_when_file_added gave the watcher only 2s to deliver the
first Created event. Locally that fires in <100ms, but Windows CI
runners under parallel-test load delivered it outside 2s (observed: 15s
test duration in release.yml run 24679665779). 10s keeps the assertion
meaningful — still catches a watcher that is truly not wired up — while
absorbing CI-side FSW latency.

* ci(release): seed appsettings.json from committed template

PR #21 (d92367f) removed appsettings.json from git and gitignored it for
secrets hygiene, but PersonaEngine.App.csproj still declares it as an
AdditionalFiles entry with CopyToOutputDirectory=Always, so every
'dotnet publish' in CI has been failing with MSB3030 since that merge.

Ship a sanitized appsettings.template.json (empty API keys, everything
else schema-complete) and copy it to appsettings.json before the publish
step. This also means the released zip now contains a usable schema next
to the exe — end users fill in their own keys instead of hitting a
missing-config crash on first run.

* docs: rewrite INSTALLATION.md around the in-app installer, split out CONFIGURATION.md

INSTALLATION.md was written for the pre-installer world — manual CUDA/cuDNN
tarball copy, .NET Runtime install, espeak-ng PATH setup, Whisper download,
appsettings.json hand-editing. All of that is now handled by the bootstrapper
(models + NVIDIA redists, SHA-256 verified) and the self-contained publish
(bundled espeak-ng, no runtime install). The old doc was actively misleading.

New INSTALLATION.md covers what users actually need:
  - requirements (GPU + Windows + disk + network, that's it)
  - quick start and profile picker walk-through (Try / Stream / Build with sizes)
  - "turning on the better models after Build-with-it" — Build downloads the
    assets but defaults stay on the light stack; users have to flip Voice to
    Expressive (Qwen3), Listening→Recognition to Accurate (Whisper Turbo),
    and Avatar→Lip-Sync to Realistic (Audio2Face). This wasn't documented
    anywhere and is the #1 confusion vector for Build users.
  - CLI flags (all seven: --profile, --reinstall, --repair, --verify,
    --offline, --non-interactive, --skip-gpu-check)
  - LLM + personality configuration via their dedicated panels
  - viewing the avatar: built-in overlay is the primary path now (PR #21
    added the always-on-top desktop overlay, no OBS required); Spout/OBS
    kept as the streaming path
  - building from source (uses the new appsettings.template.json seed)
  - upgrading from pre-installer builds
  - troubleshooting scoped to bootstrapper failure modes only
    (hash mismatch, network, disk, GPU gate, lock file, verify+repair loop)

Air-gapped install is dropped — if someone truly needs it they can read
the manifest and use --offline with a pre-seeded tree, but writing it up
as a supported path was overpromising.

CONFIGURATION.md is new and owns the field-by-field appsettings.json
reference (previously buried mid-INSTALLATION). Documents every field
under Config.*, including the sections that landed in PR #21 and earlier:
Tts.ActiveEngine (kokoro/qwen3), Tts.AuditionSample, Tts.Qwen3 sampling
knobs, LipSync (VBridger/Audio2Face) + Audio2FaceOptions, Overlay
geometry, ConversationContext.UseCustomPrompt, updated
Asr.VadMinSpeechDuration=150.

README.md: fix three references to PersonaEngine.App.exe — the csproj
sets <AssemblyName>PersonaEngine</AssemblyName>, so the actual exe is
PersonaEngine.exe. Users following today's README hit "file not found".
Also replace the obsolete "Manual / air-gapped install" pointer with a
"Deeper docs" section pointing at the new INSTALLATION.md and
CONFIGURATION.md, and update the features-list appsettings.json link.

* docs: restructure README around the new UI, add panel screenshots

Rework the README as a proper landing page for the post-installer /
post-UI-rework state: hero + At-a-glance strip, collapsible TOC,
profile comparison table with a "flip the switches in the UI" callout,
feature cards, and a screenshot grid covering Dashboard, Voice,
Listening, Avatar, and Overlay. Drops stale OBS-first framing in
favour of the built-in overlay. Tones down the heavier emoji usage
while keeping the project's voice.

Also picks up a trivial quote/comment reflow in release.yml left
behind by a post-commit formatter on the previous commit.
@fagenorn
fagenorn merged commit dd7f773 into main Apr 20, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant