python-voiceio 0.5.0__py3-none-any.whl → 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 0.5.0
3
+ Version: 0.7.0
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -1,7 +1,7 @@
1
- python_voiceio-0.5.0.dist-info/licenses/LICENSE,sha256=Gz61o8jFTAvZUZyB3nWDB3DQQVuipjfPkVu9W8hBHM0,1072
2
- voiceio/__init__.py,sha256=LBK46heutvn3KmsCrKIYu8RQikbfnjZaj2xFrXaeCzQ,22
1
+ python_voiceio-0.7.0.dist-info/licenses/LICENSE,sha256=Gz61o8jFTAvZUZyB3nWDB3DQQVuipjfPkVu9W8hBHM0,1072
2
+ voiceio/__init__.py,sha256=RaANGbRu5e-vehwXI1-Qe2ggPPfs1TQaZj072JdbLk4,22
3
3
  voiceio/__main__.py,sha256=xT5QCGGreYMisHO7Lh_Y-xAQ2TOzG2D7npKknGNcWSY,53
4
- voiceio/app.py,sha256=Xrd3UKJD109Bge0Qp6MiI_M_ztoHE7-18ltMxrpVUoI,63884
4
+ voiceio/app.py,sha256=4o3nOv_Z_NCBr6KTHr1eSGT159ZE98dVBAy5wUOpzAo,64398
5
5
  voiceio/audit.py,sha256=vX3x-YAhmt6EZYRZd0_6wvTquZu5sxGMvH45BhSBnCk,14915
6
6
  voiceio/autocorrect.py,sha256=jGsglEYwAHWk2uWedeEZzyAMW2LWO_8nQGZgrWYy4Dc,26552
7
7
  voiceio/autocorrect_state.py,sha256=BNzdHP5slcOV_-OU4MlaKE7xgVJzrPncsYFK30JWTs8,6858
@@ -9,7 +9,7 @@ voiceio/backends.py,sha256=-rs1YhPblub4NjFb6qCD7Ih9FqBRXtPuvjVv9bQoQ1w,368
9
9
  voiceio/cli.py,sha256=_HfcWQt2Jf34YfI4W1RavsyqegQy9jrg0nwLe6DvD6E,71639
10
10
  voiceio/clipboard_read.py,sha256=CqbHSvOztcQ3u2I2EzFm0jdcDVKv56gl0T1YfWxNpmI,3390
11
11
  voiceio/commands.py,sha256=Vhtn8s5G5OcCbRN44YZ-_14Fa85ISXl_3Ix-KqSHs2A,4694
12
- voiceio/config.py,sha256=L8_us8nthnNWFH1igJzyofuHf3ZQvGt-tz2WiXLRh7A,12925
12
+ voiceio/config.py,sha256=CHDtEigtv-aKPsDFFGkc1AiqmubiFHeU1HaEaPgoIMc,14343
13
13
  voiceio/consent.py,sha256=jtdp1TkTbicUSTCDmV4qo4SdbS4kQpYzy1Kjc_7pJVw,1883
14
14
  voiceio/corrections.py,sha256=7moKhkVvo5CZU1mZobV2-MajkcHiwliKBY1dSBFh0w0,7445
15
15
  voiceio/demo.py,sha256=QRNJuNObXCsjwGD-Cz3dh81cfb21xiuSeVyyU720U9Q,8673
@@ -18,19 +18,19 @@ voiceio/health.py,sha256=5YNu1mMzwTo6I_Z_Mu3Bs46ZHDpVmxP0AXyMNAbaczc,15707
18
18
  voiceio/hints.py,sha256=dmoiMAJ0YGTWwFc9kIPvEs_BQJN7UEUiwYuLUD_GoQQ,1415
19
19
  voiceio/history.py,sha256=v7ciyZui8MndaKtTF5TdTKmql2ALOZNYKguE0Mo_lTY,5581
20
20
  voiceio/llm.py,sha256=_m13jodMVB1iEtze3VGkBCafkg85OssTPBEVyaRabgA,9250
21
- voiceio/llm_api.py,sha256=ex1brY10a8xNtw4cHCYC0yCth3Z02NkCQaHdB4XQPag,7497
21
+ voiceio/llm_api.py,sha256=1wLLi6pliNmg-gS-bUKgwsGQSsLWAhX2XkEWXk292V8,9441
22
22
  voiceio/numbers.py,sha256=MP8jag4_F2OqUU5H14KzOmR9L8gWg48yQhLoUbGg_YI,8376
23
23
  voiceio/pidlock.py,sha256=RInlF_8H7wR0nqkdlhUfxYs7OGzHIvb5p-XDjjQSTNg,619
24
24
  voiceio/platform.py,sha256=f_8MmC5q1sCEuEPyKjRqlVvV9FExuGmwn_aJXhkpiEk,11809
25
- voiceio/postcorrect.py,sha256=lxm3zxdz3yzoR6qpf0bKRycBrF5y91bVnU4DqyTRbxU,8163
25
+ voiceio/postcorrect.py,sha256=iyO7MPIR4a4XIASEGOs9Y3iUXVTfwtVkhVOWj2vY1lk,11201
26
26
  voiceio/postprocess.py,sha256=9grkq3I6XgiQV6ZQbIw2_fOUe57tzC7GBPvLXqhhdTs,2979
27
27
  voiceio/prompt.py,sha256=9iza8KoQxvO_U9NPMJ8Ep7j-zkIs9nW8N5sacCRpsEk,2904
28
28
  voiceio/recorder.py,sha256=p1r0mH_plBD3BHqBbIGV_m2N4Mj5RAF7LfMu8u6mk5I,12813
29
- voiceio/retention.py,sha256=VrN261qL4C2_mSbjI6yPnMNiTVMxpo16wYua27TQb_Q,3330
29
+ voiceio/retention.py,sha256=i_VWPCyykT4kQ1uKilTplpiIrACTYljn09BU1vL71A0,6019
30
30
  voiceio/service.py,sha256=jqX1opZyQh_dOJMK4bVf8Aoei0zOuuk1Ogta9BXTWGY,12106
