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
+67
View File
@@ -224,6 +224,73 @@ def _save_retry(
return config.to_storage_path(audio_path)
async def generate_audio_sync(
*,
profile_id: str,
text: str,
language: str,
engine: str,
model_size: str,
seed: Optional[int] = None,
instruct: Optional[str] = None,
normalize: bool = True,
max_chunk_chars: Optional[int] = None,
crossfade_ms: Optional[int] = None,
) -> bytes:
"""Run a TTS generation synchronously and return the resulting wav bytes.
Unlike :func:`run_generation`, this path does not touch the
``generations`` table, enqueue work, or write anything to the
generations directory. It's used by ``POST /profiles/{id}/speak``
when the caller passes ``persist=false`` — they just want the audio
back in the HTTP response without polluting their history.
Loads the engine model on demand, runs ``generate_chunked``, optional
normalize, then encodes in-memory via :func:`tts.audio_to_wav_bytes`
(same helper ``/generate/stream`` uses).
"""
from ..backends import load_engine_model, get_tts_backend_for_engine, engine_needs_trim
from ..utils.chunked_tts import generate_chunked
from ..utils.audio import normalize_audio, trim_tts_output
from . import tts
bg_db = next(get_db())
try:
tts_model = get_tts_backend_for_engine(engine)
await load_engine_model(engine, model_size)
voice_prompt = await profiles.create_voice_prompt_for_profile(
profile_id,
bg_db,
use_cache=True,
engine=engine,
)
finally:
bg_db.close()
trim_fn = trim_tts_output if engine_needs_trim(engine) else None
gen_kwargs: dict = dict(
language=language,
seed=seed,
instruct=instruct,
trim_fn=trim_fn,
)
if max_chunk_chars is not None:
gen_kwargs["max_chunk_chars"] = max_chunk_chars
if crossfade_ms is not None:
gen_kwargs["crossfade_ms"] = crossfade_ms
audio, sample_rate = await generate_chunked(
tts_model, text, voice_prompt, **gen_kwargs
)
if normalize:
audio = normalize_audio(audio)
return tts.audio_to_wav_bytes(audio, sample_rate)
def _save_regenerate(
*,
generation_id: str,