From 8d5c8d60979a1bcd2592a09f9f5875a9120fd336 Mon Sep 17 00:00:00 2001 From: Mirko Milovanovic Date: Sun, 5 Apr 2026 22:11:13 +0200 Subject: [PATCH] feat: implement multiprocessing for transcription with immediate cancellation --- app.py | 104 +++++++++++++++++++++++++++------------- src/_LocalTranscribe.py | 40 ++++++++++++++++ 2 files changed, 112 insertions(+), 32 deletions(-) diff --git a/app.py b/app.py index f54aae6..6ce065c 100644 --- a/app.py +++ b/app.py @@ -4,7 +4,8 @@ import tkinter as tk from tkinter import ttk from tkinter import filedialog from tkinter import messagebox -from src._LocalTranscribe import transcribe, get_path, detect_backend +from src._LocalTranscribe import transcribe, get_path, detect_backend, _transcribe_worker_process +import multiprocessing as mp import customtkinter import threading @@ -220,8 +221,10 @@ class App: self.timestamps_switch.pack(side=tk.LEFT, padx=5) # Progress Bar self.progress_bar = ttk.Progressbar(master, length=200, mode='indeterminate') - # Stop event for cancellation - self._stop_event = threading.Event() + # Worker process handle (replaces thread+stop_event for true immediate cancellation) + self._proc = None + self._parent_conn = None + self._child_conn = None # Button actions frame button_frame = customtkinter.CTkFrame(master) button_frame.pack(fill=tk.BOTH, padx=10, pady=10) @@ -262,9 +265,28 @@ class App: print("─" * 46) # Helper functions def _stop_transcription(self): - self._stop_event.set() self.stop_button.configure(state=tk.DISABLED) - print("⛔ Stop requested — finishing current file…") + if self._proc and self._proc.is_alive(): + self._proc.terminate() + try: + self._proc.join(timeout=3) + except Exception: + pass + if self._proc.is_alive(): + self._proc.kill() + try: + self._proc.join(timeout=1) + except Exception: + pass + # Close pipe ends — no semaphores, so no leak + for conn in (self._parent_conn, self._child_conn): + try: + if conn: + conn.close() + except Exception: + pass + self._parent_conn = self._child_conn = None + print("⛔ Transcription stopped by user.") def _model_desc_text(self, model_name): info = MODEL_INFO.get(model_name) @@ -287,49 +309,67 @@ class App: self.path_entry.insert(0, folder_path) # Start transcription def start_transcription(self): - self._stop_event.clear() - self.transcribe_button.configure(state=tk.DISABLED) - self.stop_button.configure(state=tk.NORMAL) - threading.Thread(target=self.transcribe_thread, daemon=True).start() - # Threading - def transcribe_thread(self): - path = self.path_entry.get() model_display = self.model_combobox.get() - # Ignore the visual separator if model_display.startswith('─'): messagebox.showinfo("Invalid selection", "Please select a model, not the separator line.") - self.transcribe_button.configure(state=tk.NORMAL) return + self.transcribe_button.configure(state=tk.DISABLED) + self.stop_button.configure(state=tk.NORMAL) + path = self.path_entry.get() model = HF_MODEL_MAP.get(model_display, model_display) lang_label = self.language_combobox.get() language = WHISPER_LANGUAGES.get(lang_label, lang_label) if lang_label else None - verbose = True # always show transcription progress in the console panel timestamps = self.timestamps_var.get() - # Show progress bar + glob_file = get_path(path) 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 + self._parent_conn, self._child_conn = mp.Pipe(duplex=False) + self._proc = mp.Process( + target=_transcribe_worker_process, + args=(self._child_conn, path, glob_file, model, language, True, timestamps), + daemon=True, + ) + self._proc.start() + self._child_conn.close() # parent doesn't write; close its write-end + self._child_conn = None + self.master.after(100, self._poll_worker) + + def _poll_worker(self): + done = False + result = None try: - output_text = transcribe(path, glob_file, model, language, verbose, timestamps, stop_event=self._stop_event) - except UnboundLocalError: - messagebox.showinfo("Files not found error!", 'Nothing found, choose another folder.') + while self._parent_conn and self._parent_conn.poll(): + msg = self._parent_conn.recv() + if isinstance(msg, tuple) and msg[0] == '__done__': + done = True + result = msg[1] + else: + sys.stdout.write(msg) + sys.stdout.flush() + except EOFError: + # Child closed the pipe (normal completion or kill) + done = True + except Exception: pass - except ValueError as e: - messagebox.showinfo("Error", str(e)) - # Hide progress bar + if done or (self._proc and not self._proc.is_alive()): + if self._parent_conn: + try: + self._parent_conn.close() + except Exception: + pass + self._parent_conn = None + self._on_transcription_done(result) + else: + self.master.after(100, self._poll_worker) + + def _on_transcription_done(self, output_text): self.progress_bar.stop() self.progress_bar.pack_forget() - # Restore buttons self.stop_button.configure(state=tk.DISABLED) self.transcribe_button.configure(state=tk.NORMAL) - # Recover output text - try: - messagebox.showinfo("Finished!", output_text) - except UnboundLocalError: - pass + if output_text: + title = "Finished!" if not output_text.startswith('⚠') else "Error" + messagebox.showinfo(title, output_text) if __name__ == "__main__": # Setting custom themes diff --git a/src/_LocalTranscribe.py b/src/_LocalTranscribe.py index 6daf35d..366d224 100644 --- a/src/_LocalTranscribe.py +++ b/src/_LocalTranscribe.py @@ -424,3 +424,43 @@ def transcribe(path, glob_file, model=None, language=None, verbose=False, timest print(output_text) print(SEP) return output_text + + +def _transcribe_worker_process(conn, path, glob_file, model, language, verbose, timestamps): + """Child-process entry point for the UI's multiprocessing backend. + + Redirects stdout/stderr → pipe connection so the main process can display + output in the console panel. The main process sends SIGTERM/SIGKILL to + stop this process immediately, including any in-progress download or inference. + """ + import sys + + class _PipeWriter: + def __init__(self, c): + self.c = c + + def write(self, text): + if text: + try: + self.c.send(text) + except Exception: + pass + + def flush(self): + pass + + writer = _PipeWriter(conn) + sys.stdout = writer + sys.stderr = writer + + result = '⚠ No output produced.' + try: + result = transcribe(path, glob_file, model, language, verbose, timestamps) + except Exception as exc: + result = f'⚠ Unexpected error: {exc}' + finally: + try: + conn.send(('__done__', result)) + except Exception: + pass + conn.close()