31
31
  voiceio/snapshots.py,sha256=bm-lzIeYucDUDlPBZiFiWd-jyB2I6Ft1jRANZCO6go4,2351
32
- voiceio/streaming.py,sha256=90D6r162DQJd-s4fTb1NuLylrdh9gVyAnfNbaMbLQ6Y,15916
33
- voiceio/transcriber.py,sha256=D6_RLO8K3bGUV4CsS2q_k5rxkHTMB_6DqnzDka5mEb0,9939
32
+ voiceio/streaming.py,sha256=3JMXBU1DAIDvItGRKlh7RGlD1zcGwgNcrFCKLkWdmTI,23215
33
+ voiceio/transcriber.py,sha256=2ev_hwdusve7ZZqht9A9wtjjT72rcr7SuhmSIW8aClk,10721
34
34
  voiceio/vad.py,sha256=72_ICk4jSuSPYvTHdHYtHzY8W5uaGEOz5FTdqkolJi8,5327
35
35
  voiceio/vocabulary.py,sha256=eGV7QZs3tN6Cw5YpyoZsVEk2M5fJvie1n2XFi9CD4hw,6321
36
36
  voiceio/wizard.py,sha256=AwjwrocrQp0DPSOMZ0m6z6G3hlo9VPceEwPzR9-xXe0,80227
@@ -71,8 +71,8 @@ voiceio/typers/pynput_type.py,sha256=DTtkT59M-EKOAlTNkvcZrRvf_Fq52OpI8S5t_8S-QAY
71
71
  voiceio/typers/wtype.py,sha256=d1wG-HZdYDQxXIysV_Xvk9HixDpaJgl3VEoKbP5vhIs,1786
72
72
  voiceio/typers/xdotool.py,sha256=dh1zhPqUT8ihJahJ7ZKm6PtfYY087UAzADx664DvQOM,1356
73
73
  voiceio/typers/ydotool.py,sha256=dt0W9ot__W8LQG2so8vZLGIw9leK2Q6g3i-Voo0LXE4,5327
74
- python_voiceio-0.5.0.dist-info/METADATA,sha256=kdTrm0Dg20K3plJVL0hlhXcu9LOJRSUTkgyiupAHag0,15877
75
- python_voiceio-0.5.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
76
- python_voiceio-0.5.0.dist-info/entry_points.txt,sha256=U64fA65zxzyLoC8bgbn2ztQVWHLsc0o0H0qCk1J9DMc,218
77
- python_voiceio-0.5.0.dist-info/top_level.txt,sha256=piwtn309lD6uexQyXdZ-efAVBJF9y6Wfr48Z-8zkNhg,8
78
- python_voiceio-0.5.0.dist-info/RECORD,,
74
+ python_voiceio-0.7.0.dist-info/METADATA,sha256=hV7gmFjuAjAJpCb9O01qqobjZfk7qxk3Kjow7l5-oaI,15877
75
+ python_voiceio-0.7.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
76
+ python_voiceio-0.7.0.dist-info/entry_points.txt,sha256=U64fA65zxzyLoC8bgbn2ztQVWHLsc0o0H0qCk1J9DMc,218
77
+ python_voiceio-0.7.0.dist-info/top_level.txt,sha256=piwtn309lD6uexQyXdZ-efAVBJF9y6Wfr48Z-8zkNhg,8
78
+ python_voiceio-0.7.0.dist-info/RECORD,,
voiceio/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.5.0"
1
+ __version__ = "0.7.0"
voiceio/app.py CHANGED
@@ -393,6 +393,7 @@ class VoiceIO:
393
393
  voice_input_prefix=self._voice_input_prefix,
394
394
  on_typer_broken=self._on_typer_broken,
395
395
  on_interim=self._on_interim_text,
396
+ freeze_secs=self.cfg.output.streaming_freeze_secs,
396
397
  )
397
398
  self._session.start()
398
399
  log.info("Recording... press [%s] again to stop", self.cfg.hotkey.key)
@@ -545,6 +546,16 @@ class VoiceIO:
545
546
  duration=elapsed,
546
547
  extra=extra,
547
548
  )
549
+ from voiceio import retention
550
+ retention.save_trace(self.cfg.data, {
551
+ "ts": time.time(),
552
+ "audio": extra.get("audio"),
553
+ "duration": round(elapsed, 2),
554
+ "latency": latency,
555
+ # Snapshot: a timed-out worker join means the thread may
556
+ # still be appending while we serialize.
557
+ "passes": list(session.trace),
558
+ })
548
559
  log.info("Streaming done (%.1fs): '%s'", elapsed, final_text)
549
560
  # Release the IBus input source now that the final commit is done.
550
561
  # Generation-checked inside: if a newer recording started, it already
voiceio/config.py CHANGED
@@ -33,6 +33,11 @@ PID_PATH = LOG_DIR / "voiceio.pid"
33
33
  # and the retired-rule state consulted by the mining side.
34
34
  CORRECTIONS_AUDIT_PATH = LOG_DIR / "corrections_audit.jsonl"
35
35
  METRICS_PATH = LOG_DIR / "metrics.jsonl"
36
+ # Intermediate pipeline data kept for debugging/profiling and future training:
37
+ # per-pass streaming decode traces and postcorrect before/after pairs. These
38
+ # live in their own JSONL files (not the rotating log) so they survive.
39
+ TRACES_PATH = LOG_DIR / "streaming_trace.jsonl"
40
+ POSTCORRECT_PAIRS_PATH = LOG_DIR / "postcorrect_pairs.jsonl"
36
41
  SNAPSHOTS_DIR = CONFIG_DIR / "snapshots"
37
42
  AUDIT_STATE_PATH = CONFIG_DIR / "audit_state.json"
