subtitld 0.0.0.dev0__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.
Files changed (237) hide show
  1. subtitld/__init__.py +19 -0
  2. subtitld/__main__.py +240 -0
  3. subtitld/ftfy/char_classes.dat +0 -0
  4. subtitld/graphics/Montserrat-Black.ttf +0 -0
  5. subtitld/graphics/Montserrat-BlackItalic.ttf +0 -0
  6. subtitld/graphics/Montserrat-Bold.ttf +0 -0
  7. subtitld/graphics/Montserrat-BoldItalic.ttf +0 -0
  8. subtitld/graphics/Montserrat-ExtraBold.ttf +0 -0
  9. subtitld/graphics/Montserrat-ExtraBoldItalic.ttf +0 -0
  10. subtitld/graphics/Montserrat-ExtraLight.ttf +0 -0
  11. subtitld/graphics/Montserrat-ExtraLightItalic.ttf +0 -0
  12. subtitld/graphics/Montserrat-Italic.ttf +0 -0
  13. subtitld/graphics/Montserrat-Light.ttf +0 -0
  14. subtitld/graphics/Montserrat-LightItalic.ttf +0 -0
  15. subtitld/graphics/Montserrat-Medium.ttf +0 -0
  16. subtitld/graphics/Montserrat-MediumItalic.ttf +0 -0
  17. subtitld/graphics/Montserrat-Regular.ttf +0 -0
  18. subtitld/graphics/Montserrat-SemiBold.ttf +0 -0
  19. subtitld/graphics/Montserrat-SemiBoldItalic.ttf +0 -0
  20. subtitld/graphics/Montserrat-Thin.ttf +0 -0
  21. subtitld/graphics/Montserrat-ThinItalic.ttf +0 -0
  22. subtitld/graphics/Ubuntu-B.ttf +0 -0
  23. subtitld/graphics/Ubuntu-BI.ttf +0 -0
  24. subtitld/graphics/Ubuntu-C.ttf +0 -0
  25. subtitld/graphics/Ubuntu-L.ttf +0 -0
  26. subtitld/graphics/Ubuntu-LI.ttf +0 -0
  27. subtitld/graphics/Ubuntu-M.ttf +0 -0
  28. subtitld/graphics/Ubuntu-MI.ttf +0 -0
  29. subtitld/graphics/Ubuntu-R.ttf +0 -0
  30. subtitld/graphics/Ubuntu-RI.ttf +0 -0
  31. subtitld/graphics/Ubuntu-Th.ttf +0 -0
  32. subtitld/graphics/UbuntuMono-B.ttf +0 -0
  33. subtitld/graphics/UbuntuMono-BI.ttf +0 -0
  34. subtitld/graphics/UbuntuMono-R.ttf +0 -0
  35. subtitld/graphics/UbuntuMono-RI.ttf +0 -0
  36. subtitld/graphics/add_and_play.svg +4 -0
  37. subtitld/graphics/add_from_last.svg +4 -0
  38. subtitld/graphics/add_from_last_checked.svg +4 -0
  39. subtitld/graphics/add_subtitle_icon.svg +4 -0
  40. subtitld/graphics/add_subtitle_to_next_start.svg +4 -0
  41. subtitld/graphics/add_subtitle_to_next_start_checked.svg +4 -0
  42. subtitld/graphics/background_watermark.svg +9 -0
  43. subtitld/graphics/character_count_icon.svg +1 -0
  44. subtitld/graphics/checkbox_checked.svg +8 -0
  45. subtitld/graphics/checkbox_checked_hover.svg +8 -0
  46. subtitld/graphics/checkbox_disabled.svg +4 -0
  47. subtitld/graphics/checkbox_unchecked.svg +5 -0
  48. subtitld/graphics/checkbox_unchecked_hover.svg +5 -0
  49. subtitld/graphics/combobox_down_arrow.svg +4 -0
  50. subtitld/graphics/cps_icon.svg +1 -0
  51. subtitld/graphics/dialog_close_icon.svg +4 -0
  52. subtitld/graphics/dialog_close_icon_hover.svg +4 -0
  53. subtitld/graphics/dubbing_container_collapse_icon.svg +8 -0
  54. subtitld/graphics/dubbing_container_expand_icon.svg +4 -0
  55. subtitld/graphics/file.svg +4 -0
  56. subtitld/graphics/gap_add_icon.svg +4 -0
  57. subtitld/graphics/gap_remove_icon.svg +4 -0
  58. subtitld/graphics/grid_frames_icon.svg +4 -0
  59. subtitld/graphics/grid_frames_icon_checked.svg +4 -0
  60. subtitld/graphics/grid_frames_icon_disabled.svg +4 -0
  61. subtitld/graphics/grid_scenes_icon.svg +4 -0
  62. subtitld/graphics/grid_scenes_icon_checked.svg +4 -0
  63. subtitld/graphics/grid_scenes_icon_disabled.svg +4 -0
  64. subtitld/graphics/grid_seconds_icon.svg +4 -0
  65. subtitld/graphics/grid_seconds_icon_checked.svg +4 -0
  66. subtitld/graphics/grid_seconds_icon_disabled.svg +4 -0
  67. subtitld/graphics/last_end_to_current_position_icon.svg +4 -0
  68. subtitld/graphics/last_start_last_end_to_current_position_button.svg +9 -0
  69. subtitld/graphics/last_start_to_current_position_icon.svg +4 -0
  70. subtitld/graphics/left_panel_autosave.svg +4 -0
  71. subtitld/graphics/left_panel_dubbing.svg +4 -0
  72. subtitld/graphics/left_panel_export.svg +4 -0
  73. subtitld/graphics/left_panel_global.svg +4 -0
  74. subtitld/graphics/left_panel_import.svg +4 -0
  75. subtitld/graphics/left_panel_interface.svg +4 -0
  76. subtitld/graphics/left_panel_keyboard.svg +4 -0
  77. subtitld/graphics/left_panel_metadata.svg +4 -0
  78. subtitld/graphics/left_panel_navigation_background.svg +11 -0
  79. subtitld/graphics/left_panel_qualitycheck.svg +4 -0
  80. subtitld/graphics/left_panel_speakers.svg +4 -0
  81. subtitld/graphics/left_panel_subtitles.svg +1 -0
  82. subtitld/graphics/left_panel_transcription.svg +4 -0
  83. subtitld/graphics/left_panel_translation.svg +4 -0
  84. subtitld/graphics/merge_back_selected_subtitle_icon.svg +4 -0
  85. subtitld/graphics/merge_next_selected_subtitle_icon.svg +4 -0
  86. subtitld/graphics/move_backward_subtitle.svg +4 -0
  87. subtitld/graphics/move_end_back_subtitle.svg +4 -0
  88. subtitld/graphics/move_end_forward_subtitle.svg +4 -0
  89. subtitld/graphics/move_forward_subtitle.svg +4 -0
  90. subtitld/graphics/move_start_back_subtitle.svg +4 -0
  91. subtitld/graphics/move_start_forward_subtitle.svg +4 -0
  92. subtitld/graphics/music_voice_separation_music_icon.svg +4 -0
  93. subtitld/graphics/music_voice_separation_music_icon_disabled.svg +4 -0
  94. subtitld/graphics/music_voice_separation_voice_icon.svg +4 -0
  95. subtitld/graphics/music_voice_separation_voice_icon_disabled.svg +4 -0
  96. subtitld/graphics/new_line_icon.svg +1 -0
  97. subtitld/graphics/next_end_to_current_position_icon.svg +4 -0
  98. subtitld/graphics/next_start_next_end_to_current_position_button.svg +4 -0
  99. subtitld/graphics/next_start_to_current_position_icon.svg +4 -0
  100. subtitld/graphics/pause_icon.svg +12 -0
  101. subtitld/graphics/play_from_last_start_icon.svg +5 -0
  102. subtitld/graphics/play_from_next_start_icon.svg +7 -0
  103. subtitld/graphics/play_icon.svg +12 -0
  104. subtitld/graphics/play_icon_hover.svg +12 -0
  105. subtitld/graphics/play_icon_pressed.svg +12 -0
  106. subtitld/graphics/playback_repeat.svg +4 -0
  107. subtitld/graphics/playback_speed.svg +6 -0
  108. subtitld/graphics/playbackspeed_border.svg +5 -0
  109. subtitld/graphics/playbackspeed_border_checked.svg +5 -0
  110. subtitld/graphics/playbackspeed_border_hover.svg +5 -0
  111. subtitld/graphics/playercontrols_button_first.svg +5 -0
  112. subtitld/graphics/playercontrols_button_first_hover.svg +6 -0
  113. subtitld/graphics/playercontrols_button_first_pressed.svg +6 -0
  114. subtitld/graphics/playercontrols_button_last.svg +6 -0
  115. subtitld/graphics/playercontrols_button_last_hover.svg +6 -0
  116. subtitld/graphics/playercontrols_button_last_pressed.svg +6 -0
  117. subtitld/graphics/playercontrols_widget_center_bottom_line.svg +13 -0
  118. subtitld/graphics/playercontrols_widget_center_top_line.svg +17 -0
  119. subtitld/graphics/plus_icon.svg +4 -0
  120. subtitld/graphics/record_icon.svg +8 -0
  121. subtitld/graphics/record_icon_pressed.svg +5 -0
  122. subtitld/graphics/remove_selected_subtitle_icon.svg +6 -0
  123. subtitld/graphics/repeat_playback_border.svg +5 -0
  124. subtitld/graphics/repeat_playback_border_checked.svg +5 -0
  125. subtitld/graphics/repeat_playback_border_hover.svg +5 -0
  126. subtitld/graphics/save.svg +4 -0
  127. subtitld/graphics/send_text_to_last_subtitle_and_slice_button.svg +8 -0
  128. subtitld/graphics/send_text_to_last_subtitle_icon.svg +8 -0
  129. subtitld/graphics/send_text_to_next_subtitle_and_slice_button.svg +8 -0
  130. subtitld/graphics/send_text_to_next_subtitle_icon.svg +8 -0
  131. subtitld/graphics/slice_selected_subtitle_icon.svg +4 -0
  132. subtitld/graphics/snap_grid_icon.svg +4 -0
  133. subtitld/graphics/snap_grid_icon_checked.svg +4 -0
  134. subtitld/graphics/snap_grid_icon_disabled.svg +4 -0
  135. subtitld/graphics/snap_limits_icon.svg +4 -0
  136. subtitld/graphics/snap_limits_icon_checked.svg +4 -0
  137. subtitld/graphics/snap_limits_icon_disabled.svg +4 -0
  138. subtitld/graphics/snap_move_next.svg +4 -0
  139. subtitld/graphics/snap_move_next_checked.svg +4 -0
  140. subtitld/graphics/snap_move_next_disabled.svg +4 -0
  141. subtitld/graphics/snap_moving_icon.svg +4 -0
  142. subtitld/graphics/snap_moving_icon_checked.svg +4 -0
  143. subtitld/graphics/snap_moving_icon_disabled.svg +4 -0
  144. subtitld/graphics/speaker_list_item_change_color_icon.svg +4 -0
  145. subtitld/graphics/speaker_list_item_change_color_icon_hover.svg +4 -0
  146. subtitld/graphics/speaker_list_item_change_image_icon.svg +4 -0
  147. subtitld/graphics/speaker_list_item_change_image_icon_hover.svg +4 -0
  148. subtitld/graphics/speaker_list_item_export_icon.svg +4 -0
  149. subtitld/graphics/speaker_list_item_export_icon_hover.svg +4 -0
  150. subtitld/graphics/speaker_list_item_remove_icon.svg +4 -0
  151. subtitld/graphics/speaker_list_item_remove_icon_hover.svg +4 -0
  152. subtitld/graphics/speaker_list_item_rename_icon.svg +4 -0
  153. subtitld/graphics/speaker_list_item_rename_icon_hover.svg +4 -0
  154. subtitld/graphics/spinbox_arrow_down.svg +4 -0
  155. subtitld/graphics/spinbox_arrow_up.svg +4 -0
  156. subtitld/graphics/start_screen_top_shadow.svg +23 -0
  157. subtitld/graphics/stop_icon.svg +6 -0
  158. subtitld/graphics/stop_icon_hover.svg +6 -0
  159. subtitld/graphics/stop_icon_pressed.svg +6 -0
  160. subtitld/graphics/stylesheet.qss +1838 -0
  161. subtitld/graphics/subtitld.ico +0 -0
  162. subtitld/graphics/subtitld.png +0 -0
  163. subtitld/graphics/subtitles_panel_simplelist_properties_duration_timing_lock_closed_icon.svg +4 -0
  164. subtitld/graphics/subtitles_panel_simplelist_properties_duration_timing_lock_open_icon.svg +4 -0
  165. subtitld/graphics/subtitles_panel_simplelist_properties_duration_timing_lock_open_icon_hover.svg +4 -0
  166. subtitld/graphics/timeline_bottom_controls_background.svg +25 -0
  167. subtitld/graphics/timeline_cursor_back_frame.svg +4 -0
  168. subtitld/graphics/timeline_cursor_back_frame_hover.svg +4 -0
  169. subtitld/graphics/timeline_cursor_next_frame.svg +4 -0
  170. subtitld/graphics/timeline_cursor_next_frame_hover.svg +4 -0
  171. subtitld/graphics/timeline_show_speaker_color.svg +5 -0
  172. subtitld/graphics/timeline_show_speaker_color_checked.svg +5 -0
  173. subtitld/graphics/timeline_show_speaker_in_tracks.svg +4 -0
  174. subtitld/graphics/timeline_show_speaker_in_tracks_checked.svg +4 -0
  175. subtitld/graphics/timelinescrolling_follow_icon.svg +4 -0
  176. subtitld/graphics/timelinescrolling_follow_icon_checked.svg +4 -0
  177. subtitld/graphics/timelinescrolling_none_icon.svg +6 -0
  178. subtitld/graphics/timelinescrolling_none_icon_checked.svg +6 -0
  179. subtitld/graphics/timelinescrolling_page_icon.svg +4 -0
  180. subtitld/graphics/timelinescrolling_page_icon_checked.svg +4 -0
  181. subtitld/graphics/titleBar_left_container_border.svg +113 -0
  182. subtitld/graphics/unknown_speaker.svg +5 -0
  183. subtitld/graphics/vosk_transcription_download_model_icon.svg +4 -0
  184. subtitld/graphics/vosk_transcription_update_model_icon.svg +4 -0
  185. subtitld/graphics/word_count_icon.svg +1 -0
  186. subtitld/graphics/wpm_icon.svg +1 -0
  187. subtitld/graphics/zoom_in_icon.svg +4 -0
  188. subtitld/graphics/zoom_in_icon_disabled.svg +4 -0
  189. subtitld/graphics/zoom_out_icon.svg +4 -0
  190. subtitld/graphics/zoom_out_icon_disabled.svg +4 -0
  191. subtitld/interface/__init__.py +0 -0
  192. subtitld/interface/actionmanager.py +18 -0
  193. subtitld/interface/bottom_panel.py +43 -0
  194. subtitld/interface/left_panel.py +144 -0
  195. subtitld/interface/left_panel_autosave.py +149 -0
  196. subtitld/interface/left_panel_dubbing.py +469 -0
  197. subtitld/interface/left_panel_export.py +755 -0
  198. subtitld/interface/left_panel_global.py +358 -0
  199. subtitld/interface/left_panel_import.py +82 -0
  200. subtitld/interface/left_panel_interface.py +616 -0
  201. subtitld/interface/left_panel_keyboard.py +261 -0
  202. subtitld/interface/left_panel_metadata.py +74 -0
  203. subtitld/interface/left_panel_qualitycheck.py +262 -0
  204. subtitld/interface/left_panel_speakers.py +737 -0
  205. subtitld/interface/left_panel_subtitleslist.py +791 -0
  206. subtitld/interface/left_panel_transcription.py +763 -0
  207. subtitld/interface/left_panel_translation.py +294 -0
  208. subtitld/interface/playercontrols.py +2348 -0
  209. subtitld/interface/preview_panel.py +407 -0
  210. subtitld/interface/productionscreen.py +65 -0
  211. subtitld/interface/startscreen.py +273 -0
  212. subtitld/interface/timeline.py +1308 -0
  213. subtitld/interface/top_bar.py +329 -0
  214. subtitld/interface/translation.py +68 -0
  215. subtitld/interface/utils.py +286 -0
  216. subtitld/locale/en_US.json +301 -0
  217. subtitld/locale/pt_BR.json +269 -0
  218. subtitld/modules/__init__.py +0 -0
  219. subtitld/modules/audioengine.py +606 -0
  220. subtitld/modules/bounce.py +229 -0
  221. subtitld/modules/config.py +84 -0
  222. subtitld/modules/file_io.py +870 -0
  223. subtitld/modules/history.py +31 -0
  224. subtitld/modules/quality_check.py +53 -0
  225. subtitld/modules/session.py +270 -0
  226. subtitld/modules/shortcuts.py +84 -0
  227. subtitld/modules/subtitles.py +413 -0
  228. subtitld/modules/timecode.py +496 -0
  229. subtitld/modules/usf.py +366 -0
  230. subtitld/modules/utils.py +79 -0
  231. subtitld/modules/waveform.py +53 -0
  232. subtitld-0.0.0.dev0.dist-info/METADATA +97 -0
  233. subtitld-0.0.0.dev0.dist-info/RECORD +237 -0
  234. subtitld-0.0.0.dev0.dist-info/WHEEL +5 -0
  235. subtitld-0.0.0.dev0.dist-info/entry_points.txt +2 -0
  236. subtitld-0.0.0.dev0.dist-info/licenses/LICENSE +674 -0
  237. subtitld-0.0.0.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,606 @@
