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/app.py b/app.py index 20db595..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 @@ -20,24 +18,38 @@ 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) + + # 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) - # Language frame - #thanks to pommicket from Stackoverflow for this fix + + # 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) + + # **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 - 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) @@ -46,87 +58,105 @@ 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.set(models[1]) # Set the default value + 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 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() - # Check if the language field has the default text or is empty + srt_format = self.srt_var.get() 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) - #messagebox.showinfo("Message", "Starting transcription!") - # Start transcription try: - output_text = transcribe(path, glob_file, model, language, verbose) + 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") + + try: + # **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 94c8f8f..98399e7 100644 --- a/src/_LocalTranscribe.py +++ b/src/_LocalTranscribe.py @@ -4,87 +4,155 @@ import whisper from torch import cuda, Generator import colorama -from colorama import Back,Fore +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. + return glob(path + '/*') - Raises: - RuntimeError: If an invalid file is encountered, it will be skipped. +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") - 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. + model = whisper.load_model(model).to(device) + files_transcripted = [] - """ - # Check for GPU acceleration - 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=[] 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: - result = model.transcribe( - file, - language=language, - verbose=verbose - ) + transcribe_kwargs = { + 'language': language, + 'verbose': verbose, + 'word_timestamps': max_segment_duration is not None, + 'fp16': cuda.is_available() + } + + 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) + + segments = [] 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: + # Pass both max and min durations to the splitting function + segments += split_segment(segment, max_segment_duration, min_duration=min_segment_duration) + else: + 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, seg in enumerate(segments, start=1): + f.write(f"{i}\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 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.') - 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 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: + return duration_split(segment, max_duration) + + current_start = segment['start'] + current_text = [] + + for word in words: + # 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 + +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: + merged.append(chunk) + return merged + +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(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) + 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 new file mode 100644 index 0000000..a3b1b53 Binary files /dev/null and b/src/__pycache__/_LocalTranscribe.cpython-311.pyc differ