38
43
  AUTOCORRECT_STATE_PATH = CONFIG_DIR / "autocorrect_state.json"
@@ -79,6 +84,11 @@ class OutputConfig:
79
84
  punctuation_cleanup: bool = True
80
85
  number_conversion: bool = True
81
86
  voice_input_prefix: str = "" # e.g. "[voice]" — empty disables
87
+ # Incremental finalization: once the un-finalized audio tail grows past
88
+ # this many seconds AND ends in silence, beam-decode and freeze it during
89
+ # recording, so interim passes and the stop-time final decode only cover
90
+ # the short remaining tail. 0 disables (full re-decode at stop).
91
+ streaming_freeze_secs: float = 25.0
82
92
  # Mirror transcribed text to the system clipboard so it can be pasted:
83
93
  # "off" — never
84
94
  # "final" — the corrected final text, once ready
@@ -137,7 +147,12 @@ class PostCorrectConfig:
137
147
  model can be overridden here; empty falls back to the autocorrect model.
138
148
  """
139
149
  enabled: bool = False
140
- model: str = "" # empty = use [autocorrect].model
150
+ # Latency-critical (blocks the commit): default to a fast cheap model.
151
+ # Bake-off on the real correction prompt: gemini-2.5-flash-lite 0.44s
152
+ # with fixes identical to kimi-k2's 2.0s. OpenRouter model id — on a
153
+ # custom base_url set a model your endpoint serves (empty = use
154
+ # [autocorrect].model).
155
+ model: str = "google/gemini-2.5-flash-lite"
141
156
  timeout_secs: float = 8.0
142
157
  min_words: int = 4 # skip utterances shorter than this
143
158
 
@@ -179,7 +194,14 @@ class DataConfig:
179
194
  """
180
195
  retain_audio: bool = True
181
196
  max_audio_mb: int = 4096 # prune oldest recordings beyond this
197
+ # Never let retention squeeze the disk: skip saving and prune harder
198
+ # when the filesystem has less than this much free space.
199
+ min_free_gb: float = 5.0
182
200
  capture_context: bool = True # best-effort active-window title
201
+ # Per-pass streaming decode traces + postcorrect before/after pairs
202
+ # (streaming_trace.jsonl / postcorrect_pairs.jsonl) for debugging,
203
+ # profiling, and future fine-tuning.
204
+ capture_intermediates: bool = True
183
205
 
184
206
 
185
207
  @dataclass
@@ -283,6 +305,8 @@ def _content_files() -> list[Path]:
283
305
  HISTORY_PATH,
284
306
  CORRECTIONS_AUDIT_PATH,
285
307
  METRICS_PATH,
308
+ TRACES_PATH,
309
+ POSTCORRECT_PAIRS_PATH,
286
310
  AUDIT_STATE_PATH,
287
311
  AUTOCORRECT_STATE_PATH,
288
312
  CONSENT_PATH,
