dictation: spoken newlines + LLM thinking animation (v1.9.8)
- Add expand_spoken_punctuation() in quality.py: converts spoken phrases
"new line" / "neue Zeile" → \n and "new paragraph" / "neuer Absatz" → \n\n
before text is typed; xdotool/wtype handle \n as Return
- Daemon: pulse "⏳ Thinking..." animation in the overlay while waiting for
the first LLM token (400ms interval), replaced automatically when streaming
starts so the user sees activity during cold-start latency
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
946d344090
commit
749ef78383
@ -456,6 +456,7 @@ class Daemon:
|
||||
)
|
||||
|
||||
text = quality.clean(text, strip_trailing_punctuation=self.cfg.strip_trailing_punctuation)
|
||||
text = quality.expand_spoken_punctuation(text)
|
||||
if not text or (self.cfg.reject_hallucinations and quality.is_hallucination(text, duration)):
|
||||
self._emit("idle", label, "No speech detected")
|
||||
log("Nothing heard — no speech detected.")
|
||||
@ -515,8 +516,26 @@ class Daemon:
|
||||
on_token = None
|
||||
if self.text_cb:
|
||||
acc: list[str] = []
|
||||
# Pulse "thinking" dots until the first token arrives.
|
||||
_thinking_frames = ["⏳ Thinking.", "⏳ Thinking..", "⏳ Thinking...", "⏳ Thinking"]
|
||||
_thinking_state: list[int] = [0] # [frame_idx] 0 = still thinking
|
||||
_first_token: list[bool] = [False]
|
||||
|
||||
def on_token(delta: str, _acc=acc) -> None:
|
||||
def _pulse_thinking(_s=_thinking_state, _f=_first_token) -> bool:
|
||||
if _f[0]:
|
||||
return False # first token arrived, stop pulsing
|
||||
self.text_cb(_thinking_frames[_s[0] % len(_thinking_frames)])
|
||||
_s[0] += 1
|
||||
return True # keep timer running
|
||||
|
||||
try:
|
||||
from gi.repository import GLib as _GLib
|
||||
_GLib.timeout_add(400, _pulse_thinking)
|
||||
except Exception: # noqa: BLE001 - headless mode, no GLib
|
||||
pass
|
||||
|
||||
def on_token(delta: str, _acc=acc, _f=_first_token) -> None:
|
||||
_f[0] = True
|
||||
_acc.append(delta)
|
||||
self.text_cb("".join(_acc))
|
||||
|
||||
|
||||
@ -71,3 +71,23 @@ def clean(text: str, *, strip_trailing_punctuation: bool = False) -> str:
|
||||
if strip_trailing_punctuation:
|
||||
text = text.rstrip(" .,!?;:")
|
||||
return text
|
||||
|
||||
|
||||
# Spoken phrases that should become newlines/paragraphs in the typed output.
|
||||
# Matched case-insensitively, whole-phrase (surrounded by word boundaries or
|
||||
# start/end of string). German and English variants are both covered.
|
||||
_NEWLINE_PHRASES: list[tuple[re.Pattern[str], str]] = [
|
||||
# Paragraph break (blank line) — must come before single-newline patterns
|
||||
(re.compile(r"(?i)\b(new paragraph|neuer absatz|neues absatz|absatz)\b"), "\n\n"),
|
||||
# Single line break
|
||||
(re.compile(r"(?i)\b(new line|neue zeile|zeilenumbruch|line break)\b"), "\n"),
|
||||
]
|
||||
|
||||
|
||||
def expand_spoken_punctuation(text: str) -> str:
|
||||
"""Replace spoken newline/paragraph commands with actual control characters."""
|
||||
for pattern, replacement in _NEWLINE_PHRASES:
|
||||
text = pattern.sub(replacement, text)
|
||||
# Collapse leading/trailing whitespace per line but preserve intentional newlines
|
||||
lines = [ln.strip() for ln in text.split("\n")]
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user