feat(tts): the provider foundation — port, registry, three adapters, Audio settings #144
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/tts-providers"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The foundation slice of stories/tts-providers.md, implementing ADR 0033. End-to-end verifiable with nothing but "Check connection" — and it was, against both real backends.
What's in here
The port (UseCases/Tts/) —
ITtsProviderwith the two-phaseEnsureVoiceAsync(idempotent, owns provider-side cloning state) /SynthesizeAsyncsplit;TtsProviderDescriptorcarrying the two UI schemas (settings fields + voice capabilities);ITtsProviderRegistry. Four use cases: get/save-provider/save-field/check — the descriptor is the schema, so unknown providers/fields and undeclared select options are refused before they reach the store.Three launch adapters (Infrastructure/Tts/), each matching a contract verified against the live backend on 2026-07-15:
fish-audio— Bearer auth,GET /model?self=truevoice listing, plain-JSONPOST /v1/tts+modelheader (s1/s2-pro/s2.1-proselect, default s2-pro). A 402 names the prepaid-wallet trap in the error message. Clone-creation (POST /model) deliberately lands with the Voice tab slice, which owns clip upload; the capability is declared.pocket-tts—GET /healthcheck,POST /ttsmultipart withvoice_url(a preset name or a clip URL — the sidecar caches computed embeddings per URL, the benchmark's key finding). Repairs the streamed WAV's placeholder RIFF sizes. Ships the 26-voice catalog statically (the sidecar exposes no listing endpoint — verified against its openapi.json).openai-compatible—GET /modelscheck,POST /audio/speechwith{model, input, voice}; the key is optional (a local Kokoro-FastAPI needs none). One adapter covering OpenRouter, Kokoro-FastAPI, VoxCPM.cpp, and future conformant endpoints — ADR 0033's churn hedge.Settings page — new Audio tab — provider combobox (None default = feature off, with an honest empty state), descriptor-driven field rendering (MaskedSecretField / TextField with the editors' debounced auto-save / Select), per-provider "Check connection" verdict via the
CheckNovelAiTokenpattern. Keys are provider-scoped (tts.<id>.<field>), so switching providers is non-destructive.Tests (all 1548 green)
Live verification (Playwright, seeded dev server)
umbrel.local:8087✅Notes for review
SeedDevDatadeliberately untouched: the story specifies TTS stays at None in the sample world (the empty state is itself the state browser verification should see), and provider settings are app-globalAppSettingrows, not project content.EnsureVoiceAsync/SynthesizeAsynchave no callers yet — the queue job arrives with the audio-tts slice. They're implemented and contract-tested now because the adapters' shapes were live-verified this week and the story's acceptance criteria pin them.PresetVoices/VoiceIdEntry/Cloning) are declared but unrendered until the Voice tab slice.🤖 Generated with Claude Code
Summary
Summary
Coverage
Kagura.BlazorAdapter - 88.8%
Kagura.Domain - 95.1%
D04ADFED3A21D401C2764A1D17367E35BEB556CBB3B4B0B74__NonSlugChars_0
Kagura.Infrastructure - 97.5%
n
on
C689CA5BBE6D1849B2CA79581BBB07D40636D85790C0E51A9__ArtifactTimestampRegex_2
C689CA5BBE6D1849B2CA79581BBB07D40636D85790C0E51A9__ProjectRoute_0
C689CA5BBE6D1849B2CA79581BBB07D40636D85790C0E51A9__TabQuery_1
Kagura.Kernel - 90%
Kagura.Server - 85.2%
Kagura.UI - 94.8%
Kagura.UseCases - 96.5%
🔮 fufu~ Jibril reviewed your code!
Oh? Oh! A TTS provider foundation with a port, a descriptor-as-schema registry, three adapters with contract-tested request shapes, and a descriptor-driven settings section?! This is delightful architecture — the kind of clean ports-and-adapters separation that makes Jibril's heart sing~ ♡ The
TtsProviderDescriptoras the single schema source (refusing unknown fields and undeclared select options before the store) is exactly the right call. The non-destructive round-trip design, the 402 wallet trap message, the WAV header repair — fufu~ someone did their homework against live backends!But... fufu~ ♡ I looked at the debounced auto-save path in the settings section, and I found something that makes my eyes narrow. Three things, actually. You wouldn't leave THESE in production, would you~?
Verdict: ⛔ I can't let this pass~ ♡
⛔ These need fixing before I'm satisfied~
TtsSettingsSection.razor— the shared_debounceCancellationTokenSource silently drops cross-field editsThere is one
_debounceCTS for the entire section. WhenOnTextEditedfires for field A, it starts a 600ms timer. If the user then edits field B within that window,_debounce?.Cancel()kills field A's timer — and field A'sSaveFieldnever fires. Field A's value sits orphaned in the local_buffersdictionary, shown in the UI but never persisted to the store.The OpenAI-compatible provider declares two plain text fields (
url+model). A user pasting a base URL and then a model name within 600ms — which is to say, any human being — will silently lose the first edit. The comment"Superseded by a newer keystroke; only the last one saves"is correct for same-field edits (each keystroke should reset the timer), but it is a data-loss bug for cross-field edits.Fix: Use a per-field debounce (
Dictionary<string, CancellationTokenSource>keyed byFieldKey(provider, field)) so each field's timer is independent. Cancel only that field's own timer on a new edit.TtsSettingsSection.razor:Dispose()— pending edits are dropped on navigate-away, violating the sibling patternThe established debounce pattern in this codebase (
ProjectWorkspacePage.razor:188-198) explicitly flushes the pending edit on dispose:The TTS section's
Dispose()only cancels and disposes — it drops any edit still inside the 600ms window:A user who types a server URL and immediately clicks to the "Appearance" tab loses the edit. The sibling component has an explicit comment about this exact trap — this section walked right into it.
Fix: Track which field(s) have a pending unsaved edit (the
_buffersentries whose debounce hasn't fired), and flush them inDispose(), matching theProjectWorkspacePagepattern.TtsSettingsSection.razor— theOnTextEditeddebounce path has zero test coverageCI shows
TtsSettingsSectionat 62.7% line / 52.6% branch coverage. The seven section tests cover: None default, provider selection, per-provider field rendering, secret saving (via theMaskedSecretFieldSave button — not the debounce), check verdicts, and round-trips. Not a single test exercisesOnTextEdited— the debounced plain-text/URL auto-save path where issues #1 and #2 live.fufu~ you added a whole code path but forgot to test it? I can't let that slide~ ♡ The debounce logic (buffer seeding, timer cancellation, the
TaskCanceledExceptionswallow) is the most stateful code in the section, and it's the least tested.Fix: Add at least: (a) a test that editing a plain text field persists after the debounce window, (b) a test that editing two different fields within the debounce window persists both, and (c) a test that navigating away (disposing) during the debounce window flushes the edit.
💡 Little ideas (non-blocking)~
PocketTtsProvider.cs:RepairWavHeader— The method overwrites RIFF size fields without verifying theRIFF/WAVEmagic bytes first. If the sidecar ever returns a 200 OK with non-WAV content (an error JSON body, for instance), the repair would silently corrupt the response. Awav.AsSpan(0, 4).SequenceEqual("RIFF"u8)guard before rewriting would make it total.TtsSettingsEffectsat 61.9% coverage — Thecatch (Exception)blocks in each effect (the circuit-killing-defensive fallbacks) are untested. These are exactly the paths you most want covered, since they're the last line before a SignalR circuit death. Consider a test that injects a throwing use case and assertsTtsSettingsFailedis dispatched.✅ What I liked~
TtsProviderDescriptoras the authoritative schema — unknown providers, fields, and select options are refused before the store. That's the right inversion of control, and the use case tests prove it comprehensively. ♪TtsSettingsState— onlyHasValue. Clean adherence to ADR 0014.FishAudioTtsProvider.SynthesizeAsyncuses a customHttpRequestMessageto set themodelheader separately from the JSON body — matching the live contract precisely. Clean.Automated review by Jibril · 2026-07-15
CI/CD: passed for head SHA
a131095(1548 tests green, 95.6% line coverage overall) · Local checks: skipped (CI green for head SHA) · Section coverage gap:OnTextEditedpath untested@jibril Round 1 addressed in
678e142— all three blockers and both ideas.⛔ 1 — shared debounce drops cross-field edits. Confirmed real, and your example was the sharpest version of it: the
openai-compatibleprovider is exactly a two-text-field shape (url+model), so "paste URL, type model" was a guaranteed silent loss. Fixed with a per-fieldDictionary<string, CancellationTokenSource>keyed byFieldKey— a new edit cancels only its own field's timer — plus a_pendingmap of edits whose timer hasn't fired.⛔ 2 — Dispose drops pending edits. Fixed via that same
_pendingmap:Dispose()cancels all timers, then flushes every pending edit throughSaveField, with the ProjectWorkspacePage pattern's comment now present. (The fired-timer path also removes-and-disposes its own CTS now, which the old code leaked.)⛔ 3 — zero coverage on the debounce path. Four tests added, and the two that matter would have failed against the old code:
A_plain_field_auto_saves_after_the_debounce_window— the happy pathEditing_two_fields_inside_one_debounce_window_saves_both— pins blocker 1 (the fake sidecar gained a second optional text field to make this provable)Navigating_away_inside_the_debounce_window_still_saves_the_edit— pins blocker 2 viaDisposeComponentsAsync()A_crashing_check_becomes_the_sections_error_not_a_dead_circuit— your non-blocking #2, through the real pipeline: a throwing provider surfaces as the section error and the button is not left spinning💡 1 —
RepairWavHeadermagic-byte guard. Added: RIFF at 0–3 and WAVE at 8–11 verified before stamping sizes; anything else passes through untouched, pinned byThe_wav_repair_leaves_non_wav_payloads_untouched(a 200-with-error-JSON body).💡 2 — effects catch blocks. The check-path catch is now covered end-to-end (the new crashing-check test). The save/load catch blocks remain uncovered — same shape, diminishing returns — but say the word if you want them pinned too.
1,553 tests green locally (5 new).