voiceio/llm_api.py CHANGED
@@ -5,16 +5,73 @@ local Ollama (via /v1/chat/completions), etc. Zero dependencies beyond stdlib.
5
5
  """
6
6
  from __future__ import annotations
7
7
 
8
+ import http.client
9
+ import io
8
10
  import json
9
11
  import logging
10
12
  import os
13
+ import threading
11
14
  import urllib.error
12
- import urllib.request
15
+ from urllib.parse import urlsplit
13
16
 
14
17
  from voiceio.config import AutocorrectConfig
15
18
 
16
19
  log = logging.getLogger(__name__)
17
20
 
21
+ # Keep-alive connection pool: a fresh TLS handshake costs ~0.2-0.5s per call,
22
+ # which lands directly on postcorrect's stop-to-commit latency. Connections
23
+ # are checked OUT for the duration of a request and returned only on clean
24
+ # completion, so a deadline-abandoned postcorrect thread can never share a
25
+ # socket with a later call.
26
+ _POOL_LOCK = threading.Lock()
27
+ _CONN_POOL: dict[str, http.client.HTTPConnection] = {}
28
+
29
+
30
+ def _post_json(url: str, headers: dict, body: dict, timeout: float) -> dict:
31
+ """POST JSON over a pooled keep-alive connection; return the parsed reply.
32
+
33
+ A stale pooled connection (server closed it while idle) gets one retry on
34
+ a fresh one. Non-2xx raises urllib.error.HTTPError so callers keep their
35
+ existing status-code handling.
36
+ """
37
+ parts = urlsplit(url)
38
+ pool_key = f"{parts.scheme}://{parts.netloc}"
39
+ path = parts.path or "/"
40
+ payload = json.dumps(body).encode()
41
+
42
+ for attempt in (1, 2):
43
+ with _POOL_LOCK:
44
+ conn = _CONN_POOL.pop(pool_key, None)
45
+ pooled = conn is not None
46
+ if conn is None:
47
+ cls = (http.client.HTTPSConnection if parts.scheme == "https"
48
+ else http.client.HTTPConnection)
49
+ conn = cls(parts.hostname, parts.port, timeout=timeout)
50
+ elif conn.sock is not None:
51
+ conn.sock.settimeout(timeout)
52
+ try:
53
+ conn.request("POST", path, body=payload, headers=headers)
54
+ resp = conn.getresponse()
55
+ data = resp.read()
56
+ except (http.client.HTTPException, OSError):
57
+ conn.close()
58
+ if not pooled or attempt == 2:
59
+ raise
60
+ continue # stale keep-alive — retry once on a fresh connection
61
+ if resp.status // 100 != 2:
62
+ conn.close()
63
+ raise urllib.error.HTTPError(
64
+ url, resp.status, resp.reason, dict(resp.getheaders()),
65
+ io.BytesIO(data),
66
+ )
67
+ with _POOL_LOCK:
68
+ if pool_key in _CONN_POOL:
69
+ conn.close() # keep at most one idle connection per host
70
+ else:
71
+ _CONN_POOL[pool_key] = conn
72
+ return json.loads(data)
73
+ raise AssertionError("unreachable")
74
+
18
75
 
19
76
  _LOCAL_HOSTS = ("localhost", "127.0.0.1", "0.0.0.0", "[::1]", "::1")
20
77
  _consent_warned = False
@@ -94,11 +151,7 @@ def _anthropic_request(
94
151
  "anthropic-version": "2023-06-01",
95
152
  }
96
153
 
97
- req = urllib.request.Request(
98
- url, data=json.dumps(body).encode(), headers=headers, method="POST",
99
- )
100
- with urllib.request.urlopen(req, timeout=timeout) as resp:
101
- data = json.loads(resp.read())
154
+ data = _post_json(url, headers, body, timeout)
102
155
  # Anthropic returns content as a list of blocks; some thinking models
103
156
  # may also include `thinking` blocks which we ignore.
104
157
  blocks = data.get("content") or []
@@ -137,11 +190,7 @@ def _openai_request(
137
190
  "Authorization": f"Bearer {api_key}",
138
191
  }
139
192
 
140
- req = urllib.request.Request(
141
- url, data=json.dumps(body).encode(), headers=headers, method="POST",
142
- )
143
- with urllib.request.urlopen(req, timeout=timeout) as resp:
144
- data = json.loads(resp.read())
193
+ data = _post_json(url, headers, body, timeout)
145
194
  # Be defensive: thinking models (Kimi K2.6, GPT reasoning, etc.) can
146
195
  # return content=None when the answer is in `reasoning` / `reasoning_content`
147
196
  # instead. Also some malformed responses lack `choices` entirely.
voiceio/postcorrect.py CHANGED
@@ -15,6 +15,7 @@ from __future__ import annotations
15
15
  import dataclasses
16
16
  import difflib
17
17
  import logging
18
+ import threading
18
19
  import time
19
20
 
20
21
  from voiceio.config import AutocorrectConfig, Config
@@ -90,6 +91,13 @@ class PostCorrector:
90
91
  self._vocabulary = ""
91
92
  self._recent: list[str] = []
92
93
  self._context: str | None = None
94
+ # Context actually sent with the in-progress correct() call (what
95
+ # _record must persist — may differ from self._context).
96
+ self._effective_ctx: str | None = None
97
+ # A worker abandoned at the deadline may block indefinitely on a hung
98
+ # endpoint; cap leakage at one thread/socket by skipping new calls
99
+ # while it is still alive.
100
+ self._abandoned: threading.Thread | None = None
93
101
 
94
102
  # ── availability ────────────────────────────────────────────────────
95
103
 
@@ -140,6 +148,27 @@ class PostCorrector:
140
148
  parts.append(f"Transcript to correct:\n{text}")
141
149
  return "\n\n".join(parts)
142
150
 
151
+ def _record(self, before: str, after: str | None, outcome: str) -> None:
152
+ """Persist one LLM attempt (before/after/outcome) as training data.
153
+
154
+ Pairs land in postcorrect_pairs.jsonl — unlike the rotating log they
155
+ survive, so accepted AND rejected corrections stay available for
156
+ tuning guards or training a local corrector later.
157
+ """
158
+ if not self._cfg.data.capture_intermediates:
159
+ return
160
+ from voiceio import retention
161
+ from voiceio.config import POSTCORRECT_PAIRS_PATH
162
+ retention.append_jsonl(POSTCORRECT_PAIRS_PATH, {
163
+ "ts": time.time(),
164
+ "before": before,
165
+ "after": after,
166
+ "outcome": outcome,
167
+ "secs": round(self.last_secs, 3) if self.last_secs is not None else None,
168
+ "model": self._pc.model or self._ac.model,
169
+ "context": self._effective_ctx,
170
+ })
171
+
143
172
  def correct(
144
173
  self, text: str, *, vocabulary: str = "",
145
174
  recent: list[str] | None = None, context: str | None = None,
@@ -161,28 +190,65 @@ class PostCorrector:
161
190
  vocab = vocabulary or self._vocabulary
162
191
  rec = recent if recent is not None else self._recent
163
192
  ctx = context if context is not None else self._context
193
+ self._effective_ctx = ctx
194
+
195
+ if self._abandoned is not None:
196
+ if self._abandoned.is_alive():
197
+ log.warning(
198
+ "PostCorrector: previous request still hung — skipping this one",
199
+ )
200
+ self._record(text, None, "skipped_busy")
201
+ return text
202
+ self._abandoned = None
164
203
 
165
204
  from voiceio.llm_api import chat
166
205
  user_msg = self._build_user_message(text, vocab, rec, ctx)
206
+ # timeout_secs must bound the WALL CLOCK the user waits, but urllib's
207
+ # timeout is per-socket-read — a slowly streaming response can run
208
+ # far past it (observed ~14s with an 8s config). Run the call in a
209
+ # thread and abandon it at the deadline.
167
210
  t0 = time.monotonic()
168
- try:
169
- response = chat(self._client_cfg(), _SYSTEM_PROMPT, user_msg, max_tokens=1024)
170
- except Exception as e:
171
- log.debug("PostCorrector LLM error: %s — keeping original", e)
211
+ outcome: dict = {}
212
+
213
+ def _call() -> None:
214
+ try:
215
+ outcome["response"] = chat(
216
+ self._client_cfg(), _SYSTEM_PROMPT, user_msg, max_tokens=1024,
217
+ )
218
+ except Exception as e:
219
+ outcome["error"] = e
220
+
221
+ worker = threading.Thread(target=_call, daemon=True)
222
+ worker.start()
223
+ worker.join(self._pc.timeout_secs)
224
+ self.last_secs = time.monotonic() - t0
225
+ if worker.is_alive():
226
+ log.debug(
227
+ "PostCorrector deadline (%.1fs) exceeded — keeping original",
228
+ self._pc.timeout_secs,
229
+ )
230
+ self._abandoned = worker
231
+ self._record(text, None, "timeout")
232
+ return text
233
+ if "error" in outcome:
234
+ log.debug("PostCorrector LLM error: %s — keeping original", outcome["error"])
235
+ self._record(text, None, "error")
172
236
  return text
173
- finally:
174
- self.last_secs = time.monotonic() - t0
237
+ response = outcome.get("response")
175
238
 
176
239
  if not response:
177
240
  log.debug("PostCorrector: empty/failed response — keeping original")
241
+ self._record(text, None, "empty")
178
242
  return text
179
243
 
180
244
  corrected = _strip_wrapping(response)
181
245
  if not corrected:
182
246
  log.debug("PostCorrector: response empty after stripping — keeping original")
247
+ self._record(text, None, "empty")
183
248
  return text
184
249
 
185
250
  if corrected == text:
251
+ self._record(text, corrected, "unchanged")
186
252
  return text
187
253
 
188
254
  # Guard: word-count must not change materially.
@@ -192,6 +258,7 @@ class PostCorrector:
192
258
  "PostCorrector reject: word count %d→%d (>%.0f%%) — keeping original",
193
259
  orig_wc, new_wc, _MAX_WORDCOUNT_DELTA * 100,
194
260
  )
261
+ self._record(text, corrected, "rejected_wordcount")
195
262
  return text
196
263
 
197
264
  # Guard: only a small fraction of words may change.
@@ -201,7 +268,9 @@ class PostCorrector:
201
268
  "PostCorrector reject: edit ratio %.2f > %.2f — keeping original",
202
269
  ratio, _MAX_EDIT_RATIO,
203
270
  )
271
+ self._record(text, corrected, "rejected_editratio")
204
272
  return text
205
273
 
206
274
  log.info("PostCorrector fixed: %s", ", ".join(_changed_words(text, corrected)))
275
+ self._record(text, corrected, "applied")
207
276
  return corrected
voiceio/retention.py CHANGED
@@ -7,17 +7,19 @@ mining measurable and personal fine-tuning possible later.
7
7
  from __future__ import annotations
8
8
 
9
9
  import functools
10
+ import json
10
11
  import logging
11
12
  import shutil
12
13
  import subprocess
13
14
  import time
14
15
  import wave
16
+ from pathlib import Path
15
17
  from typing import TYPE_CHECKING
16
18
 
17
19
  import numpy as np
18
20
 
19
21
  from voiceio import config
20
- from voiceio.config import RECORDINGS_DIR
22
+ from voiceio.config import RECORDINGS_DIR, TRACES_PATH
21
23
 
22
24
  if TYPE_CHECKING:
23
25
  from voiceio.config import DataConfig
@@ -27,11 +29,25 @@ log = logging.getLogger(__name__)
27
29
  _which = functools.lru_cache(maxsize=8)(shutil.which)
28
30
 
29
31
 
32
+ def _free_gb(path: Path) -> float:
33
+ """Free space (GB) on the filesystem holding `path`. Inf on failure."""
34
+ try:
35
+ return shutil.disk_usage(path).free / 1024**3
36
+ except OSError:
37
+ return float("inf")
38
+
39
+
30
40
  def save_audio(audio: np.ndarray, ts: float, cfg: DataConfig) -> str | None:
31
41
  """Persist one utterance as 16kHz mono int16 WAV. Returns the filename
