python-voiceio 0.7.1__tar.gz → 0.8.0__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 (124) hide show
  1. {python_voiceio-0.7.1/python_voiceio.egg-info → python_voiceio-0.8.0}/PKG-INFO +1 -1
  2. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/pyproject.toml +1 -1
  3. {python_voiceio-0.7.1 → python_voiceio-0.8.0/python_voiceio.egg-info}/PKG-INFO +1 -1
  4. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/tests/test_streaming.py +29 -21
  5. python_voiceio-0.8.0/voiceio/__init__.py +1 -0
  6. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/config.py +6 -4
  7. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/streaming.py +78 -31
  8. python_voiceio-0.7.1/voiceio/__init__.py +0 -1
  9. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/LICENSE +0 -0
  10. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/README.md +0 -0
  11. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/python_voiceio.egg-info/SOURCES.txt +0 -0
  12. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/python_voiceio.egg-info/dependency_links.txt +0 -0
  13. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/python_voiceio.egg-info/entry_points.txt +0 -0
  14. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/python_voiceio.egg-info/requires.txt +0 -0
  15. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/python_voiceio.egg-info/top_level.txt +0 -0
  16. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/setup.cfg +0 -0
  17. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/tests/test_adjudicate.py +0 -0
  18. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/tests/test_app_wiring.py +0 -0
  19. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/tests/test_audio_quality.py +0 -0
  20. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/tests/test_audit.py +0 -0
  21. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/tests/test_autocorrect.py +0 -0
  22. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/tests/test_autocorrect_state.py +0 -0
  23. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/tests/test_backend_probes.py +0 -0
  24. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/tests/test_cli.py +0 -0
  25. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/tests/test_clipboard_read.py +0 -0
  26. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/tests/test_commands.py +0 -0
  27. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/tests/test_concurrency_lockdown.py +0 -0
  28. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/tests/test_config.py +0 -0
  29. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/tests/test_correct_batch.py +0 -0
  30. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/tests/test_corrections.py +0 -0
  31. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/tests/test_fallback.py +0 -0
  32. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/tests/test_health.py +0 -0
  33. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/tests/test_hints.py +0 -0
  34. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/tests/test_history.py +0 -0
  35. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/tests/test_ibus_pending.py +0 -0
  36. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/tests/test_ibus_typer.py +0 -0
  37. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/tests/test_llm.py +0 -0
  38. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/tests/test_llm_api.py +0 -0
  39. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/tests/test_numbers.py +0 -0
  40. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/tests/test_platform.py +0 -0
  41. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/tests/test_postcorrect.py +0 -0
  42. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/tests/test_postprocess.py +0 -0
  43. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/tests/test_prebuffer.py +0 -0
  44. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/tests/test_prompt.py +0 -0
  45. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/tests/test_recorder_integration.py +0 -0
  46. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/tests/test_retention.py +0 -0
  47. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/tests/test_robustness.py +0 -0
  48. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/tests/test_security_hardening.py +0 -0
  49. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/tests/test_snapshots.py +0 -0
  50. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/tests/test_transcriber.py +0 -0
  51. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/tests/test_tts.py +0 -0
  52. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/tests/test_vad.py +0 -0
  53. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/tests/test_vocabulary.py +0 -0
  54. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/tests/test_wizard.py +0 -0
  55. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/tests/test_wordfreq.py +0 -0
  56. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/__main__.py +0 -0
  57. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/app.py +0 -0
  58. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/audit.py +0 -0
  59. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/autocorrect.py +0 -0
  60. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/autocorrect_state.py +0 -0
  61. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/backends.py +0 -0
  62. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/cli.py +0 -0
  63. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/clipboard_read.py +0 -0
  64. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/commands.py +0 -0
  65. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/consent.py +0 -0
  66. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/corrections.py +0 -0
  67. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/demo.py +0 -0
  68. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/feedback.py +0 -0
  69. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/health.py +0 -0
  70. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/hints.py +0 -0
  71. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/history.py +0 -0
  72. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/hotkeys/__init__.py +0 -0
  73. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/hotkeys/base.py +0 -0
  74. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/hotkeys/chain.py +0 -0
  75. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/hotkeys/evdev.py +0 -0
  76. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/hotkeys/pynput_backend.py +0 -0
  77. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/hotkeys/socket_backend.py +0 -0
  78. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/ibus/__init__.py +0 -0
  79. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/ibus/engine.py +0 -0
  80. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/ibus/pending.py +0 -0
  81. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/llm.py +0 -0
  82. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/llm_api.py +0 -0
  83. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/models/__init__.py +0 -0
  84. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/models/silero_vad.onnx +0 -0
  85. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/numbers.py +0 -0
  86. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/pidlock.py +0 -0
  87. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/platform.py +0 -0
  88. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/postcorrect.py +0 -0
  89. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/postprocess.py +0 -0
  90. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/prompt.py +0 -0
  91. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/recorder.py +0 -0
  92. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/retention.py +0 -0
  93. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/service.py +0 -0
  94. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/snapshots.py +0 -0
  95. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/sounds/__init__.py +0 -0
  96. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/sounds/commit.wav +0 -0
  97. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/sounds/start.wav +0 -0
  98. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/sounds/stop.wav +0 -0
  99. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/transcriber.py +0 -0
  100. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/tray/__init__.py +0 -0
  101. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/tray/_icons.py +0 -0
  102. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/tray/_indicator.py +0 -0
  103. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/tray/_pystray.py +0 -0
  104. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/tts/__init__.py +0 -0
  105. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/tts/base.py +0 -0
  106. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/tts/chain.py +0 -0
  107. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/tts/edge_engine.py +0 -0
  108. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/tts/espeak.py +0 -0
  109. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/tts/piper_engine.py +0 -0
  110. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/tts/player.py +0 -0
  111. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/typers/__init__.py +0 -0
  112. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/typers/base.py +0 -0
  113. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/typers/chain.py +0 -0
  114. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/typers/clipboard.py +0 -0
  115. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/typers/ibus.py +0 -0
  116. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/typers/pynput_type.py +0 -0
  117. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/typers/wtype.py +0 -0
  118. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/typers/xdotool.py +0 -0
  119. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/typers/ydotool.py +0 -0
  120. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/vad.py +0 -0
  121. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/vocabulary.py +0 -0
  122. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/wizard.py +0 -0
  123. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/wordfreq.py +0 -0
  124. {python_voiceio-0.7.1 → python_voiceio-0.8.0}/voiceio/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 0.7.1
