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.
- {python_voiceio-0.5.0.dist-info → python_voiceio-0.7.0.dist-info}/METADATA +1 -1
- {python_voiceio-0.5.0.dist-info → python_voiceio-0.7.0.dist-info}/RECORD +14 -14
- voiceio/__init__.py +1 -1
- voiceio/app.py +11 -0
- voiceio/config.py +25 -1
- voiceio/llm_api.py +60 -11
- voiceio/postcorrect.py +75 -6
- voiceio/retention.py +65 -1
- voiceio/streaming.py +161 -12
- voiceio/transcriber.py +22 -6
- {python_voiceio-0.5.0.dist-info → python_voiceio-0.7.0.dist-info}/WHEEL +0 -0
- {python_voiceio-0.5.0.dist-info → python_voiceio-0.7.0.dist-info}/entry_points.txt +0 -0
- {python_voiceio-0.5.0.dist-info → python_voiceio-0.7.0.dist-info}/licenses/LICENSE +0 -0
- {python_voiceio-0.5.0.dist-info → python_voiceio-0.7.0.dist-info}/top_level.txt +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
python_voiceio-0.
|
|
2
|
-
voiceio/__init__.py,sha256=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
33
|
-
voiceio/transcriber.py,sha256=
|
|
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.
|
|
75
|
-
python_voiceio-0.
|
|
76
|
-
python_voiceio-0.
|
|
77
|
-
python_voiceio-0.
|
|
78
|
-
python_voiceio-0.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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 =
|
|
243
|
-
self.final_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
|
|
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
|
|
400
|
+
if raw:
|
|
257
401
|
from voiceio.postprocess import apply_pipeline
|
|
258
402
|
t1 = time.monotonic()
|
|
259
403
|
text, abort = apply_pipeline(
|
|
260
|
-
|
|
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(
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
|
|
212
|
+
raise TranscriptionError("invalid worker response")
|
|
197
213
|
text = result.get("text", "")
|
|
198
214
|
self.last_segments = result.get("segments", [])
|
|
199
215
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|