32
42
  (relative to the recordings dir) or None if disabled/failed."""
33
43
  if not cfg.retain_audio or audio is None or len(audio) == 0:
34
44
  return None
45
+ if _free_gb(RECORDINGS_DIR.parent) < cfg.min_free_gb:
46
+ log.warning(
47
+ "Disk has <%.0fGB free — skipping audio retention for this utterance",
48
+ cfg.min_free_gb,
49
+ )
50
+ return None
35
51
  name = time.strftime("%Y%m%d-%H%M%S", time.localtime(ts)) + f"-{int(ts * 1000) % 1000:03d}.wav"
36
52
  path = RECORDINGS_DIR / name
37
53
  try:
@@ -70,6 +86,15 @@ def prune(cfg: DataConfig) -> None:
70
86
  )
71
87
  total = sum(p.stat().st_size for p in files)
72
88
  budget = cfg.max_audio_mb * 1024 * 1024
89
+ # Below the free-disk floor, shrink the budget so retention yields
90
+ # space back instead of holding its full cap on a squeezed disk.
91
+ free = _free_gb(RECORDINGS_DIR.parent)
92
+ if free < cfg.min_free_gb:
93
+ budget = min(budget, total // 2)
94
+ log.warning(
95
+ "Disk has %.1fGB free (<%.0fGB floor) — pruning recordings to %.0fMB",
96
+ free, cfg.min_free_gb, budget / 1024 / 1024,
97
+ )
73
98
  while total > budget and files:
74
99
  oldest = files.pop(0)
75
100
  total -= oldest.stat().st_size
@@ -79,6 +104,45 @@ def prune(cfg: DataConfig) -> None:
79
104
  log.debug("Recording prune failed", exc_info=True)
80
105
 
81
106
 
107
+ _JSONL_MAX_BYTES = 64 * 1024 * 1024 # per capture file; oldest half dropped
108
+
109
+
110
+ def append_jsonl(path: Path, entry: dict) -> None:
111
+ """Append one JSON line to a 0600 data file. Best-effort, never raises.
112
+
113
+ Capture files are bounded: past _JSONL_MAX_BYTES the oldest half of the
114
+ lines is dropped, so intermediate-data capture can never grow unbounded
115
+ on a disk the min_free_gb floor is trying to protect.
116
+ """
117
+ try:
118
+ path.parent.mkdir(parents=True, exist_ok=True)
119
+ config._chmod(path.parent, config._SECURE_DIR)
120
+ newly_created = not path.exists()
121
+ if not newly_created and path.stat().st_size > _JSONL_MAX_BYTES:
122
+ lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
123
+ path.write_text("".join(lines[len(lines) // 2:]), encoding="utf-8")
124
+ log.info("Trimmed %s to newest %d entries", path.name, len(lines) - len(lines) // 2)
125
+ with open(path, "a", encoding="utf-8") as f:
126
+ f.write(json.dumps(entry, ensure_ascii=False) + "\n")
127
+ if newly_created:
128
+ config._chmod(path, config._SECURE_FILE)
129
+ except (OSError, TypeError, ValueError) as e:
130
+ log.warning("Failed to write %s: %s", path.name, e)
131
+
132
+
133
+ def save_trace(cfg: DataConfig, entry: dict) -> None:
134
+ """Persist one utterance's per-pass streaming decode trace.
135
+
136
+ One line per utterance in streaming_trace.jsonl: pass timings, kinds
137
+ (interim/freeze/final), tail lengths, and raw tail texts — the data
138
+ needed to debug/profile streaming behaviour and to train on interim
139
+ hypotheses later. Linked to history via ts + audio filename.
140
+ """
141
+ if not cfg.capture_intermediates:
142
+ return
143
+ append_jsonl(TRACES_PATH, entry)
144
+
145
+
82
146
  def active_window_title() -> str | None:
83
147
  """Best-effort title of the focused window (dictation target context).