3
+ Version: 0.8.0
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.7.1"
7
+ version = "0.8.0"
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.7.1
3
+ Version: 0.8.0
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -678,15 +678,21 @@ class TestSegmentFreeze:
678
678
  )
679
679
  return session, transcriber, recorder
680
680
 
681
- def test_freeze_triggers_on_long_silent_tail(self):
681
+ def test_freeze_cuts_at_interior_pause(self):
682
682
  s, tr, rec = self._make_session(freeze_secs=2.0)
683
- rec.get_audio_so_far.return_value = _silent(3)
683
+ # 2.5s speech, 0.5s pause, 2s speech: cut must land in the pause
684
+ rec.get_audio_so_far.return_value = np.concatenate(
685
+ [_loud(2.5), _silent(0.5), _loud(2.0)])
684
686
  tr.transcribe.return_value = "hello there friend"
685
687
  s._transcribe_and_apply()
686
688
  assert s._frozen_raw == "hello there friend"
687
- assert s._frozen_samples == SR * 3
688
- # Freeze pass gets beam search (final=True)
689
+ # Boundary inside the pause (2.5s..3.0s), NOT at the end of audio
690
+ assert SR * 2.5 <= s._frozen_samples <= SR * 3.0
691
+ # Freeze pass gets beam search and decodes ONLY the chunk
689
692
  assert tr.transcribe.call_args.kwargs["final"] is True
693
+ assert len(tr.transcribe.call_args.args[0]) == s._frozen_samples
694
+ # Display untouched: interim text already covers this audio
695
+ s._typer.type_text.assert_not_called()
690
696
 
691
697
  def test_no_freeze_when_tail_is_speech(self):
692
698
  s, tr, rec = self._make_session(freeze_secs=2.0)
@@ -785,16 +791,15 @@ class TestDecodeTrace:
785
791
  freeze_secs=2.0,
786
792
  )
