From 8f7b90b885c697672933f4969d3e5169299efe4e Mon Sep 17 00:00:00 2001 From: Kabeer2004 Date: Mon, 17 Feb 2025 02:44:02 +0530 Subject: [PATCH 1/4] gitignored venv --- .gitignore | 1 + src/__pycache__/_LocalTranscribe.cpython-311.pyc | Bin 0 -> 5174 bytes 2 files changed, 1 insertion(+) create mode 100644 .gitignore create mode 100644 src/__pycache__/_LocalTranscribe.cpython-311.pyc diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f5e96db --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +venv \ No newline at end of file diff --git a/src/__pycache__/_LocalTranscribe.cpython-311.pyc b/src/__pycache__/_LocalTranscribe.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5e33f6b4dc0f2763d96333b9dab445a88d4789ac GIT binary patch literal 5174 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 literal 0 HcmV?d00001 From 652c1bd8a1be0ac15ea6418f19d5a1b9d7c86c03 Mon Sep 17 00:00:00 2001 From: Kabeer2004 Date: Mon, 17 Feb 2025 03:51:11 +0530 Subject: [PATCH 2/4] added SRT file generation, max segment length functionality very helpful to generate subtitle files! --- app.py | 35 ++-- src/_LocalTranscribe.py | 174 +++++++++++------- .../_LocalTranscribe.cpython-311.pyc | Bin 5174 -> 7937 bytes 3 files changed, 131 insertions(+), 78 deletions(-) diff --git a/app.py b/app.py index 20db595..255c7b0 100644 --- a/app.py +++ b/app.py @@ -20,24 +20,25 @@ class App: def __init__(self, master): print(Back.CYAN + "Welcome to Local Transcribe with Whisper!\U0001f600\nCheck back here to see some output from your transcriptions.\nDon't worry, they will also be saved on the computer!\U0001f64f") self.master = master - # Change font - font = ('Roboto', 13, 'bold') # Change the font and size here - font_b = ('Roboto', 12) # Change the font and size here - # Folder Path + font = ('Roboto', 13, 'bold') + font_b = ('Roboto', 12) path_frame = customtkinter.CTkFrame(master) path_frame.pack(fill=tk.BOTH, padx=10, pady=10) customtkinter.CTkLabel(path_frame, text="Folder:", font=font).pack(side=tk.LEFT, padx=5) self.path_entry = customtkinter.CTkEntry(path_frame, width=50, font=font_b) self.path_entry.pack(side=tk.LEFT, fill=tk.X, expand=True) customtkinter.CTkButton(path_frame, text="Browse", command=self.browse, font=font).pack(side=tk.LEFT, padx=5) - # Language frame - #thanks to pommicket from Stackoverflow for this fix + max_duration_frame = customtkinter.CTkFrame(master) + max_duration_frame.pack(fill=tk.BOTH, padx=10, pady=10) + customtkinter.CTkLabel(max_duration_frame, text="Max Segment (sec):", font=font).pack(side=tk.LEFT, padx=5) + self.max_duration_entry = customtkinter.CTkEntry(max_duration_frame, width=50, placeholder_text="0 = no limit", font=font_b) + self.max_duration_entry.pack(side=tk.LEFT, fill=tk.X, expand=True) def on_entry_click(event): """function that gets called whenever entry is clicked""" global firstclick - if firstclick: # if this is the first time they clicked it + if firstclick: firstclick = False - self.language_entry.delete(0, "end") # delete all the text in the entry + self.language_entry.delete(0, "end") language_frame = customtkinter.CTkFrame(master) language_frame.pack(fill=tk.BOTH, padx=10, pady=10) customtkinter.CTkLabel(language_frame, text="Language:", font=font).pack(side=tk.LEFT, padx=5) @@ -56,13 +57,18 @@ def on_entry_click(event): self.model_combobox = customtkinter.CTkComboBox( model_frame, width=50, state="readonly", values=models, font=font_b) - self.model_combobox.set(models[1]) # Set the default value + self.model_combobox.set(models[1]) self.model_combobox.pack(side=tk.LEFT, fill=tk.X, expand=True) # Verbose frame verbose_frame = customtkinter.CTkFrame(master) verbose_frame.pack(fill=tk.BOTH, padx=10, pady=10) self.verbose_var = tk.BooleanVar() customtkinter.CTkCheckBox(verbose_frame, text="Output transcription to terminal", variable=self.verbose_var, font=font).pack(side=tk.LEFT, padx=5) + #Output Frame + output_frame = customtkinter.CTkFrame(master) + output_frame.pack(fill=tk.BOTH, padx=10, pady=10) + self.srt_var = tk.BooleanVar() + customtkinter.CTkCheckBox(output_frame, text="Export as SRT", variable=self.srt_var, font=font).pack(side=tk.LEFT, padx=5) # Progress Bar self.progress_bar = ttk.Progressbar(master, length=200, mode='indeterminate') # Button actions frame @@ -89,6 +95,7 @@ def transcribe_thread(self): path = self.path_entry.get() model = self.model_combobox.get() language = self.language_entry.get() + srt_format = self.srt_var.get() # Check if the language field has the default text or is empty if language == self.default_language_text or not language.strip(): language = None # This is the same as passing nothing @@ -98,10 +105,16 @@ def transcribe_thread(self): self.progress_bar.start() # Setting path and files glob_file = get_path(path) - #messagebox.showinfo("Message", "Starting transcription!") + try: + max_duration_str = self.max_duration_entry.get().strip() + max_duration = float(max_duration_str) if max_duration_str else 0 + max_duration = max_duration if max_duration > 0 else None + except ValueError: + max_duration = None + messagebox.showwarning("Invalid Duration", "Using default segment duration") # Start transcription try: - output_text = transcribe(path, glob_file, model, language, verbose) + output_text = transcribe(path, glob_file, model, language, verbose, max_duration, srt_format) except UnboundLocalError: messagebox.showinfo("Files not found error!", 'Nothing found, choose another folder.') pass diff --git a/src/_LocalTranscribe.py b/src/_LocalTranscribe.py index 94c8f8f..145d313 100644 --- a/src/_LocalTranscribe.py +++ b/src/_LocalTranscribe.py @@ -7,84 +7,124 @@ from colorama import Back,Fore colorama.init(autoreset=True) - -# Get the path def get_path(path): - glob_file = glob(path + '/*') - return glob_file - -# Main function -def transcribe(path, glob_file, model=None, language=None, verbose=False): - """ - Transcribes audio files in a specified folder using OpenAI's Whisper model. - - Args: - path (str): Path to the folder containing the audio files. - glob_file (list): List of audio file paths to transcribe. - model (str, optional): Name of the Whisper model to use for transcription. - Defaults to None, which uses the default model. - language (str, optional): Language code for transcription. Defaults to None, - which enables automatic language detection. - verbose (bool, optional): If True, enables verbose mode with detailed information - during the transcription process. Defaults to False. - - Returns: - str: A message indicating the result of the transcription process. - - Raises: - RuntimeError: If an invalid file is encountered, it will be skipped. + return glob(path + '/*') - Notes: - - The function downloads the specified model if not available locally. - - The transcribed text files will be saved in a "transcriptions" folder - within the specified path. - - """ - # Check for GPU acceleration +def transcribe(path, glob_file, model=None, language=None, verbose=False, max_segment_duration=None, srt_format=False): if cuda.is_available(): Generator('cuda').manual_seed(42) else: Generator().manual_seed(42) - # Load model - model = whisper.load_model(model) - # Start main loop - files_transcripted=[] + + device = "cuda" if cuda.is_available() else "cpu" + print(f"Using {device.upper()} for transcription") + + + model = whisper.load_model(model).to(device) + files_transcripted = [] + for file in glob_file: title = os.path.basename(file).split('.')[0] print(Back.CYAN + '\nTrying to transcribe file named: {}\U0001f550'.format(title)) + try: - result = model.transcribe( - file, - language=language, - verbose=verbose - ) + # Enable word timestamps if duration control is needed + transcribe_kwargs = { + 'language': language, + 'verbose': verbose, + 'word_timestamps': max_segment_duration is not None, + 'fp16': cuda.is_available() # This enables FP16 when using GPU + } + + result = model.transcribe(file, **transcribe_kwargs) + files_transcripted.append(result) - # Make folder if missing - try: - os.makedirs('{}/transcriptions'.format(path), exist_ok=True) - except FileExistsError: - pass - # Create segments for text files - start = [] - end = [] - text = [] + os.makedirs(f'{path}/transcriptions', exist_ok=True) + + start, end, text = [], [], [] for segment in result['segments']: - start.append(str(datetime.timedelta(seconds=segment['start']))) - end.append(str(datetime.timedelta(seconds=segment['end']))) - text.append(segment['text']) - # Save files to transcriptions folder - with open("{}/transcriptions/{}.txt".format(path, title), 'w', encoding='utf-8') as file: - file.write(title) - for i in range(len(result['segments'])): - file.write('\n[{} --> {}]:{}'.format(start[i], end[i], text[i])) - # Skip invalid files + if max_segment_duration: + process_segment_with_duration(segment, max_segment_duration, start, end, text) + else: + start.append(str(datetime.timedelta(seconds=segment['start']))) + end.append(str(datetime.timedelta(seconds=segment['end']))) + text.append(segment['text']) + + with open(f"{path}/transcriptions/{title}.{'srt' if srt_format else 'txt'}", 'w', encoding='utf-8') as f: + if srt_format: + for i, (s, e, t) in enumerate(zip(start, end, text), start=1): + # Directly use original float values instead of string parsing + start_sec = float(s.split(':')[-1]) if isinstance(s, str) else s + end_sec = float(e.split(':')[-1]) if isinstance(e, str) else e + + f.write(f"{i}\n") + f.write(f"{format_timedelta_srt(datetime.timedelta(seconds=start_sec))} --> " + f"{format_timedelta_srt(datetime.timedelta(seconds=end_sec))}\n") + f.write(f"{t}\n\n") + else: + f.write(title) + for s, e, t in zip(start, end, text): + f.write(f'\n[{s} --> {e}]: {t}') + + except RuntimeError: print(Fore.RED + 'Not a valid file, skipping.') - pass - # Check if any files were processed. - if len(files_transcripted) > 0: - output_text = 'Finished transcription, {} files can be found in {}/transcriptions'.format(len(files_transcripted), path) - else: - output_text = 'No files elligible for transcription, try adding audio or video files to this folder or choose another folder!' - # Return output text - return output_text + continue + + return f'Finished transcription, {len(files_transcripted)} files in {path}/transcriptions' + +def process_segment_with_duration(segment, max_duration, start_list, end_list, text_list): + words = segment.get('text', []) + if not words: + start_list.append(str(datetime.timedelta(seconds=segment['start']))) + end_list.append(str(datetime.timedelta(seconds=segment['end']))) + text_list.append(segment['text']) + return + + current_start = None + current_text = [] + for word in words: + # Handle cases where word dict might not have 'text' key + if 'text' not in word: + continue # Skip words without text + + word_text = word['text'].strip() + if not word_text: # Skip empty text entries + continue + + word_start = word.get('start') + word_end = word.get('end') + + # Skip words missing timestamps + if word_start is None or word_end is None: + continue + + if current_start is None: + current_start = word_start + current_end = word_end + current_text = [word_text] + else: + if (word_end - current_start) <= max_duration: + current_end = word_end + current_text.append(word_text) + else: + start_list.append(str(datetime.timedelta(seconds=current_start)).split('.')[0]) + end_list.append(str(datetime.timedelta(seconds=current_end))) + text_list.append(' '.join(current_text)) + current_start = word_start + current_end = word_end + current_text = [word_text] + + if current_start is not None and current_text: + start_list.append(str(datetime.timedelta(seconds=current_start)).split('.')[0]) + end_list.append(str(datetime.timedelta(seconds=current_end))) + text_list.append(' '.join(current_text)) + +def format_timedelta_srt(td): + """Convert timedelta to SRT format HH:MM:SS,mmm""" + total_seconds = td.total_seconds() + hours = int(total_seconds // 3600) + minutes = int((total_seconds % 3600) // 60) + seconds = int(total_seconds % 60) + milliseconds = int((total_seconds - int(total_seconds)) * 1000) + return f"{hours:02}:{minutes:02}:{seconds:02},{milliseconds:03}" diff --git a/src/__pycache__/_LocalTranscribe.cpython-311.pyc b/src/__pycache__/_LocalTranscribe.cpython-311.pyc index 5e33f6b4dc0f2763d96333b9dab445a88d4789ac..7110e143c262f5bcc55a05b465a80bccf3baa766 100644 GIT binary patch literal 7937 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 From 73b9059b2fcf9f68c9fa3937d4ccc7833c249364 Mon Sep 17 00:00:00 2001 From: Kabeer2004 Date: Mon, 17 Feb 2025 03:55:01 +0530 Subject: [PATCH 3/4] adding words key - need to debug --- src/_LocalTranscribe.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_LocalTranscribe.py b/src/_LocalTranscribe.py index 145d313..2d1b881 100644 --- a/src/_LocalTranscribe.py +++ b/src/_LocalTranscribe.py @@ -74,7 +74,7 @@ def transcribe(path, glob_file, model=None, language=None, verbose=False, max_se return f'Finished transcription, {len(files_transcripted)} files in {path}/transcriptions' def process_segment_with_duration(segment, max_duration, start_list, end_list, text_list): - words = segment.get('text', []) + words = segment.get('words', []) if not words: start_list.append(str(datetime.timedelta(seconds=segment['start']))) end_list.append(str(datetime.timedelta(seconds=segment['end']))) From f185f829c7a294e5771c22120d8673c2a19785e1 Mon Sep 17 00:00:00 2001 From: Kabeer2004 Date: Wed, 19 Feb 2025 03:07:07 +0530 Subject: [PATCH 4/4] fixing SRT file generation - added minimum segment length logic --- app.py | 79 ++++---- src/_LocalTranscribe.py | 174 ++++++++++-------- .../_LocalTranscribe.cpython-311.pyc | Bin 7937 -> 8113 bytes 3 files changed, 149 insertions(+), 104 deletions(-) diff --git a/app.py b/app.py index 255c7b0..7300690 100644 --- a/app.py +++ b/app.py @@ -10,8 +10,6 @@ colorama.init(autoreset=True) import os - - customtkinter.set_appearance_mode("System") customtkinter.set_default_color_theme("blue") # Themes: blue (default), dark-blue, green firstclick = True @@ -22,17 +20,30 @@ def __init__(self, master): self.master = master font = ('Roboto', 13, 'bold') font_b = ('Roboto', 12) + + # Folder selection frame path_frame = customtkinter.CTkFrame(master) path_frame.pack(fill=tk.BOTH, padx=10, pady=10) customtkinter.CTkLabel(path_frame, text="Folder:", font=font).pack(side=tk.LEFT, padx=5) self.path_entry = customtkinter.CTkEntry(path_frame, width=50, font=font_b) self.path_entry.pack(side=tk.LEFT, fill=tk.X, expand=True) customtkinter.CTkButton(path_frame, text="Browse", command=self.browse, font=font).pack(side=tk.LEFT, padx=5) + + # Max segment duration frame max_duration_frame = customtkinter.CTkFrame(master) max_duration_frame.pack(fill=tk.BOTH, padx=10, pady=10) customtkinter.CTkLabel(max_duration_frame, text="Max Segment (sec):", font=font).pack(side=tk.LEFT, padx=5) self.max_duration_entry = customtkinter.CTkEntry(max_duration_frame, width=50, placeholder_text="0 = no limit", font=font_b) - self.max_duration_entry.pack(side=tk.LEFT, fill=tk.X, expand=True) + self.max_duration_entry.pack(side=tk.LEFT, fill=tk.X, expand=True) + + # **New** Minimum segment duration frame + min_duration_frame = customtkinter.CTkFrame(master) + min_duration_frame.pack(fill=tk.BOTH, padx=10, pady=10) + customtkinter.CTkLabel(min_duration_frame, text="Min Segment (sec):", font=font).pack(side=tk.LEFT, padx=5) + self.min_duration_entry = customtkinter.CTkEntry(min_duration_frame, width=50, placeholder_text="0 = no limit", font=font_b) + self.min_duration_entry.pack(side=tk.LEFT, fill=tk.X, expand=True) + + # Language frame def on_entry_click(event): """function that gets called whenever entry is clicked""" global firstclick @@ -47,63 +58,64 @@ def on_entry_click(event): self.language_entry.insert(0, self.default_language_text) self.language_entry.bind('', on_entry_click) self.language_entry.pack(side=tk.LEFT, fill=tk.X, expand=True) + # Model frame - models = ['base.en', 'base', 'small.en', - 'small', 'medium.en', 'medium', 'large'] + models = ['base.en', 'base', 'small.en', 'small', 'medium.en', 'medium', 'large'] model_frame = customtkinter.CTkFrame(master) model_frame.pack(fill=tk.BOTH, padx=10, pady=10) customtkinter.CTkLabel(model_frame, text="Model:", font=font).pack(side=tk.LEFT, padx=5) - # ComboBox frame - self.model_combobox = customtkinter.CTkComboBox( - model_frame, width=50, state="readonly", - values=models, font=font_b) + self.model_combobox = customtkinter.CTkComboBox(model_frame, width=50, state="readonly", + values=models, font=font_b) self.model_combobox.set(models[1]) self.model_combobox.pack(side=tk.LEFT, fill=tk.X, expand=True) + # Verbose frame verbose_frame = customtkinter.CTkFrame(master) verbose_frame.pack(fill=tk.BOTH, padx=10, pady=10) self.verbose_var = tk.BooleanVar() customtkinter.CTkCheckBox(verbose_frame, text="Output transcription to terminal", variable=self.verbose_var, font=font).pack(side=tk.LEFT, padx=5) - #Output Frame + + # Output Format Frame output_frame = customtkinter.CTkFrame(master) output_frame.pack(fill=tk.BOTH, padx=10, pady=10) self.srt_var = tk.BooleanVar() customtkinter.CTkCheckBox(output_frame, text="Export as SRT", variable=self.srt_var, font=font).pack(side=tk.LEFT, padx=5) + # Progress Bar self.progress_bar = ttk.Progressbar(master, length=200, mode='indeterminate') + # Button actions frame button_frame = customtkinter.CTkFrame(master) button_frame.pack(fill=tk.BOTH, padx=10, pady=10) self.transcribe_button = customtkinter.CTkButton(button_frame, text="Transcribe", command=self.start_transcription, font=font) self.transcribe_button.pack(side=tk.LEFT, padx=5, pady=10, fill=tk.X, expand=True) customtkinter.CTkButton(button_frame, text="Quit", command=master.quit, font=font).pack(side=tk.RIGHT, padx=5, pady=10, fill=tk.X, expand=True) + # Helper functions - # Browsing def browse(self): initial_dir = os.getcwd() folder_path = filedialog.askdirectory(initialdir=initial_dir) self.path_entry.delete(0, tk.END) self.path_entry.insert(0, folder_path) - # Start transcription + def start_transcription(self): # Disable transcribe button self.transcribe_button.configure(state=tk.DISABLED) # Start a new thread for the transcription process threading.Thread(target=self.transcribe_thread).start() - # Threading + def transcribe_thread(self): path = self.path_entry.get() model = self.model_combobox.get() language = self.language_entry.get() srt_format = self.srt_var.get() - # Check if the language field has the default text or is empty if language == self.default_language_text or not language.strip(): - language = None # This is the same as passing nothing + language = None verbose = self.verbose_var.get() - # Show progress bar + self.progress_bar.pack(fill=tk.X, padx=5, pady=5) self.progress_bar.start() - # Setting path and files + glob_file = get_path(path) try: max_duration_str = self.max_duration_entry.get().strip() @@ -112,34 +124,39 @@ def transcribe_thread(self): except ValueError: max_duration = None messagebox.showwarning("Invalid Duration", "Using default segment duration") - # Start transcription + try: - output_text = transcribe(path, glob_file, model, language, verbose, max_duration, srt_format) + # **New** Read minimum segment duration + min_duration_str = self.min_duration_entry.get().strip() + min_duration = float(min_duration_str) if min_duration_str else 0 + # Use 0 if no valid minimum is provided + min_duration = min_duration if min_duration > 0 else 0 + except ValueError: + min_duration = 0 + messagebox.showwarning("Invalid Duration", "Using default segment minimum duration") + + try: + output_text = transcribe(path, glob_file, model, language, verbose, max_duration, srt_format, min_segment_duration=min_duration) except UnboundLocalError: messagebox.showinfo("Files not found error!", 'Nothing found, choose another folder.') - pass + output_text = "" except ValueError: - messagebox.showinfo("Invalid language name, you might have to clear the default text to continue!") - # Hide progress bar + messagebox.showinfo("Invalid language name", "You might have to clear the default text to continue!") + output_text = "" + self.progress_bar.stop() self.progress_bar.pack_forget() - # Enable transcribe button self.transcribe_button.configure(state=tk.NORMAL) - # Recover output text try: messagebox.showinfo("Finished!", output_text) except UnboundLocalError: pass if __name__ == "__main__": - # Setting custom themes root = customtkinter.CTk() root.title("Local Transcribe with Whisper") - # Geometry - width,height = 450,275 - root.geometry('{}x{}'.format(width,height)) - # Icon + width, height = 450,450 + root.geometry('{}x{}'.format(width, height)) root.iconbitmap('images/icon.ico') - # Run app = App(root) - root.mainloop() + root.mainloop() \ No newline at end of file diff --git a/src/_LocalTranscribe.py b/src/_LocalTranscribe.py index 2d1b881..98399e7 100644 --- a/src/_LocalTranscribe.py +++ b/src/_LocalTranscribe.py @@ -4,124 +4,152 @@ import whisper from torch import cuda, Generator import colorama -from colorama import Back,Fore +from colorama import Back, Fore colorama.init(autoreset=True) def get_path(path): return glob(path + '/*') -def transcribe(path, glob_file, model=None, language=None, verbose=False, max_segment_duration=None, srt_format=False): - if cuda.is_available(): - Generator('cuda').manual_seed(42) - else: - Generator().manual_seed(42) - +def transcribe(path, glob_file, model=None, language=None, verbose=False, max_segment_duration=None, srt_format=False, min_segment_duration=0): device = "cuda" if cuda.is_available() else "cpu" print(f"Using {device.upper()} for transcription") - model = whisper.load_model(model).to(device) files_transcripted = [] - + for file in glob_file: title = os.path.basename(file).split('.')[0] - print(Back.CYAN + '\nTrying to transcribe file named: {}\U0001f550'.format(title)) - + print(Back.CYAN + f'\nTrying to transcribe file named: {title}\U0001f550') + try: - # Enable word timestamps if duration control is needed transcribe_kwargs = { 'language': language, 'verbose': verbose, 'word_timestamps': max_segment_duration is not None, - 'fp16': cuda.is_available() # This enables FP16 when using GPU + 'fp16': cuda.is_available() } result = model.transcribe(file, **transcribe_kwargs) - files_transcripted.append(result) os.makedirs(f'{path}/transcriptions', exist_ok=True) - - start, end, text = [], [], [] + + segments = [] for segment in result['segments']: if max_segment_duration: - process_segment_with_duration(segment, max_segment_duration, start, end, text) + # Pass both max and min durations to the splitting function + segments += split_segment(segment, max_segment_duration, min_duration=min_segment_duration) else: - start.append(str(datetime.timedelta(seconds=segment['start']))) - end.append(str(datetime.timedelta(seconds=segment['end']))) - text.append(segment['text']) - + segments.append({ + 'start': segment['start'], + 'end': segment['end'], + 'text': segment['text'] + }) + with open(f"{path}/transcriptions/{title}.{'srt' if srt_format else 'txt'}", 'w', encoding='utf-8') as f: if srt_format: - for i, (s, e, t) in enumerate(zip(start, end, text), start=1): - # Directly use original float values instead of string parsing - start_sec = float(s.split(':')[-1]) if isinstance(s, str) else s - end_sec = float(e.split(':')[-1]) if isinstance(e, str) else e - + for i, seg in enumerate(segments, start=1): f.write(f"{i}\n") - f.write(f"{format_timedelta_srt(datetime.timedelta(seconds=start_sec))} --> " - f"{format_timedelta_srt(datetime.timedelta(seconds=end_sec))}\n") - f.write(f"{t}\n\n") + f.write(f"{format_timedelta_srt(seg['start'])} --> {format_timedelta_srt(seg['end'])}\n") + f.write(f"{seg['text'].strip()}\n\n") else: f.write(title) - for s, e, t in zip(start, end, text): - f.write(f'\n[{s} --> {e}]: {t}') + for seg in segments: + start = str(datetime.timedelta(seconds=seg['start'])).split('.')[0] + end = str(datetime.timedelta(seconds=seg['end'])).split('.')[0] + f.write(f"\n[{start} --> {end}]: {seg['text']}") - except RuntimeError: print(Fore.RED + 'Not a valid file, skipping.') continue return f'Finished transcription, {len(files_transcripted)} files in {path}/transcriptions' -def process_segment_with_duration(segment, max_duration, start_list, end_list, text_list): +def split_segment(segment, max_duration, min_duration=0): + # If the entire segment is already short, return it as is. + if segment['end'] - segment['start'] <= max_duration: + return [segment] + + chunks = [] words = segment.get('words', []) + + # If word timestamps are not available, use the fallback. if not words: - start_list.append(str(datetime.timedelta(seconds=segment['start']))) - end_list.append(str(datetime.timedelta(seconds=segment['end']))) - text_list.append(segment['text']) - return - - current_start = None + return duration_split(segment, max_duration) + + current_start = segment['start'] current_text = [] + for word in words: - # Handle cases where word dict might not have 'text' key - if 'text' not in word: - continue # Skip words without text - - word_text = word['text'].strip() - if not word_text: # Skip empty text entries - continue - - word_start = word.get('start') - word_end = word.get('end') - - # Skip words missing timestamps - if word_start is None or word_end is None: - continue + # If adding this word would exceed max_duration and we have some text accumulated... + if (word['end'] - current_start) > max_duration and current_text: + chunks.append({ + 'start': current_start, + 'end': word['start'], # you might choose to use previous word's end if desired + 'text': ' '.join(current_text) + }) + current_start = word['start'] + current_text = [word['text'].strip()] + else: + current_text.append(word['text'].strip()) + + # Add any remaining words as a chunk. + if current_text: + chunks.append({ + 'start': current_start, + 'end': words[-1]['end'], + 'text': ' '.join(current_text) + }) + + # Optionally, merge chunks that are too short + if min_duration > 0: + chunks = merge_short_chunks(chunks, min_duration) + + return chunks - if current_start is None: - current_start = word_start - current_end = word_end - current_text = [word_text] +def merge_short_chunks(chunks, min_duration): + if not chunks: + return chunks + merged = [chunks[0]] + for chunk in chunks[1:]: + prev_chunk = merged[-1] + # If the duration of the previous chunk is less than min_duration, merge it with the current chunk. + if (prev_chunk['end'] - prev_chunk['start']) < min_duration: + merged[-1] = { + 'start': prev_chunk['start'], + 'end': chunk['end'], + 'text': prev_chunk['text'] + ' ' + chunk['text'] + } else: - if (word_end - current_start) <= max_duration: - current_end = word_end - current_text.append(word_text) - else: - start_list.append(str(datetime.timedelta(seconds=current_start)).split('.')[0]) - end_list.append(str(datetime.timedelta(seconds=current_end))) - text_list.append(' '.join(current_text)) - current_start = word_start - current_end = word_end - current_text = [word_text] + merged.append(chunk) + return merged - if current_start is not None and current_text: - start_list.append(str(datetime.timedelta(seconds=current_start)).split('.')[0]) - end_list.append(str(datetime.timedelta(seconds=current_end))) - text_list.append(' '.join(current_text)) +def duration_split(segment, max_duration): + """Fallback splitting when word timestamps are unavailable""" + chunks = [] + start = segment['start'] + end = segment['end'] + duration = end - start + + if duration <= max_duration: + return [segment] + + num_splits = int(duration // max_duration) + 1 + chunk_duration = duration / num_splits + + for i in range(num_splits): + chunk_start = start + i * chunk_duration + chunk_end = min(start + (i+1) * chunk_duration, end) + chunks.append({ + 'start': chunk_start, + 'end': chunk_end, + 'text': segment['text'][i*len(segment['text'])//num_splits : (i+1)*len(segment['text'])//num_splits] + }) + + return chunks -def format_timedelta_srt(td): - """Convert timedelta to SRT format HH:MM:SS,mmm""" +def format_timedelta_srt(seconds): + """Directly convert seconds to SRT format""" + td = datetime.timedelta(seconds=seconds) total_seconds = td.total_seconds() hours = int(total_seconds // 3600) minutes = int((total_seconds % 3600) // 60) diff --git a/src/__pycache__/_LocalTranscribe.cpython-311.pyc b/src/__pycache__/_LocalTranscribe.cpython-311.pyc index 7110e143c262f5bcc55a05b465a80bccf3baa766..a3b1b535999c766a5dda9f8d918476eb8c794c2b 100644 GIT binary patch literal 8113 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
_e18A~4fhM~XssP+D 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 T5{~S7aFeu