84
148
 
voiceio/streaming.py CHANGED
@@ -8,11 +8,12 @@ import threading
8
8
  import time
9
9
  from typing import TYPE_CHECKING, Callable
10
10
 
11
+ import numpy as np
12
+
11
13
  from voiceio.transcriber import transcribe_timeout
12
14
  from voiceio.typers.base import StreamingTyper
13
15
 
14
16
  if TYPE_CHECKING:
15
- import numpy as np
16
17
  from voiceio.commands import CommandProcessor
17
18
  from voiceio.corrections import CorrectionDict
18
19
  from voiceio.llm import LLMProcessor
@@ -24,6 +25,35 @@ if TYPE_CHECKING:
24
25
  log = logging.getLogger(__name__)
25
26
  DELETE_SETTLE_SECS = 0.05 # delay between delete and type for ydotool reliability
26
27
 
28
+ # Incremental finalization: a freeze cut is only safe at a speech pause, so
29
+ # require the tail's trailing window to be quiet before cutting there.
30
+ _FREEZE_SILENCE_WINDOW_SECS = 0.3
31
+ # Quiet is judged RELATIVE to the tail's own level: mic gains vary wildly
32
+ # and the decode path normalizes audio anyway, so an absolute threshold
33
+ # would read a quiet mic's speech as silence (mid-word cuts) and a hot
34
+ # noise floor as speech (freeze never fires).
35
+ # Calibrated on real recordings: between-words noise floor sits at
36
+ # ~0.27-0.38 of overall tail RMS, speech windows at >=0.9. 0.6 separates
37
+ # both with margin; 0.15 never fired on a mic with a normal noise floor.
38
+ _FREEZE_SILENCE_RATIO = 0.6
39
+ _FREEZE_SILENCE_FLOOR = 1e-4 # digital silence is always quiet
40
+ # Whisper conditions on ~224 prompt tokens; more frozen context is wasted.
41
+ _FREEZE_CONTEXT_CHARS = 400
42
+
43
+
44
+ def _rms(samples: np.ndarray) -> float:
45
+ return float(np.sqrt(np.mean(np.square(samples, dtype=np.float64))))
46
+
47
+
48
+ def _tail_ends_in_silence(tail: np.ndarray, sample_rate: int) -> bool:
49
+ """True when the last _FREEZE_SILENCE_WINDOW_SECS of audio are quiet
50
+ relative to the tail's overall level."""
51
+ window = tail[-int(_FREEZE_SILENCE_WINDOW_SECS * sample_rate):]
52
+ if len(window) == 0:
53
+ return False
54
+ threshold = max(_FREEZE_SILENCE_RATIO * _rms(tail), _FREEZE_SILENCE_FLOOR)
55
+ return _rms(window) < threshold
56
+
27
57
 
28
58
  def _common_prefix_len(a: str, b: str) -> int:
29
59
  """Length of the longest common prefix between two strings."""
@@ -34,6 +64,16 @@ def _common_prefix_len(a: str, b: str) -> int:
34
64
  return limit
35
65
 
36
66
 
67
+ def _join_text(a: str, b: str) -> str:
68
+ """Join two transcript fragments with a single space."""
69
+ a, b = a.strip(), (b or "").strip()
70
+ if not a:
71
+ return b
72
+ if not b:
73
+ return a
74
+ return a + " " + b
75
+
76
+
37
77
  def _clean_word(w: str) -> str:
38
78
  """Strip punctuation for fuzzy word matching."""
39
79
  return re.sub(r'[^\w]', '', w).lower()
@@ -85,6 +125,7 @@ class StreamingSession:
85
125
  on_typer_broken: Callable[[], None] | None = None,
86
126
  is_current: Callable[[], bool] | None = None,
87
127
  on_interim: Callable[[str], None] | None = None,
128
+ freeze_secs: float = 25.0,
88
129
  ):
89
130
  self._transcriber = transcriber
90
131
  self._typer = typer
@@ -113,10 +154,27 @@ class StreamingSession:
113
154
  self._stop_event = threading.Event()
114
155
  self._worker_thread: threading.Thread | None = None
115
156
  self._final_audio: np.ndarray | None = None # set on stop