787
793
  transcriber.transcribe.return_value = "hello there friend"
788
- rec_audio = _silent(3)
789
- recorder.get_audio_so_far.return_value = rec_audio
790
- s._transcribe_and_apply() # freeze pass
794
+ recorder.get_audio_so_far.return_value = _silent(3)
795
+ s._transcribe_and_apply() # freeze pass (interior cut in silence)
791
796
  s._final_audio = np.concatenate([_silent(3), _loud(1)])
792
797
  transcriber.transcribe.return_value = "bye"
793
798
  s._transcribe_and_apply(min_seconds=0.5, final=True)
794
799
  kinds = [p["kind"] for p in s.trace]
795
800
  assert kinds == ["freeze", "final"]
796
801
  assert s.trace[0]["text"] == "hello there friend"
797
- assert s.trace[1]["tail_secs"] == 1.0
802
+ assert s.trace[1]["tail_secs"] > 0
798
803
  assert all("secs" in p for p in s.trace)
799
804
 
800
805
 
@@ -856,31 +861,34 @@ class TestDecodeFailureSafety:
856
861
  s._typer.delete_chars.assert_not_called()
857
862
  assert s._typed_text == "hello there friend how are"
858
863
 
859
- def test_quiet_mic_speech_is_not_silence(self):
860
- from voiceio.streaming import _tail_ends_in_silence
864
+ def test_quiet_mic_speech_finds_no_cut(self):
865
+ from voiceio.streaming import _find_freeze_cut
861
866
  # Continuous speech on a low-gain mic: quiet in absolute terms but
862
- # uniform — trailing window matches overall level, so NOT a pause.
867
+ # uniform — no window is quiet relative to the tail, so no cut.
863
868
  quiet_speech = np.full(SR * 3, 0.005, dtype=np.float32)
864
- assert not _tail_ends_in_silence(quiet_speech, SR)
865
- # Same quiet mic with a real trailing pause IS a pause.
869
+ assert _find_freeze_cut(quiet_speech, SR, 2.0) is None
870
+ # Same quiet mic with a real pause after the threshold IS cuttable.
866
871
  with_pause = np.concatenate([
867
- np.full(int(SR * 2.5), 0.005, dtype=np.float32), _silent(0.5),
872
+ np.full(int(SR * 2.2), 0.005, dtype=np.float32), _silent(0.5),
873
+ np.full(int(SR * 0.3), 0.005, dtype=np.float32),
868
874
  ])
869
- assert _tail_ends_in_silence(with_pause, SR)
875
+ cut = _find_freeze_cut(with_pause, SR, 2.0)
876
+ assert cut is not None and SR * 2.2 <= cut <= SR * 2.8
870
877
 
871
878
 
872
879
  class TestFreezeCalibration:
873
- def test_hot_noise_floor_pause_is_silence(self):
880
+ def test_hot_noise_floor_pause_is_cuttable(self):
874
881
  """Real-mic calibration: noise floor ~0.3x of tail RMS IS a pause."""
875
- from voiceio.streaming import _tail_ends_in_silence
882
+ from voiceio.streaming import _find_freeze_cut
876
883
  speech = np.full(int(SR * 2.5), 0.12, dtype=np.float32)
877
884
  pause = np.full(int(SR * 0.5), 0.03, dtype=np.float32) # noise floor
878
- assert _tail_ends_in_silence(np.concatenate([speech, pause]), SR)
885
+ cut = _find_freeze_cut(np.concatenate([speech, pause]), SR, 2.0)
886
+ assert cut is not None and cut >= SR * 2.3
879
887
 
880
- def test_speech_level_window_is_not_silence(self):
881
- from voiceio.streaming import _tail_ends_in_silence
888
+ def test_speech_level_tail_has_no_cut(self):
889
+ from voiceio.streaming import _find_freeze_cut
882
890
  tail = np.full(SR * 3, 0.12, dtype=np.float32)
883
- assert not _tail_ends_in_silence(tail, SR)
891
+ assert _find_freeze_cut(tail, SR, 2.0) is None
884
892
 
885
893
 
886
894
  class TestDecodeBackpressure:
@@ -0,0 +1 @@
1
+ __version__ = "0.8.0"
@@ -85,10 +85,12 @@ class OutputConfig:
85
85
  number_conversion: bool = True
