python-voiceio 0.9.2__tar.gz → 0.9.4__tar.gz
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.9.2/python_voiceio.egg-info → python_voiceio-0.9.4}/PKG-INFO +1 -1
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/pyproject.toml +1 -1
- {python_voiceio-0.9.2 → python_voiceio-0.9.4/python_voiceio.egg-info}/PKG-INFO +1 -1
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_robustness.py +91 -0
- python_voiceio-0.9.4/voiceio/__init__.py +1 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/app.py +22 -2
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/config.py +5 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/recorder.py +45 -4
- python_voiceio-0.9.2/voiceio/__init__.py +0 -1
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/LICENSE +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/README.md +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/python_voiceio.egg-info/SOURCES.txt +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/python_voiceio.egg-info/dependency_links.txt +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/python_voiceio.egg-info/entry_points.txt +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/python_voiceio.egg-info/requires.txt +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/python_voiceio.egg-info/top_level.txt +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/setup.cfg +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_adjudicate.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_app_wiring.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_audio_quality.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_audit.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_autocorrect.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_autocorrect_state.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_backend_probes.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_cli.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_clipboard_read.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_commands.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_concurrency_lockdown.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_config.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_correct_batch.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_corrections.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_evaluate.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_fallback.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_health.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_hints.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_history.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_ibus_pending.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_ibus_ping.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_ibus_typer.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_llm.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_llm_api.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_numbers.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_platform.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_postcorrect.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_postprocess.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_prebuffer.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_prompt.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_recorder_integration.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_retention.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_security_hardening.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_snapshots.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_streaming.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_tokens.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_transcriber.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_tts.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_vad.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_vocabulary.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_wizard.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_wordfreq.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/__main__.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/audit.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/autocorrect.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/autocorrect_state.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/backends.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/cli.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/clipboard_read.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/commands.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/consent.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/corrections.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/demo.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/evaluate.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/feedback.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/health.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/hints.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/history.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/hotkeys/__init__.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/hotkeys/base.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/hotkeys/chain.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/hotkeys/evdev.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/hotkeys/pynput_backend.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/hotkeys/socket_backend.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/ibus/__init__.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/ibus/engine.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/ibus/pending.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/llm.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/llm_api.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/models/__init__.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/models/silero_vad.onnx +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/numbers.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/pidlock.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/platform.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/postcorrect.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/postprocess.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/prompt.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/retention.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/service.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/snapshots.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/sounds/__init__.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/sounds/commit.wav +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/sounds/start.wav +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/sounds/stop.wav +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/streaming.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/tokens.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/transcriber.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/tray/__init__.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/tray/_icons.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/tray/_indicator.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/tray/_pystray.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/tts/__init__.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/tts/base.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/tts/chain.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/tts/edge_engine.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/tts/espeak.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/tts/piper_engine.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/tts/player.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/typers/__init__.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/typers/base.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/typers/chain.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/typers/clipboard.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/typers/ibus.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/typers/pynput_type.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/typers/wtype.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/typers/xdotool.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/typers/ydotool.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/vad.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/vocab_stats.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/vocabulary.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/wizard.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/wordfreq.py +0 -0
- {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/worker.py +0 -0
|
@@ -63,6 +63,7 @@ def _make_recorder():
|
|
|
63
63
|
cfg.silence_threshold = 0.01
|
|
64
64
|
cfg.silence_duration = 0.6
|
|
65
65
|
cfg.auto_stop_silence_secs = 5.0
|
|
66
|
+
cfg.auto_stop_no_speech_secs = 20.0
|
|
66
67
|
|
|
67
68
|
with patch("voiceio.recorder.sd"):
|
|
68
69
|
from voiceio.recorder import AudioRecorder
|
|
@@ -72,6 +73,96 @@ def _make_recorder():
|
|
|
72
73
|
return rec
|
|
73
74
|
|
|
74
75
|
|
|
76
|
+
class TestAutoStop:
|
|
77
|
+
"""Auto-stop distinguishes 'spoke then paused' from 'nothing ever heard'."""
|
|
78
|
+
|
|
79
|
+
def _feed(self, rec, secs, *, speech, amplitude=0.0):
|
|
80
|
+
"""Drive _callback with `secs` of audio.
|
|
81
|
+
|
|
82
|
+
speech → what the (mocked) VAD reports (is_speech).
|
|
83
|
+
amplitude → raw sample level; 0.0 = digital silence (muted mic),
|
|
84
|
+
>1e-4 = a real audio signal reaching the mic.
|
|
85
|
+
"""
|
|
86
|
+
rec._vad.is_speech.return_value = speech
|
|
87
|
+
frames = int(secs * rec.sample_rate)
|
|
88
|
+
data = np.full((frames, 1), amplitude, dtype=np.float32)
|
|
89
|
+
rec._callback(data, frames, None, None)
|
|
90
|
+
|
|
91
|
+
def test_muted_mic_fires_no_speech_reason(self):
|
|
92
|
+
rec = _make_recorder()
|
|
93
|
+
rec._recording = True
|
|
94
|
+
fired = []
|
|
95
|
+
rec.set_on_auto_stop(lambda reason: fired.append(reason))
|
|
96
|
+
# 21s of digital zeros — a muted / dead mic.
|
|
97
|
+
self._feed(rec, 21, speech=False, amplitude=0.0)
|
|
98
|
+
assert fired == ["no_speech"]
|
|
99
|
+
|
|
100
|
+
def test_real_audio_vad_misses_does_not_autostop(self):
|
|
101
|
+
"""Regression: real audio the VAD fails to flag as speech must NOT be
|
|
102
|
+
cut off. This is the 0.9.3 bug — a 20s dictation the Silero VAD scored
|
|
103
|
+
as silence auto-stopped mid-sentence."""
|
|
104
|
+
rec = _make_recorder()
|
|
105
|
+
rec._recording = True
|
|
106
|
+
fired = []
|
|
107
|
+
rec.set_on_auto_stop(lambda reason: fired.append(reason))
|
|
108
|
+
# 25s of clearly-present audio, but the VAD never calls it speech.
|
|
109
|
+
self._feed(rec, 25, speech=False, amplitude=0.05)
|
|
110
|
+
assert fired == [] # signal is present → neither auto-stop fires
|
|
111
|
+
|
|
112
|
+
def test_no_speech_does_not_fire_early(self):
|
|
113
|
+
rec = _make_recorder()
|
|
114
|
+
rec._recording = True
|
|
115
|
+
fired = []
|
|
116
|
+
rec.set_on_auto_stop(lambda reason: fired.append(reason))
|
|
117
|
+
self._feed(rec, 10, speech=False, amplitude=0.0) # below the 20s threshold
|
|
118
|
+
assert fired == []
|
|
119
|
+
|
|
120
|
+
def test_speech_then_silence_fires_silence_reason(self):
|
|
121
|
+
rec = _make_recorder()
|
|
122
|
+
rec._recording = True
|
|
123
|
+
fired = []
|
|
124
|
+
rec.set_on_auto_stop(lambda reason: fired.append(reason))
|
|
125
|
+
self._feed(rec, 1, speech=True) # heard speech → _heard_speech
|
|
126
|
+
self._feed(rec, 6, speech=False) # 6s silence ≥ 5s auto_stop
|
|
127
|
+
assert fired == ["silence"]
|
|
128
|
+
|
|
129
|
+
def test_single_fire(self):
|
|
130
|
+
"""Callback is cleared on fire — a later chunk must not re-trigger."""
|
|
131
|
+
rec = _make_recorder()
|
|
132
|
+
rec._recording = True
|
|
133
|
+
fired = []
|
|
134
|
+
rec.set_on_auto_stop(lambda reason: fired.append(reason))
|
|
135
|
+
self._feed(rec, 21, speech=False)
|
|
136
|
+
self._feed(rec, 21, speech=False)
|
|
137
|
+
assert fired == ["no_speech"]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class TestAutoStopNotification:
|
|
141
|
+
"""The app surfaces a 'no_speech' auto-stop; a plain 'silence' one is quiet."""
|
|
142
|
+
|
|
143
|
+
def test_no_speech_muted_mic_notifies(self):
|
|
144
|
+
vio, _, _ = _make_vio()
|
|
145
|
+
vio.recorder.has_signal = MagicMock(return_value=False) # all zeros = muted
|
|
146
|
+
with patch("voiceio.feedback.notify") as notify:
|
|
147
|
+
vio._on_auto_stop("no_speech")
|
|
148
|
+
assert notify.called
|
|
149
|
+
assert "mute" in " ".join(notify.call_args[0]).lower()
|
|
150
|
+
|
|
151
|
+
def test_no_speech_with_signal_notifies_generic(self):
|
|
152
|
+
vio, _, _ = _make_vio()
|
|
153
|
+
vio.recorder.has_signal = MagicMock(return_value=True) # mic works, user quiet
|
|
154
|
+
with patch("voiceio.feedback.notify") as notify:
|
|
155
|
+
vio._on_auto_stop("no_speech")
|
|
156
|
+
assert notify.called
|
|
157
|
+
assert "mute" not in " ".join(notify.call_args[0]).lower()
|
|
158
|
+
|
|
159
|
+
def test_normal_silence_is_quiet(self):
|
|
160
|
+
vio, _, _ = _make_vio()
|
|
161
|
+
with patch("voiceio.feedback.notify") as notify:
|
|
162
|
+
vio._on_auto_stop("silence")
|
|
163
|
+
assert not notify.called
|
|
164
|
+
|
|
165
|
+
|
|
75
166
|
# ===========================================================================
|
|
76
167
|
# 1. stream_health() tests
|
|
77
168
|
# ===========================================================================
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.9.4"
|
|
@@ -283,8 +283,28 @@ class VoiceIO:
|
|
|
283
283
|
self._last_hotkey = now
|
|
284
284
|
self._toggle()
|
|
285
285
|
|
|
286
|
-
def _on_auto_stop(self) -> None:
|
|
287
|
-
"""Called from audio thread when
|
|
286
|
+
def _on_auto_stop(self, reason: str = "silence") -> None:
|
|
287
|
+
"""Called from the audio thread when the recorder auto-stops.
|
|
288
|
+
|
|
289
|
+
reason="silence": spoke, then went quiet — a normal end of dictation.
|
|
290
|
+
reason="no_speech": nothing was heard the whole time — a muted mic or a
|
|
291
|
+
hotkey pressed by accident. Surface it so it isn't a silent failure
|
|
292
|
+
(today's Discord-mute confusion: recording ran, nothing was captured,
|
|
293
|
+
and there was no sign why).
|
|
294
|
+
"""
|
|
295
|
+
if reason == "no_speech":
|
|
296
|
+
from voiceio import feedback
|
|
297
|
+
if not self.recorder.has_signal():
|
|
298
|
+
feedback.notify(
|
|
299
|
+
"VoiceIO: microphone is muted",
|
|
300
|
+
"Stopped — no audio is reaching voiceio. Check your mic's "
|
|
301
|
+
"mute button and that the right input device is selected.",
|
|
302
|
+
)
|
|
303
|
+
else:
|
|
304
|
+
feedback.notify(
|
|
305
|
+
"VoiceIO: stopped — no speech detected",
|
|
306
|
+
"Recording auto-stopped after silence.",
|
|
307
|
+
)
|
|
288
308
|
threading.Thread(target=self._request_stop, daemon=True).start()
|
|
289
309
|
|
|
290
310
|
def _request_stop(self) -> None:
|
|
@@ -71,6 +71,11 @@ class AudioConfig:
|
|
|
71
71
|
silence_threshold: float = 0.01
|
|
72
72
|
silence_duration: float = 0.6
|
|
73
73
|
auto_stop_silence_secs: float = 5.0
|
|
74
|
+
# Safety net for a muted mic or a forgotten hotkey: if NO speech is heard
|
|
75
|
+
# for this long from the start, stop and notify. The 5s auto-stop above
|
|
76
|
+
# only fires once speech has been heard, so it can't catch a dead mic.
|
|
77
|
+
# 0 disables.
|
|
78
|
+
auto_stop_no_speech_secs: float = 20.0
|
|
74
79
|
vad_backend: str = "silero" # "silero" | "rms"
|
|
75
80
|
vad_threshold: float = 0.5 # Silero speech probability threshold
|
|
76
81
|
|
|
@@ -15,6 +15,11 @@ if TYPE_CHECKING:
|
|
|
15
15
|
|
|
16
16
|
log = logging.getLogger(__name__)
|
|
17
17
|
|
|
18
|
+
# Peak amplitude below this is treated as no signal at all (a muted mic emits
|
|
19
|
+
# exact zeros; a working mic's "silence" still noises above ~1e-4). Matches the
|
|
20
|
+
# floor in has_signal(). Used only to detect a dead mic, never speech vs pause.
|
|
21
|
+
_SIGNAL_FLOOR = 1e-4
|
|
22
|
+
|
|
18
23
|
|
|
19
24
|
class RingBuffer:
|
|
20
25
|
"""Fixed-size ring buffer for float32 audio samples."""
|
|
@@ -108,9 +113,16 @@ class AudioRecorder:
|
|
|
108
113
|
|
|
109
114
|
# Auto-stop on sustained silence
|
|
110
115
|
self._auto_stop_secs = cfg.auto_stop_silence_secs
|
|
116
|
+
self._no_speech_secs = cfg.auto_stop_no_speech_secs
|
|
111
117
|
self._sustained_silence = 0.0
|
|
112
118
|
self._heard_speech = False
|
|
113
|
-
|
|
119
|
+
# Raw-signal (amplitude) tracking for the muted-mic auto-stop, kept
|
|
120
|
+
# separate from the VAD-based _heard_speech / _sustained_silence above.
|
|
121
|
+
self._heard_signal = False
|
|
122
|
+
self._silent_signal_secs = 0.0
|
|
123
|
+
# Called with a reason: "silence" (spoke then paused) or "no_speech"
|
|
124
|
+
# (nothing ever heard — a muted mic or a forgotten hotkey).
|
|
125
|
+
self._on_auto_stop: Callable[[str], None] | None = None
|
|
114
126
|
|
|
115
127
|
# Heartbeat: updated by _callback, checked by health watchdog
|
|
116
128
|
self._last_callback_time: float = 0.0
|
|
@@ -218,6 +230,8 @@ class AudioRecorder:
|
|
|
218
230
|
self._silent_chunks = 0.0
|
|
219
231
|
self._sustained_silence = 0.0
|
|
220
232
|
self._heard_speech = False
|
|
233
|
+
self._heard_signal = False
|
|
234
|
+
self._silent_signal_secs = 0.0
|
|
221
235
|
self._pause_fired_at = 0
|
|
222
236
|
self._meter_peak = 0.0
|
|
223
237
|
self._meter_clipped = 0
|
|
@@ -260,7 +274,7 @@ class AudioRecorder:
|
|
|
260
274
|
"""Set/clear the speech pause callback (used by streaming session)."""
|
|
261
275
|
self._on_speech_pause = callback
|
|
262
276
|
|
|
263
|
-
def set_on_auto_stop(self, callback: Callable[[], None] | None) -> None:
|
|
277
|
+
def set_on_auto_stop(self, callback: Callable[[str], None] | None) -> None:
|
|
264
278
|
"""Set/clear the auto-stop callback (fires after sustained silence)."""
|
|
265
279
|
self._on_auto_stop = callback
|
|
266
280
|
|
|
@@ -298,8 +312,19 @@ class AudioRecorder:
|
|
|
298
312
|
|
|
299
313
|
# Level metering
|
|
300
314
|
abs_chunk = np.abs(chunk.ravel())
|
|
301
|
-
|
|
315
|
+
chunk_peak = float(abs_chunk.max(initial=0.0))
|
|
316
|
+
self._meter_peak = max(self._meter_peak, chunk_peak)
|
|
302
317
|
self._meter_samples += len(abs_chunk)
|
|
318
|
+
|
|
319
|
+
# Raw-signal presence, independent of the VAD. A muted mic delivers
|
|
320
|
+
# digital zeros; real audio (even speech the VAD fails to classify)
|
|
321
|
+
# sits well above the floor. This — NOT the VAD — gates the muted-mic
|
|
322
|
+
# auto-stop, so a recording is never cut while real audio is coming in.
|
|
323
|
+
if chunk_peak > _SIGNAL_FLOOR:
|
|
324
|
+
self._heard_signal = True
|
|
325
|
+
self._silent_signal_secs = 0.0
|
|
326
|
+
else:
|
|
327
|
+
self._silent_signal_secs += frames / self.sample_rate
|
|
303
328
|
pinned = abs_chunk >= 0.99
|
|
304
329
|
if np.count_nonzero(pinned) >= 4:
|
|
305
330
|
runs = np.convolve(pinned.astype(np.int8), np.ones(4, dtype=np.int8), "valid")
|
|
@@ -340,4 +365,20 @@ class AudioRecorder:
|
|
|
340
365
|
self._on_auto_stop = None
|
|
341
366
|
self._sustained_silence = 0.0
|
|
342
367
|
log.info("Auto-stopping after %.0fs of silence", self._auto_stop_secs)
|
|
343
|
-
cb()
|
|
368
|
+
cb("silence")
|
|
369
|
+
|
|
370
|
+
# Safety net for a dead mic: NO raw audio signal at all for a long
|
|
371
|
+
# time — the mic is muted / unplugged / delivering zeros, never merely
|
|
372
|
+
# that the VAD didn't flag speech (that would cut real dictation off
|
|
373
|
+
# mid-sentence). _heard_signal latches on the first non-zero chunk, so
|
|
374
|
+
# this only fires for a mic that was silent from the very start.
|
|
375
|
+
elif (self._on_auto_stop is not None
|
|
376
|
+
and self._no_speech_secs > 0
|
|
377
|
+
and not self._heard_signal
|
|
378
|
+
and self._silent_signal_secs >= self._no_speech_secs):
|
|
379
|
+
cb = self._on_auto_stop
|
|
380
|
+
self._on_auto_stop = None
|
|
381
|
+
self._silent_signal_secs = 0.0
|
|
382
|
+
log.info("Auto-stopping: no mic signal in %.0fs (muted?)",
|
|
383
|
+
self._no_speech_secs)
|
|
384
|
+
cb("no_speech")
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.9.2"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|