157
+ # Incremental finalization: audio before _frozen_samples has already
158
+ # been beam-decoded into _frozen_raw and is never decoded again.
159
+ # Interim and final passes only decode the tail after this offset.
160
+ self._freeze_secs = freeze_secs
161
+ self._frozen_samples = 0
162
+ self._frozen_raw = ""
163
+ self._frozen_segments: list[dict] = []
164
+ # Decode backpressure: when the CPU is contended, decodes slow down
165
+ # and 1s-tick interim passes would queue back-to-back, each covering
166
+ # a longer tail — a starvation spiral. Require at least as much NEW
167
+ # audio as the last decode took before starting another interim.
168
+ self._last_decode_end = 0 # samples covered by the last decode
169
+ self._last_decode_secs = 0.0 # how long that decode took
116
170
  # Raw (pre-pipeline) text + confidence of the final pass, for history
117
171
  self.raw_final_text: str | None = None
118
172
  self.final_latency: dict = {}
119
173
  self.final_segments: list[dict] = []
174
+ # Per-pass decode trace (interim/freeze/final) for debugging,
175
+ # profiling, and future training. Persisted via retention.save_trace.
176
+ self.trace: list[dict] = []
177
+ self._t0 = time.monotonic()
120
178
 
121
179
  @property
122
180
  def interim_text(self) -> str:
@@ -213,10 +271,22 @@ class StreamingSession:
213
271
  log.exception("Final transcribe/apply error")
214
272
  self._final_audio = None # release memory
215
273
 
274
+ def _frozen_context(self) -> str | None:
275
+ """Frozen-text suffix used to condition the tail decode."""
276
+ if not self._frozen_raw:
277
+ return None
278
+ return self._frozen_raw[-_FREEZE_CONTEXT_CHARS:]
279
+
216
280
  def _transcribe_and_apply(
217
281
  self, min_seconds: float = 1.0, final: bool = False,
218
282
  ) -> None:
219
- """Get audio, transcribe, apply correction."""
283
+ """Get audio, transcribe the un-frozen tail, apply correction.
284
+
285
+ Incremental finalization: audio before _frozen_samples was already
286
+ beam-decoded and frozen, so every pass — including the final one —
287
+ only decodes the tail. Long dictations finalize in O(tail), not
288
+ O(recording).
289
+ """
220
290
  if final and self._final_audio is not None:
221
291
  # Use the snapshot passed to stop() — recorder may be gone
222
292
  audio = self._final_audio
@@ -230,22 +300,96 @@ class StreamingSession:
230
300
  if len(audio) < self._sample_rate * min_seconds:
231
301
  return
232
302
 
303
+ tail = audio[self._frozen_samples:]
304
+ if not final and len(tail) < self._sample_rate * min_seconds:
305
+ return # not enough new audio since the last freeze
306
+ grown_secs = (len(audio) - self._last_decode_end) / self._sample_rate
307
+ if not final and grown_secs < max(min_seconds, self._last_decode_secs):
308
+ return # backpressure: don't decode faster than we can keep up
309
+
310
+ # Freeze when the tail has grown long AND ends at a speech pause
311
+ # (never cut mid-word). The freeze pass gets beam search because its
312
+ # text is never decoded again.
313
+ freeze = (
314
+ not final
315
+ and self._freeze_secs > 0
316
+ and len(tail) >= self._freeze_secs * self._sample_rate
317
+ and _tail_ends_in_silence(tail, self._sample_rate)
318
+ )
319
+
320
+ tail_text = ""
321
+ tail_segments: list[dict] = []
233
322
  t0 = time.monotonic()
234
- try:
235
- text = self._transcriber.transcribe(audio, final=final)
236
- except Exception:
237
- log.exception("Streaming transcription failed")
238
- return
323
+ # The final pass decodes ANY remaining audio (a clipped last word
324
+ # must not be dropped); interim/freeze passes skip sub-0.3s tails.
325
+ if len(tail) > (0 if final else self._sample_rate * 0.3):
326
+ try:
327
+ tail_text = self._transcriber.transcribe(
328
+ tail, final=final or freeze, context=self._frozen_context(),
329
+ )
330
+ except Exception:
331
+ # Decode FAILED (timeout/crash) — this is not silence. Never
332
+ # advance the freeze boundary or char-diff against a partial
333
+ # transcript; on final, commit what is already on screen.
334
+ log.exception("Streaming transcription failed")
335
+ if final:
336
+ self.raw_final_text = ""
337
+ self.final_latency = {
338
+ "audio_secs": round(len(audio) / self._sample_rate, 2),
339
+ "frozen_secs": round(self._frozen_samples / self._sample_rate, 2),
340
+ "tail_secs": round(len(tail) / self._sample_rate, 2),
341
+ "transcribe": round(time.monotonic() - t0, 3),
342
+ "decode_failed": True,
343
+ }
344
+ self._commit_interim_on_final()
345
+ return
346
+ tail_segments = list(getattr(self._transcriber, "last_segments", []))
239
347
  t_transcribe = time.monotonic() - t0
348
+ self._last_decode_end = len(audio)
349
+ self._last_decode_secs = t_transcribe
350
+
351
+ self.trace.append({
352
+ "t": round(time.monotonic() - self._t0, 2),
353
+ "kind": "final" if final else ("freeze" if freeze else "interim"),
354
+ "frozen_secs": round(self._frozen_samples / self._sample_rate, 2),
355
+ "tail_secs": round(len(tail) / self._sample_rate, 2),
356
+ "secs": round(t_transcribe, 3),
357
+ "text": tail_text,
358
+ })
359
+
360
+ if freeze:
361
+ self._frozen_raw = _join_text(self._frozen_raw, tail_text)
362
+ self._frozen_samples = len(audio)
363
+ self._frozen_segments.extend(tail_segments)
364
+ log.debug(
365
+ "Froze %.1fs of audio (frozen text now %d chars)",
366
+ len(tail) / self._sample_rate, len(self._frozen_raw),
367
+ )
368
+ raw = self._frozen_raw if freeze else _join_text(self._frozen_raw, tail_text)
369
+
370
+ # A slow interim decode can complete AFTER the user pressed stop
371
+ # (observed: 29s under CPU contention). Applying it would overwrite
372
+ # the preedit with stale partial text moments before the final
373
+ # commit rewrites it again — discard it. Freeze bookkeeping above
374
+ # is kept: it is beam-quality and shrinks the final tail.
375
+ if not final and self._stop_event.is_set():
376
+ return
377
+ # An interim pass that decoded nothing must not touch the display:
378
+ # rewriting to frozen-only text would visibly delete the un-frozen
379
+ # words the previous pass just showed (transient decoder flake).
380
+ if not final and not tail_text:
381
+ return
240
382
 