86
86
  voice_input_prefix: str = "" # e.g. "[voice]" — empty disables
87
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
88
+ # this many seconds, it is cut at the nearest interior speech pause,
89
+ # beam-decoded and frozen during recording, so interim passes and the
90
+ # stop-time final decode only cover the short remaining tail. Smaller
91
+ # chunks decode faster (less worker blocking) at slightly more boundary
92
+ # risk. 0 disables (full re-decode at stop).
93
+ streaming_freeze_secs: float = 15.0
92
94
  # Mirror transcribed text to the system clipboard so it can be pasted:
93
95
  # "off" — never
94
96
  # "final" — the corrected final text, once ready
@@ -25,8 +25,7 @@ if TYPE_CHECKING:
25
25
  log = logging.getLogger(__name__)
26
26
  DELETE_SETTLE_SECS = 0.05 # delay between delete and type for ydotool reliability
27
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.
28
+ # Incremental finalization: a freeze cut is only safe at a speech pause.
30
29
  _FREEZE_SILENCE_WINDOW_SECS = 0.3
31
30
  # Quiet is judged RELATIVE to the tail's own level: mic gains vary wildly
32
31
  # and the decode path normalizes audio anyway, so an absolute threshold
@@ -37,6 +36,10 @@ _FREEZE_SILENCE_WINDOW_SECS = 0.3
37
36
  # both with margin; 0.15 never fired on a mic with a normal noise floor.
38
37
  _FREEZE_SILENCE_RATIO = 0.6
39
38
  _FREEZE_SILENCE_FLOOR = 1e-4 # digital silence is always quiet
39
+ # How far past freeze_secs to look for a pause. Real speech has a usable
40
+ # pause every ~5s (measured: longest pause-free stretch 5.6s in a 3min
41
+ # "continuous" note), so 20s of search span practically always finds one.
42
+ _FREEZE_SEARCH_SECS = 20.0
40
43
  # Whisper conditions on ~224 prompt tokens; more frozen context is wasted.
41
44
  _FREEZE_CONTEXT_CHARS = 400
42
45
 
@@ -45,14 +48,31 @@ def _rms(samples: np.ndarray) -> float:
45
48
  return float(np.sqrt(np.mean(np.square(samples, dtype=np.float64))))
46
49
 
