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.
- subtitld/__init__.py +19 -0
- subtitld/__main__.py +240 -0
- subtitld/ftfy/char_classes.dat +0 -0
- subtitld/graphics/Montserrat-Black.ttf +0 -0
- subtitld/graphics/Montserrat-BlackItalic.ttf +0 -0
- subtitld/graphics/Montserrat-Bold.ttf +0 -0
- subtitld/graphics/Montserrat-BoldItalic.ttf +0 -0
- subtitld/graphics/Montserrat-ExtraBold.ttf +0 -0
- subtitld/graphics/Montserrat-ExtraBoldItalic.ttf +0 -0
- subtitld/graphics/Montserrat-ExtraLight.ttf +0 -0
- subtitld/graphics/Montserrat-ExtraLightItalic.ttf +0 -0
- subtitld/graphics/Montserrat-Italic.ttf +0 -0
- subtitld/graphics/Montserrat-Light.ttf +0 -0
- subtitld/graphics/Montserrat-LightItalic.ttf +0 -0
- subtitld/graphics/Montserrat-Medium.ttf +0 -0
- subtitld/graphics/Montserrat-MediumItalic.ttf +0 -0
- subtitld/graphics/Montserrat-Regular.ttf +0 -0
- subtitld/graphics/Montserrat-SemiBold.ttf +0 -0
- subtitld/graphics/Montserrat-SemiBoldItalic.ttf +0 -0
- subtitld/graphics/Montserrat-Thin.ttf +0 -0
- subtitld/graphics/Montserrat-ThinItalic.ttf +0 -0
- subtitld/graphics/Ubuntu-B.ttf +0 -0
- subtitld/graphics/Ubuntu-BI.ttf +0 -0
- subtitld/graphics/Ubuntu-C.ttf +0 -0
- subtitld/graphics/Ubuntu-L.ttf +0 -0
- subtitld/graphics/Ubuntu-LI.ttf +0 -0
- subtitld/graphics/Ubuntu-M.ttf +0 -0
- subtitld/graphics/Ubuntu-MI.ttf +0 -0
- subtitld/graphics/Ubuntu-R.ttf +0 -0
- subtitld/graphics/Ubuntu-RI.ttf +0 -0
- subtitld/graphics/Ubuntu-Th.ttf +0 -0
- subtitld/graphics/UbuntuMono-B.ttf +0 -0
- subtitld/graphics/UbuntuMono-BI.ttf +0 -0
- subtitld/graphics/UbuntuMono-R.ttf +0 -0
- subtitld/graphics/UbuntuMono-RI.ttf +0 -0
- subtitld/graphics/add_and_play.svg +4 -0
- subtitld/graphics/add_from_last.svg +4 -0
- subtitld/graphics/add_from_last_checked.svg +4 -0
- subtitld/graphics/add_subtitle_icon.svg +4 -0
- subtitld/graphics/add_subtitle_to_next_start.svg +4 -0
- subtitld/graphics/add_subtitle_to_next_start_checked.svg +4 -0
- subtitld/graphics/background_watermark.svg +9 -0
- subtitld/graphics/character_count_icon.svg +1 -0
- subtitld/graphics/checkbox_checked.svg +8 -0
- subtitld/graphics/checkbox_checked_hover.svg +8 -0
- subtitld/graphics/checkbox_disabled.svg +4 -0
- subtitld/graphics/checkbox_unchecked.svg +5 -0
- subtitld/graphics/checkbox_unchecked_hover.svg +5 -0
- subtitld/graphics/combobox_down_arrow.svg +4 -0
- subtitld/graphics/cps_icon.svg +1 -0
- subtitld/graphics/dialog_close_icon.svg +4 -0
- subtitld/graphics/dialog_close_icon_hover.svg +4 -0
- subtitld/graphics/dubbing_container_collapse_icon.svg +8 -0
- subtitld/graphics/dubbing_container_expand_icon.svg +4 -0
- subtitld/graphics/file.svg +4 -0
- subtitld/graphics/gap_add_icon.svg +4 -0
- subtitld/graphics/gap_remove_icon.svg +4 -0
- subtitld/graphics/grid_frames_icon.svg +4 -0
- subtitld/graphics/grid_frames_icon_checked.svg +4 -0
- subtitld/graphics/grid_frames_icon_disabled.svg +4 -0
- subtitld/graphics/grid_scenes_icon.svg +4 -0
- subtitld/graphics/grid_scenes_icon_checked.svg +4 -0
- subtitld/graphics/grid_scenes_icon_disabled.svg +4 -0
- subtitld/graphics/grid_seconds_icon.svg +4 -0
- subtitld/graphics/grid_seconds_icon_checked.svg +4 -0
- subtitld/graphics/grid_seconds_icon_disabled.svg +4 -0
- subtitld/graphics/last_end_to_current_position_icon.svg +4 -0
- subtitld/graphics/last_start_last_end_to_current_position_button.svg +9 -0
- subtitld/graphics/last_start_to_current_position_icon.svg +4 -0
- subtitld/graphics/left_panel_autosave.svg +4 -0
- subtitld/graphics/left_panel_dubbing.svg +4 -0
- subtitld/graphics/left_panel_export.svg +4 -0
- subtitld/graphics/left_panel_global.svg +4 -0
- subtitld/graphics/left_panel_import.svg +4 -0
- subtitld/graphics/left_panel_interface.svg +4 -0
- subtitld/graphics/left_panel_keyboard.svg +4 -0
- subtitld/graphics/left_panel_metadata.svg +4 -0
- subtitld/graphics/left_panel_navigation_background.svg +11 -0
- subtitld/graphics/left_panel_qualitycheck.svg +4 -0
- subtitld/graphics/left_panel_speakers.svg +4 -0
- subtitld/graphics/left_panel_subtitles.svg +1 -0
- subtitld/graphics/left_panel_transcription.svg +4 -0
- subtitld/graphics/left_panel_translation.svg +4 -0
- subtitld/graphics/merge_back_selected_subtitle_icon.svg +4 -0
- subtitld/graphics/merge_next_selected_subtitle_icon.svg +4 -0
- subtitld/graphics/move_backward_subtitle.svg +4 -0
- subtitld/graphics/move_end_back_subtitle.svg +4 -0
- subtitld/graphics/move_end_forward_subtitle.svg +4 -0
- subtitld/graphics/move_forward_subtitle.svg +4 -0
- subtitld/graphics/move_start_back_subtitle.svg +4 -0
- subtitld/graphics/move_start_forward_subtitle.svg +4 -0
- subtitld/graphics/music_voice_separation_music_icon.svg +4 -0
- subtitld/graphics/music_voice_separation_music_icon_disabled.svg +4 -0
- subtitld/graphics/music_voice_separation_voice_icon.svg +4 -0
- subtitld/graphics/music_voice_separation_voice_icon_disabled.svg +4 -0
- subtitld/graphics/new_line_icon.svg +1 -0
- subtitld/graphics/next_end_to_current_position_icon.svg +4 -0
- subtitld/graphics/next_start_next_end_to_current_position_button.svg +4 -0
- subtitld/graphics/next_start_to_current_position_icon.svg +4 -0
- subtitld/graphics/pause_icon.svg +12 -0
- subtitld/graphics/play_from_last_start_icon.svg +5 -0
- subtitld/graphics/play_from_next_start_icon.svg +7 -0
- subtitld/graphics/play_icon.svg +12 -0
- subtitld/graphics/play_icon_hover.svg +12 -0
- subtitld/graphics/play_icon_pressed.svg +12 -0
- subtitld/graphics/playback_repeat.svg +4 -0
- subtitld/graphics/playback_speed.svg +6 -0
- subtitld/graphics/playbackspeed_border.svg +5 -0
- subtitld/graphics/playbackspeed_border_checked.svg +5 -0
- subtitld/graphics/playbackspeed_border_hover.svg +5 -0
- subtitld/graphics/playercontrols_button_first.svg +5 -0
- subtitld/graphics/playercontrols_button_first_hover.svg +6 -0
- subtitld/graphics/playercontrols_button_first_pressed.svg +6 -0
- subtitld/graphics/playercontrols_button_last.svg +6 -0
- subtitld/graphics/playercontrols_button_last_hover.svg +6 -0
- subtitld/graphics/playercontrols_button_last_pressed.svg +6 -0
- subtitld/graphics/playercontrols_widget_center_bottom_line.svg +13 -0
- subtitld/graphics/playercontrols_widget_center_top_line.svg +17 -0
- subtitld/graphics/plus_icon.svg +4 -0
- subtitld/graphics/record_icon.svg +8 -0
- subtitld/graphics/record_icon_pressed.svg +5 -0
- subtitld/graphics/remove_selected_subtitle_icon.svg +6 -0
- subtitld/graphics/repeat_playback_border.svg +5 -0
- subtitld/graphics/repeat_playback_border_checked.svg +5 -0
- subtitld/graphics/repeat_playback_border_hover.svg +5 -0
- subtitld/graphics/save.svg +4 -0
- subtitld/graphics/send_text_to_last_subtitle_and_slice_button.svg +8 -0
- subtitld/graphics/send_text_to_last_subtitle_icon.svg +8 -0
- subtitld/graphics/send_text_to_next_subtitle_and_slice_button.svg +8 -0
- subtitld/graphics/send_text_to_next_subtitle_icon.svg +8 -0
- subtitld/graphics/slice_selected_subtitle_icon.svg +4 -0
- subtitld/graphics/snap_grid_icon.svg +4 -0
- subtitld/graphics/snap_grid_icon_checked.svg +4 -0
- subtitld/graphics/snap_grid_icon_disabled.svg +4 -0
- subtitld/graphics/snap_limits_icon.svg +4 -0
- subtitld/graphics/snap_limits_icon_checked.svg +4 -0
- subtitld/graphics/snap_limits_icon_disabled.svg +4 -0
- subtitld/graphics/snap_move_next.svg +4 -0
- subtitld/graphics/snap_move_next_checked.svg +4 -0
- subtitld/graphics/snap_move_next_disabled.svg +4 -0
- subtitld/graphics/snap_moving_icon.svg +4 -0
- subtitld/graphics/snap_moving_icon_checked.svg +4 -0
- subtitld/graphics/snap_moving_icon_disabled.svg +4 -0
- subtitld/graphics/speaker_list_item_change_color_icon.svg +4 -0
- subtitld/graphics/speaker_list_item_change_color_icon_hover.svg +4 -0
- subtitld/graphics/speaker_list_item_change_image_icon.svg +4 -0
- subtitld/graphics/speaker_list_item_change_image_icon_hover.svg +4 -0
- subtitld/graphics/speaker_list_item_export_icon.svg +4 -0
- subtitld/graphics/speaker_list_item_export_icon_hover.svg +4 -0
- subtitld/graphics/speaker_list_item_remove_icon.svg +4 -0
- subtitld/graphics/speaker_list_item_remove_icon_hover.svg +4 -0
- subtitld/graphics/speaker_list_item_rename_icon.svg +4 -0
- subtitld/graphics/speaker_list_item_rename_icon_hover.svg +4 -0
- subtitld/graphics/spinbox_arrow_down.svg +4 -0
- subtitld/graphics/spinbox_arrow_up.svg +4 -0
- subtitld/graphics/start_screen_top_shadow.svg +23 -0
- subtitld/graphics/stop_icon.svg +6 -0
- subtitld/graphics/stop_icon_hover.svg +6 -0
- subtitld/graphics/stop_icon_pressed.svg +6 -0
- subtitld/graphics/stylesheet.qss +1838 -0
- subtitld/graphics/subtitld.ico +0 -0
- subtitld/graphics/subtitld.png +0 -0
- subtitld/graphics/subtitles_panel_simplelist_properties_duration_timing_lock_closed_icon.svg +4 -0
- subtitld/graphics/subtitles_panel_simplelist_properties_duration_timing_lock_open_icon.svg +4 -0
- subtitld/graphics/subtitles_panel_simplelist_properties_duration_timing_lock_open_icon_hover.svg +4 -0
- subtitld/graphics/timeline_bottom_controls_background.svg +25 -0
- subtitld/graphics/timeline_cursor_back_frame.svg +4 -0
- subtitld/graphics/timeline_cursor_back_frame_hover.svg +4 -0
- subtitld/graphics/timeline_cursor_next_frame.svg +4 -0
- subtitld/graphics/timeline_cursor_next_frame_hover.svg +4 -0
- subtitld/graphics/timeline_show_speaker_color.svg +5 -0
- subtitld/graphics/timeline_show_speaker_color_checked.svg +5 -0
- subtitld/graphics/timeline_show_speaker_in_tracks.svg +4 -0
- subtitld/graphics/timeline_show_speaker_in_tracks_checked.svg +4 -0
- subtitld/graphics/timelinescrolling_follow_icon.svg +4 -0
- subtitld/graphics/timelinescrolling_follow_icon_checked.svg +4 -0
- subtitld/graphics/timelinescrolling_none_icon.svg +6 -0
- subtitld/graphics/timelinescrolling_none_icon_checked.svg +6 -0
- subtitld/graphics/timelinescrolling_page_icon.svg +4 -0
- subtitld/graphics/timelinescrolling_page_icon_checked.svg +4 -0
- subtitld/graphics/titleBar_left_container_border.svg +113 -0
- subtitld/graphics/unknown_speaker.svg +5 -0
- subtitld/graphics/vosk_transcription_download_model_icon.svg +4 -0
- subtitld/graphics/vosk_transcription_update_model_icon.svg +4 -0
- subtitld/graphics/word_count_icon.svg +1 -0
- subtitld/graphics/wpm_icon.svg +1 -0
- subtitld/graphics/zoom_in_icon.svg +4 -0
- subtitld/graphics/zoom_in_icon_disabled.svg +4 -0
- subtitld/graphics/zoom_out_icon.svg +4 -0
- subtitld/graphics/zoom_out_icon_disabled.svg +4 -0
- subtitld/interface/__init__.py +0 -0
- subtitld/interface/actionmanager.py +18 -0
- subtitld/interface/bottom_panel.py +43 -0
- subtitld/interface/left_panel.py +144 -0
- subtitld/interface/left_panel_autosave.py +149 -0
- subtitld/interface/left_panel_dubbing.py +469 -0
- subtitld/interface/left_panel_export.py +755 -0
- subtitld/interface/left_panel_global.py +358 -0
- subtitld/interface/left_panel_import.py +82 -0
- subtitld/interface/left_panel_interface.py +616 -0
- subtitld/interface/left_panel_keyboard.py +261 -0
- subtitld/interface/left_panel_metadata.py +74 -0
- subtitld/interface/left_panel_qualitycheck.py +262 -0
- subtitld/interface/left_panel_speakers.py +737 -0
- subtitld/interface/left_panel_subtitleslist.py +791 -0
- subtitld/interface/left_panel_transcription.py +763 -0
- subtitld/interface/left_panel_translation.py +294 -0
- subtitld/interface/playercontrols.py +2348 -0
- subtitld/interface/preview_panel.py +407 -0
- subtitld/interface/productionscreen.py +65 -0
- subtitld/interface/startscreen.py +273 -0
- subtitld/interface/timeline.py +1308 -0
- subtitld/interface/top_bar.py +329 -0
- subtitld/interface/translation.py +68 -0
- subtitld/interface/utils.py +286 -0
- subtitld/locale/en_US.json +301 -0
- subtitld/locale/pt_BR.json +269 -0
- subtitld/modules/__init__.py +0 -0
- subtitld/modules/audioengine.py +606 -0
- subtitld/modules/bounce.py +229 -0
- subtitld/modules/config.py +84 -0
- subtitld/modules/file_io.py +870 -0
- subtitld/modules/history.py +31 -0
- subtitld/modules/quality_check.py +53 -0
- subtitld/modules/session.py +270 -0
- subtitld/modules/shortcuts.py +84 -0
- subtitld/modules/subtitles.py +413 -0
- subtitld/modules/timecode.py +496 -0
- subtitld/modules/usf.py +366 -0
- subtitld/modules/utils.py +79 -0
- subtitld/modules/waveform.py +53 -0
- subtitld-0.0.0.dev0.dist-info/METADATA +97 -0
- subtitld-0.0.0.dev0.dist-info/RECORD +237 -0
- subtitld-0.0.0.dev0.dist-info/WHEEL +5 -0
- subtitld-0.0.0.dev0.dist-info/entry_points.txt +2 -0
- subtitld-0.0.0.dev0.dist-info/licenses/LICENSE +674 -0
- 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)
|