241
383
  if final:
242
- self.raw_final_text = text
243
- self.final_segments = getattr(self._transcriber, "last_segments", [])
384
+ self.raw_final_text = raw
385
+ self.final_segments = self._frozen_segments + tail_segments
244
386
  self.final_latency = {
245
387
  "audio_secs": round(len(audio) / self._sample_rate, 2),
388
+ "frozen_secs": round(self._frozen_samples / self._sample_rate, 2),
389
+ "tail_secs": round(len(tail) / self._sample_rate, 2),
246
390
  "transcribe": round(t_transcribe, 3),
247
391
  }
248
- if not text:
392
+ if not raw:
249
393
  # The final pass produced nothing. If we already have interim
250
394
  # text (e.g. the final transcription timed out on a long
251
395
  # dictation), commit that instead of silently dropping it —
@@ -253,11 +397,11 @@ class StreamingSession:
253
397
  self._commit_interim_on_final()
254
398
  return
255
399
 
256
- if text and isinstance(text, str):
400
+ if raw:
257
401
  from voiceio.postprocess import apply_pipeline
258
402
  t1 = time.monotonic()
259
403
  text, abort = apply_pipeline(
260
- text,
404
+ raw,
261
405
  do_cleanup=self._cleanup,
262
406
  number_conversion=self._number_conversion,
263
407
  language=self._language,
@@ -274,6 +418,11 @@ class StreamingSession:
274
418
  if pc_secs is not None:
275
419
  self.final_latency["postcorrect"] = round(pc_secs, 3)
276
420
  if abort:
421
+ # A voice command wiped the utterance — discard frozen text
422
+ # too, or the next pass would resurrect it.
423
+ self._frozen_raw = ""
424
+ self._frozen_segments = []
425
+ self._frozen_samples = len(audio)
277
426
  # A superseded session must not touch the typer on the final
278
427
  # path (would corrupt the newer session's output).
279
428
  if final and not self._is_current():
voiceio/transcriber.py CHANGED
@@ -18,6 +18,12 @@ if TYPE_CHECKING:
18
18
  log = logging.getLogger(__name__)
19
19
 
20
20
  TRANSCRIBE_TIMEOUT = 30 # seconds (floor; scaled up for long audio)
21
+
22
+
23
+ class TranscriptionError(RuntimeError):
24
+ """Decode failed (timeout / worker crash). Distinct from a legitimate
25
+ empty result ("" = silence) so callers never treat lost audio as decoded
26
+ silence — the streaming freeze/final paths must not advance state on it."""
21
27
  # Realtime-factor headroom for the read timeout. Whisper decodes well faster
22
28
  # than realtime, so 1.5x the audio duration is a generous ceiling that still
23
29
  # never kills a long dictation mid-decode.
@@ -152,10 +158,17 @@ class Transcriber:
152
158
  log.warning("Worker died, restarting (attempt %d/%d)", self._restarts, MAX_RESTARTS)
153
159
  self._start_worker()
154
160
 
155
- def transcribe(self, audio: np.ndarray, final: bool = False) -> str:
161
+ def transcribe(
162
+ self, audio: np.ndarray, final: bool = False, context: str | None = None,
163
+ ) -> str:
156
164
  """Transcribe audio. `final=True` marks the pass whose text the user
157
165
  keeps (streaming final / batch): it gets beam search; interim streaming
158
- passes stay greedy for latency."""
166
+ passes stay greedy for latency.
167
+
168
+ `context` is per-call text appended to the initial_prompt — used by
169
+ incremental finalization to condition a tail decode on the already-
170
+ frozen transcript so sentences stay coherent across the cut.
171
+ """
159
172
  with self._lock:
160
173
  self._ensure_worker()
161
174
 
@@ -165,8 +178,9 @@ class Transcriber:
165
178
  audio = normalize_audio(audio)
166
179
  audio_b64 = base64.b64encode(audio.tobytes()).decode("ascii")
167
180
  req = {"audio_b64": audio_b64, "options": {"beam_size": 5 if final else 1}}
168
- if self._initial_prompt:
169
- req["initial_prompt"] = self._initial_prompt
181
+ prompt = "\n".join(p for p in (self._initial_prompt, context) if p)
182
+ if prompt:
183
+ req["initial_prompt"] = prompt
170
184
  if self._hotwords:
171
185
  req["hotwords"] = self._hotwords
172
186
  try:
@@ -184,16 +198,18 @@ class Transcriber:
184
198
  timeout = transcribe_timeout(duration)
185
199
  result_line = self._read_with_timeout(timeout)
186
200
  if result_line is None:
201
+ self.last_segments = []
187
202
  log.warning("Transcription timed out after %.0fs, restarting worker", timeout)
188
203
  self._kill_worker()
189
204
  self._ensure_worker()
190
- return ""
205
+ raise TranscriptionError(f"decode timed out after {timeout:.0f}s")
191
206
 
192
207
  try:
193
208
  result = json.loads(result_line)
194
209
  except (json.JSONDecodeError, TypeError):
210
+ self.last_segments = []
195
211
  log.warning("Invalid response from worker: %s", repr(result_line)[:100])
196
- return ""
212
+ raise TranscriptionError("invalid worker response")
197
213
  text = result.get("text", "")
198
214
  self.last_segments = result.get("segments", [])
199
215