47
50
 
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
51
+ def _find_freeze_cut(
52
+ tail: np.ndarray, sample_rate: int, min_secs: float,
53
+ ) -> int | None:
54
+ """Sample index of the best freeze cut in `tail`, or None.
55
+
56
+ Searches tail[min_secs : min_secs+_FREEZE_SEARCH_SECS] for the quietest
57
+ 300ms window below the relative silence threshold and cuts at that
58
+ window's end. Searching the interior (rather than requiring the pass to
59
+ land on a trailing pause) is what makes freezing actually fire on real
60
+ speech: a pass's trailing 300ms is almost never a pause, but the
61
+ interior of a 15-35s span almost always contains one.
62
+ """
63
+ win = int(_FREEZE_SILENCE_WINDOW_SECS * sample_rate)
64
+ start = int(min_secs * sample_rate)
65
+ end = min(len(tail), int((min_secs + _FREEZE_SEARCH_SECS) * sample_rate))
66
+ if end - start < win:
67
+ return None
68
+ threshold = max(_FREEZE_SILENCE_RATIO * _rms(tail[:end]), _FREEZE_SILENCE_FLOOR)
69
+ best_cut, best_rms = None, threshold
70
+ for i in range(start, end - win + 1, win // 2):
71
+ window_rms = _rms(tail[i:i + win])
72
+ if window_rms < best_rms:
73
+ best_rms = window_rms
74
+ best_cut = i + win
75
+ return best_cut
56
76
 
57
77
 
58
78
  def _common_prefix_len(a: str, b: str) -> int:
@@ -277,6 +297,40 @@ class StreamingSession:
277
297
  return None
278
298
  return self._frozen_raw[-_FREEZE_CONTEXT_CHARS:]
279
299
 
300
+ def _freeze_chunk(self, audio: np.ndarray, chunk: np.ndarray) -> None:
301
+ """Beam-decode `chunk` (the tail up to a pause cut) and freeze it.
302
+
303
+ On decode failure the boundary is left untouched — the audio will be
304
+ decoded again by a later pass (never treat lost audio as silence).
305
+ """
306
+ t0 = time.monotonic()
307
+ try:
308
+ chunk_text = self._transcriber.transcribe(
309
+ chunk, final=True, context=self._frozen_context(),
310
+ )
311
+ except Exception:
312
+ log.exception("Freeze decode failed (boundary unchanged)")
313
+ return
314
+ t_decode = time.monotonic() - t0
315
+ self._frozen_raw = _join_text(self._frozen_raw, chunk_text)
316
+ self._frozen_samples += len(chunk)
317
+ self._frozen_segments.extend(
318
+ list(getattr(self._transcriber, "last_segments", [])))
319
+ self._last_decode_end = len(audio)
320
+ self._last_decode_secs = t_decode
321
+ self.trace.append({
322
+ "t": round(time.monotonic() - self._t0, 2),
323
+ "kind": "freeze",
324
+ "frozen_secs": round(self._frozen_samples / self._sample_rate, 2),
325
+ "tail_secs": round(len(chunk) / self._sample_rate, 2),
326
+ "secs": round(t_decode, 3),
327
+ "text": chunk_text,
328
+ })
329
+ log.debug(
330
+ "Froze %.1fs of audio in %.1fs (frozen text now %d chars)",
331
+ len(chunk) / self._sample_rate, t_decode, len(self._frozen_raw),
332
+ )
333
+
280
334
  def _transcribe_and_apply(
281
335
  self, min_seconds: float = 1.0, final: bool = False,
282
336
  ) -> None:
@@ -307,25 +361,26 @@ class StreamingSession:
307
361
  if not final and grown_secs < max(min_seconds, self._last_decode_secs):
308
362
  return # backpressure: don't decode faster than we can keep up
309
363
 
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
- )
364
+ # Freeze: once the tail is long enough, cut it at an interior speech
365
+ # pause and beam-decode ONLY that chunk (its text is never decoded
366
+ # again). The freeze pass does not touch the display — the interim
367
+ # text already covers this audio; the next interim refreshes it.
368
+ if (not final and self._freeze_secs > 0
369
+ and len(tail) >= self._freeze_secs * self._sample_rate):
370
+ cut = _find_freeze_cut(tail, self._sample_rate, self._freeze_secs)
371
+ if cut is not None:
372
+ self._freeze_chunk(audio, tail[:cut])
373
+ return
319
374
 
320
375
  tail_text = ""
321
376
  tail_segments: list[dict] = []
322
377
  t0 = time.monotonic()
323
378
  # The final pass decodes ANY remaining audio (a clipped last word
324
- # must not be dropped); interim/freeze passes skip sub-0.3s tails.
379
+ # must not be dropped); interim passes skip sub-0.3s tails.
325
380
  if len(tail) > (0 if final else self._sample_rate * 0.3):
326
381
  try:
327
382
  tail_text = self._transcriber.transcribe(
328
- tail, final=final or freeze, context=self._frozen_context(),
383
+ tail, final=final, context=self._frozen_context(),
329
384
  )
330
385
  except Exception:
331
386
  # Decode FAILED (timeout/crash) — this is not silence. Never
@@ -350,22 +405,14 @@ class StreamingSession:
350
405
 
351
406
  self.trace.append({
352
407
  "t": round(time.monotonic() - self._t0, 2),
353
- "kind": "final" if final else ("freeze" if freeze else "interim"),
408
+ "kind": "final" if final else "interim",
354
409
  "frozen_secs": round(self._frozen_samples / self._sample_rate, 2),
355
410
  "tail_secs": round(len(tail) / self._sample_rate, 2),
356
411
  "secs": round(t_transcribe, 3),
357
412
  "text": tail_text,
358
413
  })
359
414
 
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)
415
+ raw = _join_text(self._frozen_raw, tail_text)
369
416
 
370
417
  # A slow interim decode can complete AFTER the user pressed stop
371
418
  # (observed: 29s under CPU contention). Applying it would overwrite
@@ -1 +0,0 @@
1
- __version__ = "0.7.1"
File without changes
File without changes
File without changes