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.
Files changed (130) hide show
  1. {python_voiceio-0.9.2/python_voiceio.egg-info → python_voiceio-0.9.4}/PKG-INFO +1 -1
  2. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/pyproject.toml +1 -1
  3. {python_voiceio-0.9.2 → python_voiceio-0.9.4/python_voiceio.egg-info}/PKG-INFO +1 -1
  4. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_robustness.py +91 -0
  5. python_voiceio-0.9.4/voiceio/__init__.py +1 -0
  6. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/app.py +22 -2
  7. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/config.py +5 -0
  8. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/recorder.py +45 -4
  9. python_voiceio-0.9.2/voiceio/__init__.py +0 -1
  10. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/LICENSE +0 -0
  11. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/README.md +0 -0
  12. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/python_voiceio.egg-info/SOURCES.txt +0 -0
  13. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/python_voiceio.egg-info/dependency_links.txt +0 -0
  14. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/python_voiceio.egg-info/entry_points.txt +0 -0
  15. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/python_voiceio.egg-info/requires.txt +0 -0
  16. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/python_voiceio.egg-info/top_level.txt +0 -0
  17. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/setup.cfg +0 -0
  18. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_adjudicate.py +0 -0
  19. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_app_wiring.py +0 -0
  20. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_audio_quality.py +0 -0
  21. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_audit.py +0 -0
  22. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_autocorrect.py +0 -0
  23. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_autocorrect_state.py +0 -0
  24. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_backend_probes.py +0 -0
  25. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_cli.py +0 -0
  26. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_clipboard_read.py +0 -0
  27. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_commands.py +0 -0
  28. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_concurrency_lockdown.py +0 -0
  29. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_config.py +0 -0
  30. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_correct_batch.py +0 -0
  31. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_corrections.py +0 -0
  32. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_evaluate.py +0 -0
  33. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_fallback.py +0 -0
  34. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_health.py +0 -0
  35. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_hints.py +0 -0
  36. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_history.py +0 -0
  37. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_ibus_pending.py +0 -0
  38. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_ibus_ping.py +0 -0
  39. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_ibus_typer.py +0 -0
  40. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_llm.py +0 -0
  41. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_llm_api.py +0 -0
  42. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_numbers.py +0 -0
  43. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_platform.py +0 -0
  44. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_postcorrect.py +0 -0
  45. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_postprocess.py +0 -0
  46. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_prebuffer.py +0 -0
  47. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_prompt.py +0 -0
  48. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_recorder_integration.py +0 -0
  49. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_retention.py +0 -0
  50. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_security_hardening.py +0 -0
  51. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_snapshots.py +0 -0
  52. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_streaming.py +0 -0
  53. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_tokens.py +0 -0
  54. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_transcriber.py +0 -0
  55. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_tts.py +0 -0
  56. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_vad.py +0 -0
  57. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_vocabulary.py +0 -0
  58. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_wizard.py +0 -0
  59. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/tests/test_wordfreq.py +0 -0
  60. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/__main__.py +0 -0
  61. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/audit.py +0 -0
  62. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/autocorrect.py +0 -0
  63. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/autocorrect_state.py +0 -0
  64. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/backends.py +0 -0
  65. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/cli.py +0 -0
  66. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/clipboard_read.py +0 -0
  67. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/commands.py +0 -0
  68. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/consent.py +0 -0
  69. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/corrections.py +0 -0
  70. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/demo.py +0 -0
  71. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/evaluate.py +0 -0
  72. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/feedback.py +0 -0
  73. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/health.py +0 -0
  74. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/hints.py +0 -0
  75. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/history.py +0 -0
  76. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/hotkeys/__init__.py +0 -0
  77. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/hotkeys/base.py +0 -0
  78. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/hotkeys/chain.py +0 -0
  79. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/hotkeys/evdev.py +0 -0
  80. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/hotkeys/pynput_backend.py +0 -0
  81. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/hotkeys/socket_backend.py +0 -0
  82. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/ibus/__init__.py +0 -0
  83. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/ibus/engine.py +0 -0
  84. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/ibus/pending.py +0 -0
  85. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/llm.py +0 -0
  86. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/llm_api.py +0 -0
  87. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/models/__init__.py +0 -0
  88. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/models/silero_vad.onnx +0 -0
  89. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/numbers.py +0 -0
  90. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/pidlock.py +0 -0
  91. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/platform.py +0 -0
  92. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/postcorrect.py +0 -0
  93. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/postprocess.py +0 -0
  94. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/prompt.py +0 -0
  95. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/retention.py +0 -0
  96. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/service.py +0 -0
  97. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/snapshots.py +0 -0
  98. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/sounds/__init__.py +0 -0
  99. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/sounds/commit.wav +0 -0
  100. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/sounds/start.wav +0 -0
  101. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/sounds/stop.wav +0 -0
  102. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/streaming.py +0 -0
  103. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/tokens.py +0 -0
  104. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/transcriber.py +0 -0
  105. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/tray/__init__.py +0 -0
  106. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/tray/_icons.py +0 -0
  107. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/tray/_indicator.py +0 -0
  108. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/tray/_pystray.py +0 -0
  109. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/tts/__init__.py +0 -0
  110. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/tts/base.py +0 -0
  111. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/tts/chain.py +0 -0
  112. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/tts/edge_engine.py +0 -0
  113. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/tts/espeak.py +0 -0
  114. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/tts/piper_engine.py +0 -0
  115. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/tts/player.py +0 -0
  116. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/typers/__init__.py +0 -0
  117. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/typers/base.py +0 -0
  118. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/typers/chain.py +0 -0
  119. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/typers/clipboard.py +0 -0
  120. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/typers/ibus.py +0 -0
  121. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/typers/pynput_type.py +0 -0
  122. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/typers/wtype.py +0 -0
  123. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/typers/xdotool.py +0 -0
  124. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/typers/ydotool.py +0 -0
  125. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/vad.py +0 -0
  126. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/vocab_stats.py +0 -0
  127. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/vocabulary.py +0 -0
  128. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/wizard.py +0 -0
  129. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/wordfreq.py +0 -0
  130. {python_voiceio-0.9.2 → python_voiceio-0.9.4}/voiceio/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 0.9.2
3
+ Version: 0.9.4
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-voiceio"
7
- version = "0.9.2"
7
+ version = "0.9.4"
8
8
  description = "Voice dictation for Linux. Speak → text, locally, instantly."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 0.9.2
3
+ Version: 0.9.4
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -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 sustained silence triggers auto-stop."""
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
- self._on_auto_stop: Callable[[], None] | None = None
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
- self._meter_peak = max(self._meter_peak, float(abs_chunk.max(initial=0.0)))
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