feat(capture): dictation, personalities, 0.5.0

Ships the Capture release end to end. Global-hotkey dictation with
synthetic paste into the focused app on macOS and Windows, an on-screen
pill across recording / transcribing / refining, customizable push-to-
talk and toggle chords, and an accessibility-permission prompt scoped to
Settings → Captures with inline re-check feedback.

Voice profiles gain optional personalities that power compose / rewrite /
respond actions via a local Qwen3 LLM — shared with refinement, so there
is one local LLM in the app, not two.

Refinement hardened with deterministic Whisper-loop collapse before the
LLM sees the transcript, per-capture flag snapshots for re-runs, and a
ten-transcript evaluation harness across every bundled refinement size.

Version bump 0.4.5 → 0.5.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
James Pine
2026-04-22 18:49:16 -07:00
co-authored by Claude Opus 4.7
parent ed2eec591a
commit 87c582ad54
84 changed files with 11043 additions and 512 deletions
+193
View File
@@ -20,6 +20,7 @@ class VoiceProfileCreate(BaseModel):
preset_voice_id: Optional[str] = Field(None, max_length=100)
design_prompt: Optional[str] = Field(None, max_length=2000)
default_engine: Optional[str] = Field(None, max_length=50)
personality: Optional[str] = Field(None, max_length=2000)
class VoiceProfileResponse(BaseModel):
@@ -36,6 +37,7 @@ class VoiceProfileResponse(BaseModel):
preset_voice_id: Optional[str] = None
design_prompt: Optional[str] = None
default_engine: Optional[str] = None
personality: Optional[str] = None
generation_count: int = 0
sample_count: int = 0
created_at: datetime
@@ -107,6 +109,7 @@ class GenerationResponse(BaseModel):
status: str = "completed"
error: Optional[str] = None
is_favorited: bool = False
source: str = "manual"
created_at: datetime
versions: Optional[List["GenerationVersionResponse"]] = None
active_version_id: Optional[str] = None
@@ -170,6 +173,196 @@ class TranscriptionResponse(BaseModel):
duration: float
class RefinementFlagsModel(BaseModel):
"""Boolean toggles that drive the refinement prompt builder."""
smart_cleanup: bool = True
self_correction: bool = True
preserve_technical: bool = True
class CaptureResponse(BaseModel):
"""Response model for a capture."""
id: str
audio_path: str
source: str
language: Optional[str] = None
duration_ms: Optional[int] = None
transcript_raw: str
transcript_refined: Optional[str] = None
stt_model: Optional[str] = None
llm_model: Optional[str] = None
refinement_flags: Optional[RefinementFlagsModel] = None
created_at: datetime
class Config:
from_attributes = True
class CaptureListResponse(BaseModel):
"""Response model for paginated capture list."""
items: List[CaptureResponse]
total: int
class CaptureCreateResponse(CaptureResponse):
"""
Response model for ``POST /captures``.
Adds ``auto_refine`` and ``allow_auto_paste`` — the server-side settings
captured at the moment the capture was created. The client reads these to
decide whether to chain a refinement request and whether to fire the
synthetic-paste pipeline, so it doesn't need a synced local copy of the
capture_settings table across sibling Tauri webviews.
"""
auto_refine: bool
allow_auto_paste: bool
class CaptureRefineRequest(BaseModel):
"""Request to refine a capture's transcript via the LLM."""
flags: Optional[RefinementFlagsModel] = None
model_size: Optional[str] = Field(default=None, pattern="^(0\\.6B|1\\.7B|4B)$")
class CaptureRetranscribeRequest(BaseModel):
"""Request to re-run STT on a capture's audio with a different model."""
model: Optional[str] = Field(None, pattern="^(base|small|medium|large|turbo)$")
language: Optional[str] = Field(None, pattern="^(en|zh|ja|ko|de|fr|ru|pt|es|it)$")
class CaptureSettingsResponse(BaseModel):
"""Server-persisted defaults for the capture / refine flow."""
stt_model: str = Field(default="turbo", pattern="^(base|small|medium|large|turbo)$")
language: str = Field(default="auto")
auto_refine: bool = True
llm_model: str = Field(default="0.6B", pattern="^(0\\.6B|1\\.7B|4B)$")
smart_cleanup: bool = True
self_correction: bool = True
preserve_technical: bool = True
allow_auto_paste: bool = True
default_playback_voice_id: Optional[str] = None
chord_push_to_talk_keys: List[str] = Field(default_factory=lambda: ["MetaRight", "AltGr"])
chord_toggle_to_talk_keys: List[str] = Field(
default_factory=lambda: ["MetaRight", "AltGr", "Space"]
)
class Config:
from_attributes = True
class CaptureSettingsUpdate(BaseModel):
"""Partial update for capture settings — every field is optional."""
stt_model: Optional[str] = Field(default=None, pattern="^(base|small|medium|large|turbo)$")
language: Optional[str] = None
auto_refine: Optional[bool] = None
llm_model: Optional[str] = Field(default=None, pattern="^(0\\.6B|1\\.7B|4B)$")
smart_cleanup: Optional[bool] = None
self_correction: Optional[bool] = None
preserve_technical: Optional[bool] = None
allow_auto_paste: Optional[bool] = None
default_playback_voice_id: Optional[str] = None
chord_push_to_talk_keys: Optional[List[str]] = Field(default=None, min_length=1, max_length=6)
chord_toggle_to_talk_keys: Optional[List[str]] = Field(default=None, min_length=1, max_length=6)
class GenerationSettingsResponse(BaseModel):
"""Server-persisted defaults for the generation flow."""
max_chunk_chars: int = Field(default=800, ge=100, le=5000)
crossfade_ms: int = Field(default=50, ge=0, le=500)
normalize_audio: bool = True
autoplay_on_generate: bool = True
class Config:
from_attributes = True
class GenerationSettingsUpdate(BaseModel):
"""Partial update for generation settings — every field is optional."""
max_chunk_chars: Optional[int] = Field(default=None, ge=100, le=5000)
crossfade_ms: Optional[int] = Field(default=None, ge=0, le=500)
normalize_audio: Optional[bool] = None
autoplay_on_generate: Optional[bool] = None
class LLMGenerateRequest(BaseModel):
"""Request model for LLM text generation."""
prompt: str = Field(..., min_length=1, max_length=50000)
system: Optional[str] = Field(None, max_length=4000)
model_size: Optional[str] = Field(default="0.6B", pattern="^(0\\.6B|1\\.7B|4B)$")
max_tokens: int = Field(default=512, ge=1, le=4096)
temperature: float = Field(default=0.7, ge=0.0, le=2.0)
# Few-shot (user, assistant) pairs prepended as real chat turns.
# Used by the refinement service to pin tricky rules (imperatives
# staying imperatives, technical-term punctuation) that small models
# lose when the examples live inline in the system prompt.
examples: Optional[List[List[str]]] = Field(default=None, max_length=8)
class LLMGenerateResponse(BaseModel):
"""Response model for LLM text generation."""
text: str
model_size: str
# ── Profile personality endpoints ─────────────────────────────────────
# compose / rewrite / respond return raw text; /speak chains LLM → TTS
# and either persists as a generation (persist=true) or streams audio
# back transiently.
class PersonalityTextRequest(BaseModel):
"""Body for ``/profiles/{id}/rewrite`` and ``/profiles/{id}/respond``."""
text: str = Field(..., min_length=1, max_length=10000)
class PersonalityTextResponse(BaseModel):
"""Response returned by compose / rewrite / respond endpoints."""
text: str
model_size: str
class PersonalitySpeakRequest(BaseModel):
"""Body for ``/profiles/{id}/speak`` — LLM transform then TTS."""
text: str = Field(..., min_length=1, max_length=10000)
# When true, the generated audio is persisted as a regular row in the
# generations table (tagged with ``source="personality_speak"``) and
# the response returns a GenerationResponse the client polls like any
# other generation. When false, the LLM output is fed to a synchronous
# TTS call and the wav bytes stream back directly.
persist: bool = True
language: Optional[str] = Field(
None,
pattern="^(zh|en|ja|ko|de|fr|ru|pt|es|it|he|ar|da|el|fi|hi|ms|nl|no|pl|sv|sw|tr)$",
)
engine: Optional[str] = Field(
None,
pattern="^(qwen|qwen_custom_voice|luxtts|chatterbox|chatterbox_turbo|tada|kokoro)$",
)
# ``respond`` is the default because this endpoint is designed for
# conversational / agent-style callers. Override to ``rewrite`` to
# speak the user's text in character verbatim, or ``compose`` to
# speak an utterance the character would come up with on its own
# (in which case ``text`` is treated as a topical hint, not content).
intent: str = Field(
default="respond", pattern="^(respond|rewrite|compose)$"
)
class HealthResponse(BaseModel):
"""Response model for health check."""