1
+ import sounddevice as sd
2
+ import soundfile as sf
3
+ import numpy as np
4
+ from pathlib import Path
5
+ from threading import Thread, Lock
6
+ from queue import Queue, Empty
7
+ import threading
8
+ import time
9
+
10
+
11
+ FADE_FRAMES = 64
12
+
13
+
14
+ class AudioSource:
15
+ """Streams audio from disk with background thread"""
16
+ def __init__(self, filepath, samplerate):
17
+ self.filepath = Path(filepath)
18
+ self.samplerate = samplerate
19
+ self._info = sf.info(filepath)
20
+ self.channels = self._info.channels
21
+ self.frames = self._info.frames
22
+
23
+ def read_frames(self, start_frame, num_frames):
24
+ """Read a chunk of audio from disk"""
25
+ with sf.SoundFile(self.filepath, 'r') as f:
26
+ f.seek(int(start_frame))
27
+ data = f.read(int(num_frames), dtype='float32', always_2d=True)
28
+ # Ensure stereo output
29
+ if data.shape[1] == 1:
30
+ data = np.tile(data, (1, 2))
31
+ return data
32
+
33
+
34
+ class BufferPool:
35
+ """Reuse buffers to reduce memory allocations"""
36
+ def __init__(self, max_size=20):
37
+ self.pool = []
38
+ self.max_size = max_size
39
+ self.lock = Lock()
40
+
41
+ def get(self, shape, dtype=np.float32):
42
+ with self.lock:
43
+ for i, buf in enumerate(self.pool):
44
+ if buf.shape == shape and buf.dtype == dtype:
45
+ return self.pool.pop(i)
46
+ return np.zeros(shape, dtype=dtype)
47
+
48
+ def release(self, buf):
49
+ with self.lock:
50
+ if len(self.pool) < self.max_size:
51
+ buf.fill(0.0)
52
+ self.pool.append(buf)
53
+
54
+
55
+ class Clip:
56
+ def __init__(
57
+ self,
58
+ source: AudioSource,
59
+ start_time: float,
60
+ start_offset: float = 0.0,
61
+ duration: float | None = None,
62
+ gain: float = 1.0,
63
+ speed: float = 1.0,
64
+ blocksize: int = 2048,
65
+ ):
66
+ self.source = source
67
+ self.start_time = start_time
68
+ self.start_offset = start_offset
69
+ self.duration = duration
70
+ self.gain = gain
71
+ self.speed = speed
72
+ self.enabled = True
73
+
74
+ # Double buffering for smooth playback
75
+ self._cache_a = {'start': -1, 'data': None}
76
+ self._cache_b = {'start': -1, 'data': None}
77
+ self._active_cache = self._cache_a
78
+ self._cache_size = 131072 # ~2.7 seconds at 48kHz
79
+ self._cache_lock = Lock()
80
+
81
+ # FIX 1: Pre-allocated index buffer to avoid per-callback allocations
82
+ self._src_idx_buf = np.empty(blocksize * 4, dtype=np.float64)
83
+
84
+ # FIX 2: Background prefetch thread
85
+ self._prefetch_queue = Queue(maxsize=1)
86
+ self._prefetch_running = True
87
+ self._prefetch_threshold = 0.75 # start loading when 75% through current cache
88
+ self._prefetch_thread = Thread(target=self._prefetch_worker, daemon=True)
89
+ self._prefetch_thread.start()
90
+
91
+ # ------------------------------------------------------------------
92
+ # FIX 2: Background prefetch — disk I/O never blocks the audio thread
93
+ # ------------------------------------------------------------------
94
+
95
+ def _prefetch_worker(self):
96
+ while self._prefetch_running:
97
+ try:
98
+ read_start, read_frames, target_cache = self._prefetch_queue.get(timeout=1.0)
99
+ if read_frames <= 0:
100
+ continue
101
+ data = self.source.read_frames(read_start, read_frames)
102
+ with self._cache_lock:
103
+ target_cache['data'] = data
104
+ target_cache['start'] = read_start
105
+ self._active_cache = target_cache
106
+ except Empty:
107
+ continue
108
+
109
+ def _get_cache_for_position(self, frame_pos, needed_frames=2048):
110
+ """Return a cache that covers frame_pos, triggering async prefetch when nearing the end."""
111
+ with self._cache_lock:
112
+ frames_needed = int(needed_frames * max(1.0, self.speed)) + 100
113
+
114
+ for cache in (self._cache_a, self._cache_b):
115
+ if cache['data'] is None:
116
+ continue
117
+ cache_end = cache['start'] + len(cache['data'])
118
+ if frame_pos >= cache['start'] and frame_pos + frames_needed <= cache_end:
119
+ # Trigger prefetch when we've consumed more than threshold of this cache
120
+ progress = (frame_pos - cache['start']) / max(1, len(cache['data']))
121
+ if progress > self._prefetch_threshold:
122
+ other = self._cache_b if cache is self._cache_a else self._cache_a
123
+ next_start = int(cache_end)
124
+ # Only enqueue if not already loading that position
125
+ if other.get('start') != next_start and self._prefetch_queue.empty():
126
+ next_frames = min(self._cache_size, self.source.frames - next_start)
127
+ if next_frames > 0:
128
+ try:
129
+ self._prefetch_queue.put_nowait((next_start, next_frames, other))
130
+ except Exception:
131
+ pass
132
+ return cache
133
+
134
+ # Synchronous fallback — only happens on first load or after a seek
135
+ target_cache = self._cache_a if self._active_cache is self._cache_b else self._cache_b
136
+ read_start = max(0, int(frame_pos))
137
+ read_frames = min(self._cache_size, self.source.frames - read_start)
138
+
139
+ if read_frames > 0:
140
+ target_cache['data'] = self.source.read_frames(read_start, read_frames)
141
+ target_cache['start'] = read_start
142
+ self._active_cache = target_cache
143
+
144
+ return target_cache
145
+
146
+ # ------------------------------------------------------------------
147
+ # FIX 3: Boundary fades — eliminates clicks at clip start/end
148
+ # ------------------------------------------------------------------
149
+
150
+ def _apply_boundary_fades(self, out, out_start, num_output_frames, is_clip_start, is_clip_end):
151
+ fade_len = min(FADE_FRAMES, num_output_frames)
152
+
153
+ if is_clip_start:
154
+ ramp = np.linspace(0.0, 1.0, fade_len, dtype=np.float32)
155
+ out[out_start:out_start + fade_len] *= ramp[:, None]
156
+
157
+ if is_clip_end:
158
+ ramp = np.linspace(1.0, 0.0, fade_len, dtype=np.float32)
159
+ end = out_start + num_output_frames
160
+ out[end - fade_len:end] *= ramp[:, None]
161
+
162
+ def read(self, playhead, frames, samplerate, buffer_pool):
163
+ """Read audio with double-buffered streaming and background prefetch."""
164
+ out = buffer_pool.get((frames, 2), dtype=np.float32)
165
+
166
+ if not self.enabled:
167
+ return out
168
+
169
+ t0 = playhead
170
+ t1 = playhead + frames / samplerate
171
+
172
+ clip_end = (
173
+ self.start_time + self.duration
174
+ if self.duration is not None
175
+ else self.start_time + (self.source.frames / self.source.samplerate)
176
+ )
177
+
178
+ if t1 <= self.start_time or t0 >= clip_end:
179
+ return out
180
+
181
+ clip_t0 = max(t0, self.start_time)
182
+ clip_t1 = min(t1, clip_end)
183
+
184
+ # FIX 4: Use round() instead of int() to avoid off-by-one frame errors
185
+ out_start = round((clip_t0 - t0) * samplerate)
186
+ out_end = round((clip_t1 - t0) * samplerate)
187
+ num_output_frames = out_end - out_start
188
+
189
+ if num_output_frames <= 0:
190
+ return out
191
+
192
+ # Detect clip boundaries for fade application
193
+ is_clip_start = clip_t0 <= self.start_time + 1.0 / samplerate
194
+ is_clip_end = clip_t1 >= clip_end - 1.0 / samplerate
195
+
196
+ src_time = ((clip_t0 - self.start_time) + self.start_offset) * self.speed
197
+ src_frame_start = src_time * self.source.samplerate
198
+
199
+ cache = self._get_cache_for_position(int(src_frame_start), num_output_frames)
200
+
201
+ if cache['data'] is None or len(cache['data']) == 0:
202
+ return out
203
+
204
+ cache_offset = src_frame_start - cache['start']
205
+
206
+ # FIX 5: Re-use pre-allocated index buffer — no per-callback allocation
207
+ src_idx = self._src_idx_buf[:num_output_frames]
208
+ np.multiply(np.arange(num_output_frames), self.speed, out=src_idx)
209
+ src_idx += cache_offset
210
+
211
+ i0 = np.floor(src_idx).astype(np.int64)
212
+ i1 = i0 + 1
213
+ frac = src_idx - i0
214
+
215
+ valid = (i0 >= 0) & (i1 < len(cache['data']))
216
+ if not np.any(valid):
217
+ return out
218
+
219
+ i0_valid = i0[valid]
220
+ i1_valid = i1[valid]
221
+ frac_valid = frac[valid].astype(np.float32)
222
+
223
+ s0 = cache['data'][i0_valid]
224
+ s1 = cache['data'][i1_valid]
225
+
226
+ interpolated = (1.0 - frac_valid[:, None]) * s0 + frac_valid[:, None] * s1
227
+ out[out_start:out_start + np.count_nonzero(valid)] = interpolated * self.gain
228
+
229
+ # FIX 3: Apply fades at clip boundaries
230
+ self._apply_boundary_fades(out, out_start, num_output_frames, is_clip_start, is_clip_end)
231
+
232
+ return out
233
+
234
+ def clear_cache(self):
235
+ """Clear caches on seek."""
236
+ with self._cache_lock:
237
+ self._cache_a = {'start': -1, 'data': None}
238
+ self._cache_b = {'start': -1, 'data': None}
239
+
240
+ def shutdown(self):
241
+ """Stop the prefetch thread cleanly."""
242
+ self._prefetch_running = False
243
+ self._prefetch_thread.join(timeout=2.0)
244
+
245
+
246
+ class SubtitleDubClip:
247
+ """Wrapper clip that always plays subtitle['dubbing'][0] for a given subtitle dict.
248
+
249
+ Reads `path` and `start` live each callback so timeline drags / playlist swaps
250
+ take effect immediately without engine re-sync. Loaded WAV samples are kept
251
+ in a small per-instance dict keyed by file path."""
252
+
253
+ def __init__(self, subtitle, samplerate):
254
+ self.subtitle = subtitle
255
+ self.samplerate = samplerate
256
+ self.gain = 1.0
257
+ self.speed = 1.0
258
+ self.enabled = True
259
+ self._loaded = {} # path -> (data ndarray (N,2), src_samplerate, frame_count)
260
+
261
+ def _current_dub(self):
262
+ dubs = self.subtitle.get('dubbing')
263
+ return dubs[0] if dubs else None
264
+
265
+ def preload(self, path):
266
+ if path in self._loaded:
267
+ return
268
+ try:
269
+ with sf.SoundFile(path, 'r') as f:
270
+ data = f.read(dtype='float32', always_2d=True)
271
+ if data.shape[1] == 1:
272
+ data = np.tile(data, (1, 2))
273
+ self._loaded[path] = (data, f.samplerate, len(data))
274
+ except Exception:
275
+ pass
276
+
277
+ def read(self, playhead, frames, samplerate, buffer_pool):
278
+ out = buffer_pool.get((frames, 2), dtype=np.float32)
279
+
280
+ if not self.enabled:
281
+ return out
282
+
283
+ dub = self._current_dub()
284
+ if not dub:
285
+ return out
286
+
287
+ path = dub.get('path')
288
+ if not path:
289
+ return out
290
+
291
+ if path not in self._loaded:
292
+ self.preload(path)
293
+ loaded = self._loaded.get(path)
294
+ if loaded is None:
295
+ return out
296
+ data, src_sr, total_frames = loaded
297
+
298
+ start_time = float(dub.get('start', 0.0))
299
+ clip_end = start_time + total_frames / src_sr
300
+
301
+ t0 = playhead
302
+ t1 = playhead + frames / samplerate
303
+
304
+ if t1 <= start_time or t0 >= clip_end:
305
+ return out
306
+
307
+ clip_t0 = max(t0, start_time)
308
+ clip_t1 = min(t1, clip_end)
309
+
310
+ out_start = round((clip_t0 - t0) * samplerate)
311
+ out_end = round((clip_t1 - t0) * samplerate)
312
+ num_output_frames = out_end - out_start
313
+
314
+ if num_output_frames <= 0:
315
+ return out
316
+
317
+ is_clip_start = clip_t0 <= start_time + 1.0 / samplerate
318
+ is_clip_end = clip_t1 >= clip_end - 1.0 / samplerate
319
+
320
+ ratio = (src_sr / samplerate) * self.speed
321
+ src_frame_start = (clip_t0 - start_time) * src_sr
322
+ src_idx = np.arange(num_output_frames, dtype=np.float64) * ratio + src_frame_start
323
+
324
+ i0 = np.floor(src_idx).astype(np.int64)
325
+ i1 = i0 + 1
326
+ frac = (src_idx - i0).astype(np.float32)
327
+
328
+ valid = (i0 >= 0) & (i1 < total_frames)
329
+ valid_count = int(np.count_nonzero(valid))
330
+ if valid_count == 0:
331
+ return out
332
+
333
+ i0v = i0[valid]
334
+ i1v = i1[valid]
335
+ fracv = frac[valid]
336
+ s0 = data[i0v]
337
+ s1 = data[i1v]
338
+ interp = (1.0 - fracv[:, None]) * s0 + fracv[:, None] * s1
339
+ out[out_start:out_start + valid_count] = interp * self.gain
340
+
341
+ fade_len = min(FADE_FRAMES, num_output_frames)
342
+ if is_clip_start:
343
+ ramp = np.linspace(0.0, 1.0, fade_len, dtype=np.float32)
344
+ out[out_start:out_start + fade_len] *= ramp[:, None]
345
+ if is_clip_end:
346
+ ramp = np.linspace(1.0, 0.0, fade_len, dtype=np.float32)
347
+ end = out_start + num_output_frames
348
+ out[end - fade_len:end] *= ramp[:, None]
349
+
350
+ return out
351
+
352
+ def clear_cache(self):
353
+ pass
354
+
355
+ def shutdown(self):
356
+ self._loaded.clear()
357
+
358
+
359
+ class Track:
360
+ def __init__(self):
361
+ self.clips = []
362
+ self.gain = 1.0
363
+ self.enabled = True
364
+
365
+ def add_clip(self, clip):
366
+ self.clips.append(clip)
367
+
368
+ def read(self, playhead, frames, samplerate, buffer_pool):
369
+ out = buffer_pool.get((frames, 2), dtype=np.float32)
370
+
371
+ if not self.enabled:
372
+ return out
373
+
374
+ for clip in self.clips:
375
+ clip_data = clip.read(playhead, frames, samplerate, buffer_pool)
376
+ out += clip_data
377
+ buffer_pool.release(clip_data)
378
+
379
+ out *= self.gain
380
+ return out
381
+
382
+
383
+ class SoundDeviceAudioEngine:
384
+ def __init__(self, samplerate=48000, blocksize=2048):
385
+ self.samplerate = samplerate
386
+ self.blocksize = blocksize
387
+ self.tracks = []
388
+ self.playing = False
389
+ self.speed = 1.0
390
+
391
+ # FIX 6: Thread-safe playhead access
392
+ self._playhead_lock = threading.Lock()
393
+ self._playhead = 0.0
394
+
395
+ self.buffer_pool = BufferPool(max_size=30)
396
+
397
+ # Per-speaker dub tracks. speaker_tracks[name] is the Track object.
398
+ # subtitle_clips[id(subtitle)] is the SubtitleDubClip wrapping it.
399
+ self.speaker_tracks = {}
400
+ self.subtitle_clips = {}
401
+
402
+ self.stream = sd.OutputStream(
403
+ samplerate=self.samplerate,
404
+ blocksize=self.blocksize,
405
+ channels=2,
406
+ dtype='float32',
407
+ callback=self._callback
408
+ )
409
+
410
+ # FIX 6: Property-based thread-safe playhead
411
+ @property
412
+ def playhead(self):
413
+ with self._playhead_lock:
414
+ return self._playhead
415
+
416
+ @playhead.setter
417
+ def playhead(self, value):
418
+ with self._playhead_lock:
419
+ self._playhead = value
420
+
421
+ def load_audio(self, filepath):
422
+ """Load audio source (streams from disk, not RAM)"""
423
+ info = sf.info(filepath)
424
+ if info.samplerate != self.samplerate:
425
+ raise ValueError(f"Sample rate mismatch: file has {info.samplerate}, expected {self.samplerate}")
426
+ return AudioSource(filepath, self.samplerate)
427
+
428
+ def load_clip(self, source, start_time=0.0, start_offset=0.0, duration=None, gain=1.0, speed=1.0):
429
+ return Clip(source, start_time, start_offset, duration, gain, speed, blocksize=self.blocksize)
430
+
431
+ def add_track(self, track):
432
+ self.tracks.append(track)
433
+ return track
434
+
435
+ def generate_track(self):
436
+ return Track()
437
+
438
+ def _callback(self, outdata, frames, time, status):
439
+ if status:
440
+ pass
441
+
442
+ outdata.fill(0.0)
443
+
444
+ # Read playhead once per callback to stay consistent within the block
445
+ with self._playhead_lock:
446
+ current_playhead = self._playhead
447
+
448
+ for track in self.tracks:
449
+ track_data = track.read(current_playhead, frames, self.samplerate, self.buffer_pool)
450
+ outdata += track_data
451
+ self.buffer_pool.release(track_data)
452
+
453
+ np.clip(outdata, -1.0, 1.0, out=outdata)
454
+
455
+ with self._playhead_lock:
456
+ self._playhead += (frames / self.samplerate) * self.speed
457
+
458
+ def play(self, position=0.0):
459
+ if not self.playing:
460
+ self.seek(position)
461
+ self.stream.start()
462
+ self.playing = True
463
+
464
+ def pause(self):
465
+ if self.playing:
466
+ self.stream.stop()
467
+ self.playing = False
468
+
469
+ def stop(self):
470
+ self.pause()
471
+ self.seek(0.0)
472
+
473
+ def seek(self, seconds):
474
+ self.playhead = seconds
475
+ for track in self.tracks:
476
+ for clip in track.clips:
477
+ clip.clear_cache()
478
+
479
+ def set_gain(self, track, gain):
480
+ track.gain = gain
481
+
482
+ def set_speed(self, speed):
483
+ self.speed = max(0.0, speed)
484
+
485
+ def _ensure_speaker_track(self, speaker):
486
+ track = self.speaker_tracks.get(speaker)
487
+ if track is None:
488
+ track = Track()
489
+ self.speaker_tracks[speaker] = track
490
+ self.tracks.append(track)
491
+ return track
492
+
493
+ def sync_subtitle_dubs(self, segments, default_speaker='A'):
494
+ """Reconcile per-speaker dub tracks with the current subtitle list.
495
+
496
+ Idempotent: call after loading a project, generating a dub, swapping
497
+ the playlist, or reassigning a subtitle's speaker. Subtitles without
498
+ a `dubbing` list are dropped from the engine."""
499
+ seen = set()
500
+
501
+ for sub in segments:
502
+ if not sub.get('dubbing'):
503
+ continue
504
+ speaker = sub.get('speaker', default_speaker)
505
+ track = self._ensure_speaker_track(speaker)
506
+ sub_id = id(sub)
507
+ seen.add(sub_id)
508
+
509
+ clip = self.subtitle_clips.get(sub_id)
510
+ if clip is None:
511
+ clip = SubtitleDubClip(sub, self.samplerate)
512
+ self.subtitle_clips[sub_id] = clip
513
+ track.add_clip(clip)
514
+ else:
515
+ # Speaker may have changed — move the clip to the right track.
516
+ for other in self.speaker_tracks.values():
517
+ if clip in other.clips and other is not track:
518
+ other.clips.remove(clip)
519
+ track.add_clip(clip)
520
+ break
521
+
522
+ dub = sub['dubbing'][0]
523
+ path = dub.get('path')
524
+ if path:
525
+ clip.preload(path)
526
+
527
+ for sub_id in list(self.subtitle_clips):
528
+ if sub_id in seen:
529
+ continue
530
+ clip = self.subtitle_clips.pop(sub_id)
531
+ for track in self.speaker_tracks.values():
532
+ if clip in track.clips:
533
+ track.clips.remove(clip)
534
+ break
535
+ clip.shutdown()
536
+
537
+ def shutdown(self):
538
+ """Cleanly shut down the engine and all clip prefetch threads."""
539
+ self.stop()
540
+ self.stream.close()
541
+ for track in self.tracks:
542
+ for clip in track.clips:
543
+ clip.shutdown()
544
+
545
+ def render_buffer(self, start=0.0, end=None, samplerate=None, tracks=None, blocksize=4096):
546
+ """Render the mix offline into a numpy (frames, 2) array.
547
+
548
+ start / end in seconds. If end is None, uses the video duration or the
549
+ latest clip endpoint. If `tracks` is provided, only those Track objects
550
+ are mixed (for rendering stems); otherwise all tracks mix together."""
551
+ sr = int(samplerate or self.samplerate)
552
+ mix_tracks = list(tracks) if tracks is not None else list(self.tracks)
553
+
554
+ if end is None:
555
+ latest = 0.0
556
+ for track in mix_tracks:
557
+ for clip in track.clips:
558
+ if isinstance(clip, SubtitleDubClip):
559
+ dub = clip._current_dub()
560
+ if dub:
561
+ try:
562
+ path = dub.get('path')
563
+ if path and path in clip._loaded:
564
+ _, src_sr, total = clip._loaded[path]
565
+ latest = max(latest, float(dub.get('start', 0.0)) + total / src_sr)
566
+ except Exception:
567
+ pass
568
+ end = max(end or 0.0, latest)
569
+
570
+ if end <= start:
571
+ return np.zeros((0, 2), dtype=np.float32)
572
+
573
+ total_frames = int(round((end - start) * sr))
574
+ out = np.zeros((total_frames, 2), dtype=np.float32)
575
+
576
+ written = 0
577
+ playhead = float(start)
578
+ while written < total_frames:
579
+ frames = min(blocksize, total_frames - written)
580
+ block = np.zeros((frames, 2), dtype=np.float32)
581
+ for track in mix_tracks:
582
+ td = track.read(playhead, frames, sr, self.buffer_pool)
583
+ block += td
584
+ self.buffer_pool.release(td)
585
+ out[written:written + frames] = block
586
+ written += frames
587
+ playhead = start + written / sr
588
+
589
+ np.clip(out, -1.0, 1.0, out=out)
590
+ return out
591
+
592
+ def get_memory_usage(self):
593
+ """Estimate current memory usage in MB"""
594
+ total_bytes = 0
595
+
596
+ for buf in self.buffer_pool.pool:
597
+ total_bytes += buf.nbytes
598
+
599
+ for track in self.tracks:
600
+ for clip in track.clips:
601
+ if clip._cache_a['data'] is not None:
602
+ total_bytes += clip._cache_a['data'].nbytes
603
+ if clip._cache_b['data'] is not None:
604
+ total_bytes += clip._cache_b['data'].nbytes
605
+
606
+ return total_bytes / (1024 * 1024)