# noScribe - AI-powered Audio Transcription # Copyright (C) 2025 Kai Dröge # ported to MAC by Philipp Schneider (gernophil) # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # You should have received a copy of the GNU General Public License # along with this program. If not, see . import sys import argparse import os # In the compiled version (no command line), stdout is None which might lead to errors if sys.stdout is None: sys.stdout = open(os.devnull, "w") if sys.stderr is None: sys.stderr = open(os.devnull, "w") import tkinter as tk import customtkinter as ctk from customtkinter.windows.widgets.scaling import CTkScalingBaseClass from CTkToolTips import CTkToolTip from tkHyperlinkManager import HyperlinkManager import webbrowser from functools import partial from PIL import Image import platform import yaml import locale import appdirs from subprocess import run, Popen, PIPE, STDOUT, DEVNULL if platform.system() == 'Windows': # import torch.cuda # to check with torch.cuda.is_available() from subprocess import STARTUPINFO, STARTF_USESHOWWINDOW #if platform.system() in ("Windows", "Linux"): # from ctranslate2 import get_cuda_device_count # import torch import re if platform.system() == "Darwin": # = MAC from subprocess import check_output if platform.machine() == "x86_64": os.environ['KMP_DUPLICATE_LIB_OK']='True' # prevent OMP: Error #15: Initializing libomp.dylib, but found libiomp5.dylib already initialized. # import torch.backends.mps # loading torch modules leads to segmentation fault later from faster_whisper.audio import decode_audio from faster_whisper.vad import VadOptions, get_speech_timestamps import AdvancedHTMLParser import html from threading import Thread from tempfile import TemporaryDirectory import datetime from pathlib import Path if platform.system() in ("Darwin", "Linux"): import shlex if platform.system() == 'Windows': import cpufeature if platform.system() == 'Darwin': import Foundation import logging import json import urllib import multiprocessing as mp import queue as pyqueue import gc import traceback from enum import Enum from typing import Optional, List import time import utils import audio # Pyinstaller fix, used to open multiple instances on Mac mp.freeze_support() logging.basicConfig() logging.getLogger("faster_whisper").setLevel(logging.DEBUG) app_version = '0.7.2' app_year = '2026' app_dir = os.path.abspath(os.path.dirname(__file__)) ctk.set_appearance_mode('dark') ctk.set_default_color_theme('blue') default_html = """ """ languages = { "Auto": "auto", "Multilingual": "multilingual", "Afrikaans": "af", "Arabic": "ar", "Armenian": "hy", "Azerbaijani": "az", "Belarusian": "be", "Bosnian": "bs", "Bulgarian": "bg", "Catalan": "ca", "Chinese": "zh", "Croatian": "hr", "Czech": "cs", "Danish": "da", "Dutch": "nl", "English": "en", "Estonian": "et", "Finnish": "fi", "French": "fr", "Galician": "gl", "German": "de", "Greek": "el", "Hebrew": "he", "Hindi": "hi", "Hungarian": "hu", "Icelandic": "is", "Indonesian": "id", "Italian": "it", "Japanese": "ja", "Kannada": "kn", "Kazakh": "kk", "Korean": "ko", "Latvian": "lv", "Lithuanian": "lt", "Macedonian": "mk", "Malay": "ms", "Marathi": "mr", "Maori": "mi", "Nepali": "ne", "Norwegian": "no", "Persian": "fa", "Polish": "pl", "Portuguese": "pt", "Romanian": "ro", "Russian": "ru", "Serbian": "sr", "Slovak": "sk", "Slovenian": "sl", "Spanish": "es", "Swahili": "sw", "Swedish": "sv", "Tagalog": "tl", "Tamil": "ta", "Thai": "th", "Turkish": "tr", "Ukrainian": "uk", "Urdu": "ur", "Vietnamese": "vi", "Welsh": "cy", } # config config_dir = appdirs.user_config_dir('noScribe') if not os.path.exists(config_dir): os.makedirs(config_dir) config_file = os.path.join(config_dir, 'config.yml') try: with open(config_file, 'r') as file: config = yaml.safe_load(file) if not config: raise # config file is empty (None) except: # seems we run it for the first time and there is no config file config = {} def get_config(key: str, default) -> str: """ Get a config value, set it if it doesn't exist """ if key not in config: config[key] = default return config[key] force_pyannote_cpu = get_config('force_pyannote_cpu', '').lower() == 'true' force_whisper_cpu = get_config('force_whisper_cpu', '').lower() == 'true' _CUDA_ERROR_KEYWORDS = ( 'cuda', 'cublas', 'cudnn', 'cufft', 'device-side assert', 'invalid device function', 'nccl', 'gpu driver', 'compute capability', 'hip error', ) def _is_cuda_error_message(message: str) -> bool: if not message: return False if message.find('(device_cpu)') != -1: return False lower_message = message.lower() return any(keyword in lower_message for keyword in _CUDA_ERROR_KEYWORDS) def version_higher(version1, version2, subversion_level=99) -> int: """Will return 1 if version1 is higher 2 if version2 is higher 0 if both are equal subversion_level: Adjusts how deep suversions are compared. If subversion_level = 1, "0.7.3" and "0.7.4" will be equal, because the comparison stops after the first level of subversions ("0.7"). Default: 99 """ version1_elems = version1.split('.') version2_elems = version2.split('.') # make both versions the same length elem_num = max(len(version1_elems), len(version2_elems)) while len(version1_elems) < elem_num: version1_elems.append('0') while len(version1_elems) < elem_num: version1_elems.append('0') for i in range(elem_num): if int(version1_elems[i]) > int(version2_elems[i]): return 1 elif int(version2_elems[i]) > int(version1_elems[i]): return 2 if i >= subversion_level: break # must be completely equal return 0 config['app_version'] = app_version def save_config(): with open(config_file, 'w') as file: yaml.safe_dump(config, file) save_config() # locale: setting the language of the UI # see https://pypi.org/project/python-i18n/ import i18n from i18n import t i18n.set('filename_format', '{locale}.{format}') i18n.load_path.append(os.path.join(app_dir, 'trans')) def _show_startup_error(message: str) -> None: """Show a message box during startup failures if possible.""" root = None try: root = tk.Tk() root.withdraw() tk.messagebox.showerror(title='noScribe', message=message) except Exception as tk_error: print(f"ERROR: {message}", file=sys.stderr) finally: if root is not None: root.destroy() try: app_locale = config['locale'] except: app_locale = 'auto' if app_locale == 'auto': # read system locale settings try: if platform.system() == 'Windows': app_locale = locale.getdefaultlocale()[0][0:2] elif platform.system() == "Darwin": # = MAC app_locale = Foundation.NSUserDefaults.standardUserDefaults().stringForKey_('AppleLocale')[0:2] except: app_locale = 'en' i18n.set('fallback', 'en') translation_error = '' print('\nnoScribe') try: i18n.set('locale', app_locale) print(t('app_header'), '\n') config['locale'] = app_locale except Exception as locale_error: translation_error = f"Failed to load translation for locale '{app_locale}'. Falling back to English.\n\n" if app_locale != 'en': try: i18n.set('locale', 'en') print(t('app_header'), '\n') app_locale = 'en' except Exception as english_error: print("Failed to load English fallback translation.") _show_startup_error( 'NoScribe could not load the English fallback translation and needs to close.' ) raise SystemExit(1) from english_error else: print("English translation failed to load during startup.") _show_startup_error( 'noScribe could not load the English translation and needs to close.' ) raise SystemExit(1) from locale_error # determine optimal number of threads for faster-whisper (depending on cpu cores) if platform.system() == 'Windows': number_threads = get_config('threads', cpufeature.CPUFeature["num_physical_cores"]) elif platform.system() == "Linux": number_threads = get_config('threads', os.cpu_count() if os.cpu_count() is not None else 4) elif platform.system() == "Darwin": # = MAC if platform.machine() == "arm64": cpu_count = int(check_output(["sysctl", "-n", "hw.perflevel0.logicalcpu_max"])) elif platform.machine() == "x86_64": cpu_count = int(check_output(["sysctl", "-n", "hw.logicalcpu_max"])) else: raise Exception("Unsupported mac") number_threads = get_config('threads', int(cpu_count * 0.75)) else: raise Exception('Platform not supported yet.') # timestamp regex timestamp_re = re.compile(r'\[\d\d:\d\d:\d\d.\d\d\d --> \d\d:\d\d:\d\d.\d\d\d\]') # Transcription Job Management Classes class JobStatus(Enum): WAITING = "waiting" AUDIO_CONVERSION = "audio_conversion" SPEAKER_IDENTIFICATION = "speaker_identification" TRANSCRIPTION = "transcription" CANCELING = "canceling" CANCELED = "canceled" FINISHED = "finished" ERROR = "error" class TranscriptionJob: """Represents a single transcription job with all its parameters and status""" def __init__(self): # Status tracking self.status: JobStatus = JobStatus.WAITING self.error_message: Optional[str] = None self.error_tb: Optional[str] = None self.created_at: datetime.datetime = datetime.datetime.now() self.started_at: Optional[datetime.datetime] = None self.finished_at: Optional[datetime.datetime] = None # Progress tracking self.progress: float = 0.0 # Progress from 0.0 to 1.0 # File paths self.audio_file: str = '' self.transcript_file: str = '' # Partial transcript tracking self.has_partial_transcript: bool = False # Time range self.start: int = 0 # milliseconds self.stop: int = 0 # milliseconds (0 means until end) # Language and model settings self.language_name: str = 'Auto' self.whisper_model: str = '' # path to the model # Processing options self.speaker_detection: str = 'auto' self.overlapping: bool = True self.timestamps: bool = False self.disfluencies: bool = True self.pause: int = 0 # index value (0=none, 1=1sec+, etc.) # Config-based options self.whisper_beam_size: int = 1 self.whisper_temperature: float = 0.0 self.whisper_compute_type: str = 'default' self.timestamp_interval: int = 60_000 self.timestamp_color: str = '#78909C' self.pause_marker: str = '.' self.auto_save: bool = True self.whisper_xpu: str = 'cpu' self.vad_threshold: float = 0.5 # Derived properties self.file_ext: str = '' def set_running(self): """Mark job as running and record start time""" self.status = JobStatus.AUDIO_CONVERSION self.started_at = datetime.datetime.now() def set_finished(self): """Mark job as finished and record completion time""" self.status = JobStatus.FINISHED self.finished_at = datetime.datetime.now() def set_error(self, error_message: str, error_tb: str = ''): """Mark job as failed and store error message""" self.status = JobStatus.ERROR self.error_message = error_message self.error_tb = error_tb self.finished_at = datetime.datetime.now() def set_canceled(self, message: Optional[str] = None): """Mark job as canceled by the user""" self.status = JobStatus.CANCELED self.error_message = message self.finished_at = datetime.datetime.now() def get_duration(self) -> Optional[datetime.timedelta]: """Get processing duration if job is completed""" if self.started_at and self.finished_at: return self.finished_at - self.started_at return None def format_summary(self) -> str: """Build a concise, multi-line summary for tooltips. Uses localized UI labels where available and simple symbols for booleans. """ lines = [] def yn(v: bool) -> str: return '✓' if bool(v) else '✗' # Output file (show basename and format) try: out_name = os.path.basename(self.transcript_file) if self.transcript_file else '' lines.append(f"{t('job_tt_transcript_file')} {out_name}") except Exception: pass # Time range try: start_ms = getattr(self, 'start', 0) or 0 stop_ms = getattr(self, 'stop', 0) or 0 start_txt = utils.ms_to_str(start_ms) if start_ms > 0 else '00:00:00' stop_txt = utils.ms_to_str(stop_ms) if stop_ms > 0 else 'end' lines.append(f"{t('label_start')} {start_txt}") lines.append(f"{t('label_stop')} {stop_txt}") except Exception: pass # Language try: lines.append(f"{t('label_language')} {self.language_name}") except Exception: pass # Model (display basename if a path) try: model_disp = os.path.basename(self.whisper_model) if self.whisper_model else '' if not model_disp: model_disp = str(self.whisper_model) lines.append(f"{t('label_whisper_model')} {model_disp}") except Exception: pass # Pause threshold (map int index back to label) try: pause_opts = ['none', '1sec+', '2sec+', '3sec+'] pause_disp = pause_opts[self.pause] if isinstance(self.pause, int) and 0 <= self.pause < len(pause_opts) else str(self.pause) lines.append(f"{t('label_pause')} {pause_disp}") except Exception: pass # Speaker detection try: lines.append(f"{t('label_speaker')} {self.speaker_detection}") except Exception: pass # Overlapping speech try: lines.append(f"{t('label_overlapping')} {yn(self.overlapping)}") except Exception: pass # Disfluencies try: lines.append(f"{t('label_disfluencies')} {yn(self.disfluencies)}") except Exception: pass # Timestamps try: lines.append(f"{t('label_timestamps')} {yn(self.timestamps)}") except Exception: pass return "\n".join([ln for ln in lines if ln]) class TranscriptionQueue: """Manages a queue of transcription jobs""" def __init__(self): self.jobs: List[TranscriptionJob] = [] self.current_job: Optional[TranscriptionJob] = None # Track currently running job def add_job(self, job: TranscriptionJob): """Add a job to the queue""" self.jobs.append(job) def get_waiting_jobs(self) -> List[TranscriptionJob]: """Get all jobs with WAITING status""" return [job for job in self.jobs if job.status == JobStatus.WAITING] def get_running_jobs(self) -> List[TranscriptionJob]: """Get all jobs currently being processed""" return [job for job in self.jobs if job.status in [JobStatus.AUDIO_CONVERSION, JobStatus.SPEAKER_IDENTIFICATION, JobStatus.TRANSCRIPTION, JobStatus.CANCELING]] def get_finished_jobs(self) -> List[TranscriptionJob]: """Get all successfully completed jobs""" return [job for job in self.jobs if job.status == JobStatus.FINISHED] def get_failed_jobs(self) -> List[TranscriptionJob]: """Get all jobs that encountered errors""" return [job for job in self.jobs if job.status == JobStatus.ERROR] def get_canceled_jobs(self) -> List[TranscriptionJob]: """Get all jobs that were canceled by the user""" return [job for job in self.jobs if job.status == JobStatus.CANCELED] def has_pending_jobs(self) -> bool: """Check if there are jobs waiting to be processed""" return len(self.get_waiting_jobs()) > 0 def is_running(self) -> bool: """Check if any job are currently beeing processed""" return len(self.get_running_jobs()) > 0 def get_next_waiting_job(self) -> Optional[TranscriptionJob]: """Get the next job to process""" waiting_jobs = self.get_waiting_jobs() return waiting_jobs[0] if waiting_jobs else None def get_queue_summary(self) -> dict: """Get summary statistics of the queue""" return { 'total': len(self.jobs), 'waiting': len(self.get_waiting_jobs()), 'running': len(self.get_running_jobs()), 'finished': len(self.get_finished_jobs()), 'errors': len(self.get_failed_jobs()), 'canceled': len(self.get_canceled_jobs()), } def is_empty(self) -> bool: """Check if queue is empty""" return len(self.jobs) == 0 def has_output_conflict(self, transcript_file: str, ignore_job: Optional[TranscriptionJob] = None) -> bool: """Check if another queue job uses the same output file. Ignores jobs in ERROR, CANCELING, CANCELED and optionally a given job.""" try: target = os.path.abspath(transcript_file) except Exception: return False try: for j in self.jobs: try: if not j or j is ignore_job: continue tf = getattr(j, 'transcript_file', None) if not tf: continue if os.path.abspath(tf) == target and j.status not in [JobStatus.ERROR, JobStatus.CANCELING, JobStatus.CANCELED]: return True except Exception: continue except Exception: return False return False def confirm_output_override(self, transcript_file: str, ignore_job: Optional[TranscriptionJob] = None) -> bool: """Prompt the user if a conflicting output file is found. Returns True to proceed.""" try: if self.has_output_conflict(transcript_file, ignore_job=ignore_job): msg = t('output_override') return tk.messagebox.askyesno(title='noScribe', message=msg) except Exception: pass return True # Command Line Interface def create_transcription_job(audio_file=None, transcript_file=None, start_time=None, stop_time=None, language_name=None, whisper_model_name=None, speaker_detection=None, overlapping=None, timestamps=None, disfluencies=None, pause=None, cli_mode=False) -> TranscriptionJob: """Create a TranscriptionJob with all default values This function handles both CLI and GUI job creation, ensuring all defaults are consistent between both modes. """ job = TranscriptionJob() # File paths job.audio_file = audio_file or '' job.transcript_file = transcript_file or '' if job.transcript_file: job.file_ext = os.path.splitext(job.transcript_file)[1][1:] if not job.file_ext in ['html', 'txt', 'vtt']: raise Exception(t('err_unsupported_output_format', file_type=job.file_ext)) # Time range job.start = start_time if start_time is not None else 0 job.stop = stop_time if stop_time is not None else 0 # Language - handle both language names and codes if language_name: if language_name in languages.values(): # Find language name by code job.language_name = next(name for name, code in languages.items() if code == language_name) elif language_name in languages.keys(): # Language name provided directly job.language_name = language_name else: raise ValueError(f"Unknown language: {language_name}") else: job.language_name = 'Auto' # Model (will be validated later when we have access to the app instance) job.whisper_model = whisper_model_name or 'precise' # Processing options with defaults job.speaker_detection = speaker_detection if speaker_detection is not None else 'auto' job.overlapping = overlapping if overlapping is not None else True job.timestamps = timestamps if timestamps is not None else False job.disfluencies = disfluencies if disfluencies is not None else True # Pause setting if pause is not None: if isinstance(pause, str): pause_options = ['none', '1sec+', '2sec+', '3sec+'] if pause in pause_options: job.pause = pause_options.index(pause) else: job.pause = 1 # default to '1sec+' else: job.pause = pause else: job.pause = 1 # default to '1sec+' # Config-based options (use defaults from config) job.whisper_beam_size = get_config('whisper_beam_size', 1) job.whisper_temperature = get_config('whisper_temperature', 0.0) job.whisper_compute_type = get_config('whisper_compute_type', 'default') job.timestamp_interval = get_config('timestamp_interval', 60_000) job.timestamp_color = get_config('timestamp_color', '#78909C') job.pause_marker = get_config('pause_seconds_marker', '.') job.auto_save = False if get_config('auto_save', 'True') == 'False' else True job.vad_threshold = float(get_config('voice_activity_detection_threshold', '0.5')) # Platform-specific XPU settings """ if platform.system() == "Darwin": # MAC xpu = get_config('pyannote_xpu', 'mps' if platform.mac_ver()[0] >= '12.3' else 'cpu') job.pyannote_xpu = 'mps' if xpu == 'mps' else 'cpu' elif platform.system() in ('Windows', 'Linux'): try: cuda_available = torch.cuda.is_available() and get_cuda_device_count() > 0 except: cuda_available = False xpu = get_config('pyannote_xpu', 'cuda' if cuda_available else 'cpu') job.pyannote_xpu = 'cuda' if xpu == 'cuda' else 'cpu' whisper_xpu = get_config('whisper_xpu', 'cuda' if cuda_available else 'cpu') job.whisper_xpu = 'cuda' if whisper_xpu == 'cuda' else 'cpu' else: raise Exception('Platform not supported yet.') """ # Check for invalid VTT options if job.file_ext == 'vtt' and (job.pause > 0 or job.overlapping or job.timestamps): if cli_mode: print("Warning: VTT format doesn't support pause markers, overlapping speech, or timestamps. These options will be disabled.") job.pause = 0 job.overlapping = False job.timestamps = False return job def create_job_from_cli_args(args) -> TranscriptionJob: """Create a TranscriptionJob from command line arguments""" # Parse time arguments start_time = utils.str_to_ms(args.start) if args.start else None stop_time = utils.str_to_ms(args.stop) if args.stop else None return create_transcription_job( audio_file=args.audio_file, transcript_file=args.output_file, start_time=start_time, stop_time=stop_time, language_name=args.language, whisper_model_name=args.model, speaker_detection=args.speaker_detection, overlapping=args.overlapping, timestamps=args.timestamps, disfluencies=args.disfluencies, pause=args.pause, cli_mode=True ) def parse_cli_args(): """Parse command line arguments""" parser = argparse.ArgumentParser( description='noScribe - AI-powered Audio Transcription', formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: python noScribe.py audio.wav transcript.html python noScribe.py audio.mp3 transcript.txt --language en --speaker-detection 2 python noScribe.py audio.wav transcript.vtt --start 00:01:30 --stop 00:05:00 python noScribe.py --help-models # Show available models """ ) # Special argument to show available models parser.add_argument('--help-models', action='store_true', help='Show available Whisper models and exit') # Required arguments (when not using --help-models) parser.add_argument('audio_file', nargs='?', help='Input audio file path') parser.add_argument('output_file', nargs='?', help='Output transcript file path (.html, .txt, or .vtt)') # Optional arguments parser.add_argument('--no-gui', action='store_true', default=False, help='Run without showing the GUI (headless mode)') parser.add_argument('--start', default=None, help='Start time (format: HH:MM:SS)') parser.add_argument('--stop', default=None, help='Stop time (format: HH:MM:SS)') parser.add_argument('--language', default=None, help='Language code (e.g., en, de, fr) or "auto" for auto-detection') parser.add_argument('--model', default=None, help='Whisper model to use (use --help-models to see available models)') parser.add_argument('--speaker-detection', choices=['none', 'auto', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10'], default=None, help='Speaker detection/diarization setting') parser.add_argument('--overlapping', action='store_true', default=None, help='Enable overlapping speech detection') parser.add_argument('--no-overlapping', action='store_false', dest='overlapping', default=None, help='Disable overlapping speech detection') parser.add_argument('--timestamps', action='store_true', default=None, help='Include visible timestamps in the transcript') parser.add_argument('--no-timestamps', action='store_false', dest='timestamps', default=None, help='Exclude visible timestamps from the transcript') parser.add_argument('--disfluencies', action='store_true', default=None, help='Include disfluencies (uh, um, etc.) in transcript') parser.add_argument('--no-disfluencies', action='store_false', dest='disfluencies', default=None, help='Exclude disfluencies from transcript') parser.add_argument('--pause', choices=['none', '1sec+', '2sec+', '3sec+'], default=None, help='Mark pauses in transcript') return parser.parse_args() class TimeEntry(ctk.CTkEntry): # special Entry box to enter time in the format hh:mm:ss # based on https://stackoverflow.com/questions/63622880/how-to-make-python-automatically-put-colon-in-the-format-of-time-hhmmss def __init__(self, master, **kwargs): ctk.CTkEntry.__init__(self, master, **kwargs) vcmd = self.register(self.validate) self.bind('', self.format) self.configure(validate="all", validatecommand=(vcmd, '%P')) self.valid = re.compile(r'^\d{0,2}(:\d{0,2}(:\d{0,2})?)?$', re.I) def validate(self, text): if text == '': return True elif ''.join(text.split(':')).isnumeric(): return not self.valid.match(text) is None else: return False def format(self, event): if event.keysym not in ['BackSpace', 'Shift_L', 'Shift_R', 'Control_L', 'Control_R']: i = self.index('insert') if i in [2, 5]: if event.char != ':': if self.get()[i:i+1] != ':': self.insert(i, ':') class JobEntryFrame(ctk.CTkFrame, CTkScalingBaseClass): """A custom frame that can display a progress bar as its background with text overlays""" def __init__(self, master, progress=0.0, progress_color=None, **kwargs): ctk.CTkFrame.__init__(self, master, **kwargs) CTkScalingBaseClass.__init__(self, scaling_type="widget") if not progress_color: progress_color = ctk.ThemeManager.theme['CTkProgressBar']['progress_color'][1] self.progress = progress self.progress_color = progress_color self.base_color = self._fg_color self.show_progress = False # Only show progress during processing # Store text content self.name_text = "" self.status_text = "" self.status_color = "lightgray" # Create a canvas to draw the progress background and text self.progress_canvas = tk.Canvas(self, highlightthickness=0) self.progress_canvas.place(x=0, y=0, relwidth=1, relheight=1) # Forward mouse events from canvas to frame for CTkToolTip functionality self.progress_canvas.bind("", self._on_canvas_enter) self.progress_canvas.bind("", self._on_canvas_leave) # Bind to configure event to redraw when size changes self.bind('', self._on_configure) # Update the progress display self._update_progress_display() def destroy(self): """Override destroy to properly clean up scaling callbacks""" CTkScalingBaseClass.destroy(self) ctk.CTkFrame.destroy(self) def set_progress(self, progress, show_progress=True): """Set the progress value (0.0 to 1.0) and whether to show progress bar""" self.progress = max(0.0, min(1.0, progress)) self.show_progress = show_progress self._update_progress_display() def set_name_text(self, text): """Set the name text to display""" self.name_text = text self._update_progress_display() def set_status_text(self, text, color="lightgray"): """Set the status text and color to display""" self.status_text = text self.status_color = color self._update_progress_display() def bind_click(self, callback): """Bind click event to the canvas""" self.progress_canvas.bind("", callback) def unbind_click(self): """Unbind click event from the canvas""" self.progress_canvas.unbind("") def configure_cursor(self, cursor): """Configure cursor for the canvas""" self.progress_canvas.configure(cursor=cursor) def _on_configure(self, event=None): """Handle resize events""" self._update_progress_display() def _get_scaled_font_size(self): """Calculate font size based on frame height and use CustomTkinter's scaling""" try: font = ctk.CTkFont() scaled_font = self._apply_font_scaling(font) return scaled_font[1] except: return 13 # Fallback def _on_canvas_enter(self, event): """Forward canvas Enter event to frame for CTkToolTip""" # Generate a synthetic Enter event for the frame self.event_generate("") def _on_canvas_leave(self, event): """Forward canvas Leave event to frame for CTkToolTip""" # Generate a synthetic Leave event for the frame self.event_generate("") def _update_progress_display(self): """Update the progress bar display and text""" if not self.progress_canvas.winfo_exists(): return # Clear the canvas self.progress_canvas.delete("all") # Get canvas dimensions width = self.progress_canvas.winfo_width() height = self.progress_canvas.winfo_height() if width <= 1 or height <= 1: # Canvas not ready yet self.after(10, self._update_progress_display) return # Calculate button area width to avoid overlap (1 button = 30px + padding) # Reserve space for up to 3 buttons (X, ⟲/✔, ✔) button_area_width = 3 * self._apply_widget_scaling(30 + 5) # Draw base background base_color = self.base_color[1] if isinstance(self.base_color, tuple) else self.base_color self.progress_canvas.configure(bg=base_color) # Draw progress bar only if show_progress is True and there's progress if self.show_progress and self.progress > 0: progress_width = int((width - button_area_width) * self.progress) self.progress_canvas.create_rectangle( 0, 0, progress_width, height, fill=self.progress_color, outline="" ) # Calculate font size based on screen scaling font_size = self._get_scaled_font_size() # Draw text overlays if self.name_text: self.progress_canvas.create_text( 10, height // 2, text=self.name_text, anchor="w", fill="lightgray", font=("", font_size) ) if self.status_text: # Position status text to avoid button overlap status_x = width - button_area_width - self._apply_widget_scaling(5) self.progress_canvas.create_text( status_x, height // 2, text=self.status_text, anchor="e", fill=self.status_color, font=("", font_size) ) def _init_app_state(app): app._headless = False app.user_models_dir = os.path.join(config_dir, 'whisper_models') os.makedirs(app.user_models_dir, exist_ok=True) whisper_models_readme = os.path.join(app.user_models_dir, 'readme.txt') if not os.path.exists(whisper_models_readme): with open(whisper_models_readme, 'w') as file: file.write('You can download custom Whisper-models for the transcription into this folder. \n' 'See here for more information: https://github.com/kaixxx/noScribe/wiki/Add-custom-Whisper-models-for-transcription') app.queue = TranscriptionQueue() app.audio_files_list = [] app.transcript_files_list = [] app.log_file = None app.cancel = False # if set to True, transcription will be canceled # If True, cancel only the currently running job (triggered from queue row "X") app._cancel_job_only = False app.current_progress = -1 # Track background activity for robust shutdown app._worker_threads = [] app._mp_proc = None app._mp_queue = None app._ffmpeg_proc = None app._shutting_down = False class App(ctk.CTk): def __init__(self): super().__init__() self.protocol("WM_DELETE_WINDOW", self.on_closing) _init_app_state(self) # configure window self.title('noScribe - ' + t('app_header')) if platform.system() in ("Darwin", "Linux"): self.geometry(f"{1100}x{765}") else: self.geometry(f"{1100}x{690}") if platform.system() in ("Darwin", "Windows"): self.iconbitmap(os.path.join(app_dir, 'noScribeLogo.ico')) if platform.system() == "Linux": if hasattr(sys, "_MEIPASS"): self.iconphoto(True, tk.PhotoImage(file=os.path.join(sys._MEIPASS, "noScribeLogo.png"))) else: self.iconphoto(True, tk.PhotoImage(file='noScribeLogo.png')) # header self.frame_header = ctk.CTkFrame(self, height=100) self.frame_header.pack(padx=0, pady=0, anchor='nw', fill='x') self.frame_header_logo = ctk.CTkFrame(self.frame_header, fg_color='transparent') self.frame_header_logo.pack(anchor='w', side='left') # logo self.logo_label = ctk.CTkLabel(self.frame_header_logo, text="noScribe", font=ctk.CTkFont(size=42, weight="bold")) self.logo_label.pack(padx=20, pady=[40, 0], anchor='w') # sub header self.header_label = ctk.CTkLabel(self.frame_header_logo, text=t('app_header'), font=ctk.CTkFont(size=16, weight="bold")) self.header_label.pack(padx=20, pady=[0, 20], anchor='w') # graphic self.header_graphic = ctk.CTkImage(dark_image=Image.open(os.path.join(app_dir, 'graphic_sw.png')), size=(926,119)) self.header_graphic_label = ctk.CTkLabel(self.frame_header, image=self.header_graphic, text='') self.header_graphic_label.pack(anchor='ne', side='right', padx=[30,30]) # main window self.frame_main = ctk.CTkFrame(self) self.frame_main.pack(padx=0, pady=0, anchor='nw', expand=True, fill='both') # create sidebar frame for options self.sidebar_frame = ctk.CTkFrame(self.frame_main, width=300, corner_radius=0, fg_color='transparent') self.sidebar_frame.pack(padx=0, pady=0, fill='y', expand=False, side='left') # create options scrollable frame self.scrollable_options = ctk.CTkScrollableFrame(self.sidebar_frame, width=300, corner_radius=0, fg_color='transparent') self.scrollable_options.pack(padx=0, pady=0, anchor='w', fill='both', expand=True) self.bind('', self.on_resize) # Bind the configure event of options_frame to a check_scrollbar requirement function # input audio file self.label_audio_file = ctk.CTkLabel(self.scrollable_options, text=t('label_audio_file')) self.label_audio_file.pack(padx=20, pady=[20,0], anchor='w') self.frame_audio_file = ctk.CTkFrame(self.scrollable_options, width=260, height=33, corner_radius=8, border_width=2) self.frame_audio_file.pack(padx=20, pady=[0,10], anchor='w') self.button_audio_file_name = ctk.CTkButton(self.frame_audio_file, width=200, corner_radius=8, bg_color='transparent', fg_color='transparent', hover_color=self.frame_audio_file._bg_color, border_width=0, anchor='w', text=t('label_audio_file_name'), command=self.button_audio_file_event) self.button_audio_file_name.place(x=3, y=3) self.button_audio_file = ctk.CTkButton(self.frame_audio_file, width=45, height=29, text='📂', command=self.button_audio_file_event) self.button_audio_file.place(x=213, y=2) # input transcript file name self.label_transcript_file = ctk.CTkLabel(self.scrollable_options, text=t('label_transcript_file')) self.label_transcript_file.pack(padx=20, pady=[10,0], anchor='w') self.frame_transcript_file = ctk.CTkFrame(self.scrollable_options, width=260, height=33, corner_radius=8, border_width=2) self.frame_transcript_file.pack(padx=20, pady=[0,10], anchor='w') self.button_transcript_file_name = ctk.CTkButton(self.frame_transcript_file, width=200, corner_radius=8, bg_color='transparent', fg_color='transparent', hover_color=self.frame_transcript_file._bg_color, border_width=0, anchor='w', text=t('label_transcript_file_name'), command=self.button_transcript_file_event) self.button_transcript_file_name.place(x=3, y=3) self.button_transcript_file = ctk.CTkButton(self.frame_transcript_file, width=45, height=29, text='📂', command=self.button_transcript_file_event) self.button_transcript_file.place(x=213, y=2) # Options grid self.frame_options = ctk.CTkFrame(self.scrollable_options, width=250, fg_color='transparent') self.frame_options.pack_propagate(False) self.frame_options.pack(padx=20, pady=10, anchor='w', fill='x') # self.frame_options.grid_configure .resizable(width=False, height=True) self.frame_options.grid_columnconfigure(0, weight=1, minsize=0) self.frame_options.grid_columnconfigure(1, weight=0) # Start/stop self.label_start = ctk.CTkLabel(self.frame_options, text=t('label_start')) self.label_start.grid(column=0, row=0, sticky='w', pady=[0,5]) self.entry_start = TimeEntry(self.frame_options, width=100) self.entry_start.grid(column='1', row='0', sticky='e', pady=[0,5]) self.entry_start.insert(0, '00:00:00') self.label_stop = ctk.CTkLabel(self.frame_options, text=t('label_stop')) self.label_stop.grid(column=0, row=1, sticky='w', pady=[5,10]) self.entry_stop = TimeEntry(self.frame_options, width=100) self.entry_stop.grid(column='1', row='1', sticky='e', pady=[5,10]) # language self.label_language = ctk.CTkLabel(self.frame_options, text=t('label_language')) self.label_language.grid(column=0, row=2, sticky='w', pady=5) self.option_menu_language = ctk.CTkOptionMenu(self.frame_options, width=100, values=list(languages.keys()), dynamic_resizing=False) self.option_menu_language.grid(column=1, row=2, sticky='e', pady=5) last_language = get_config('last_language', 'auto') if last_language in languages.keys(): self.option_menu_language.set(last_language) else: self.option_menu_language.set('Auto') # Whisper Model Selection class CustomCTkOptionMenu(ctk.CTkOptionMenu): # Custom version that reads available models on drop down def __init__(self, noScribe_parent, master, width = 140, height = 28, corner_radius = None, bg_color = "transparent", fg_color = None, button_color = None, button_hover_color = None, text_color = None, text_color_disabled = None, dropdown_fg_color = None, dropdown_hover_color = None, dropdown_text_color = None, font = None, dropdown_font = None, values = None, variable = None, state = tk.NORMAL, hover = True, command = None, dynamic_resizing = True, anchor = "w", **kwargs): super().__init__(master, width, height, corner_radius, bg_color, fg_color, button_color, button_hover_color, text_color, text_color_disabled, dropdown_fg_color, dropdown_hover_color, dropdown_text_color, font, dropdown_font, values, variable, state, hover, command, dynamic_resizing, anchor, **kwargs) self.noScribe_parent = noScribe_parent self.old_value = '' def _clicked(self, event=0): self.old_value = self.get() self._values = self.noScribe_parent.get_whisper_models() self._values.append('--------------------') self._values.append(t('label_add_custom_models')) self._dropdown_menu.configure(values=self._values) super()._clicked(event) def _dropdown_callback(self, value: str): if value == self._values[-2]: # divider return if value == self._values[-1]: # Add custom model # show custom model folder path = self.noScribe_parent.user_models_dir try: os_type = platform.system() if os_type == "Windows": os.startfile(path) elif os_type == "Darwin": run(["open", path]) elif os_type == "Linux": run(["xdg-open", path]) else: raise OSError(f"Unsupported operating system: {os_type}") except Exception as e: self.noScribe_parent.logn(f"Failed to open folder: {e}") else: super()._dropdown_callback(value) self.label_whisper_model = ctk.CTkLabel(self.frame_options, text=t('label_whisper_model')) self.label_whisper_model.grid(column=0, row=3, sticky='w', pady=5) models = self.get_whisper_models() self.option_menu_whisper_model = CustomCTkOptionMenu(self, self.frame_options, width=100, values=models, dynamic_resizing=False) self.option_menu_whisper_model.grid(column=1, row=3, sticky='e', pady=5) last_whisper_model = get_config('last_whisper_model', 'precise') if last_whisper_model in models: self.option_menu_whisper_model.set(last_whisper_model) elif len(models) > 0: self.option_menu_whisper_model.set(models[0]) # Mark pauses self.label_pause = ctk.CTkLabel(self.frame_options, text=t('label_pause')) self.label_pause.grid(column=0, row=4, sticky='w', pady=5) self.option_menu_pause = ctk.CTkOptionMenu(self.frame_options, width=100, values=['none', '1sec+', '2sec+', '3sec+']) self.option_menu_pause.grid(column=1, row=4, sticky='e', pady=5) self.option_menu_pause.set(get_config('last_pause', '1sec+')) # Speaker Detection (Diarization) self.label_speaker = ctk.CTkLabel(self.frame_options, text=t('label_speaker')) self.label_speaker.grid(column=0, row=5, sticky='w', pady=5) self.option_menu_speaker = ctk.CTkOptionMenu(self.frame_options, width=100, values=['none', 'auto', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10']) self.option_menu_speaker.grid(column=1, row=5, sticky='e', pady=5) self.option_menu_speaker.set(get_config('last_speaker', 'auto')) # Overlapping Speech (Diarization) self.label_overlapping = ctk.CTkLabel(self.frame_options, text=t('label_overlapping')) self.label_overlapping.grid(column=0, row=6, sticky='w', pady=5) self.check_box_overlapping = ctk.CTkCheckBox(self.frame_options, text = '') self.check_box_overlapping.grid(column=1, row=6, sticky='e', pady=5) overlapping = config.get('last_overlapping', True) if overlapping: self.check_box_overlapping.select() else: self.check_box_overlapping.deselect() # Disfluencies self.label_disfluencies = ctk.CTkLabel(self.frame_options, text=t('label_disfluencies')) self.label_disfluencies.grid(column=0, row=7, sticky='w', pady=5) self.check_box_disfluencies = ctk.CTkCheckBox(self.frame_options, text = '') self.check_box_disfluencies.grid(column=1, row=7, sticky='e', pady=5) check_box_disfluencies = config.get('last_disfluencies', True) if check_box_disfluencies: self.check_box_disfluencies.select() else: self.check_box_disfluencies.deselect() # Timestamps in text self.label_timestamps = ctk.CTkLabel(self.frame_options, text=t('label_timestamps')) self.label_timestamps.grid(column=0, row=8, sticky='w', pady=5) self.check_box_timestamps = ctk.CTkCheckBox(self.frame_options, text = '') self.check_box_timestamps.grid(column=1, row=8, sticky='e', pady=5) check_box_timestamps = config.get('last_timestamps', False) if check_box_timestamps: self.check_box_timestamps.select() else: self.check_box_timestamps.deselect() # Start control: single CTkOptionMenu styled like a button # Create a container so we can show/hide as one control self.start_button_container = ctk.CTkFrame(self.sidebar_frame, fg_color='transparent') self.start_button_container.pack(padx=[30,30], pady=[20,30], expand=False, fill='x', anchor='sw') class StartActionOptionMenu(ctk.CTkOptionMenu): """A full-width option menu that looks like a button. - Left-click (main area) runs Start immediately. - Clicking the arrow opens a dropdown with 'Send to queue'. """ def __init__(self, noScribe_parent, master, **kwargs): # Style to match CTkButton btn_theme = ctk.ThemeManager.theme.get('CTkButton', {}) kwargs.setdefault('height', 42) kwargs.setdefault('dynamic_resizing', False) kwargs.setdefault('anchor', 'center') kwargs.setdefault('fg_color', btn_theme.get('fg_color')) kwargs.setdefault('button_color', btn_theme.get('hover_color')) kwargs.setdefault('button_hover_color', btn_theme.get('hover_color')) super().__init__(master, values=['Start'], **kwargs) self.noScribe_parent = noScribe_parent try: self.set(t('start_button')) except Exception: self.set('Start') # Bind click on the text label to run Start immediately try: self._text_label.bind("", self._on_text_label_click) except Exception: pass def _clicked(self, event=None): # Open dropdown with the single queue action for non-text-label clicks try: self._values = [t('send_queue'), t('start_queue')] self._dropdown_menu.configure(values=self._values) except Exception: pass super()._clicked(event) def _dropdown_callback(self, value: str): if value == t('send_queue'): try: self.noScribe_parent.create_job(enqueue=True) finally: try: self.set(t('start_button')) except Exception: self.set('Start') elif value == t('start_queue'): try: self.noScribe_parent.create_job(enqueue=False) finally: try: self.set(t('start_button')) except Exception: self.set('Start') else: super()._dropdown_callback(value) def _on_text_label_click(self, event): try: self.noScribe_parent.create_job(enqueue=False) except Exception: pass return "break" self.start_action_menu = StartActionOptionMenu(self, self.start_button_container) self.start_action_menu.pack(padx=[0,0], fill='x', expand=True) # create queue view and log textbox self.frame_right = ctk.CTkFrame(self.frame_main, corner_radius=0, fg_color='transparent') self.frame_right.pack(padx=0, pady=0, fill='both', expand=True, side='top') self.tabview = ctk.CTkTabview(self.frame_right, anchor="nw", border_width=0, fg_color='transparent', corner_radius=0) self.tabview.pack(padx=[10,30], pady=[0,30], fill='both', expand=True, side='top') self.tab_log = self.tabview.add(t("tab_log")) self.tab_queue = self.tabview.add(t("tab_queue")) self.tabview.set(t("tab_log")) # set currently visible tab self.log_frame = ctk.CTkFrame(self.tab_log, fg_color='transparent', border_width=1, corner_radius=0) self.log_frame.pack(padx=0, pady=0, expand=True, fill='both') self.log_textbox = ctk.CTkTextbox(self.log_frame, wrap='word', state="disabled", font=("",16), text_color="lightgray", bg_color='transparent', fg_color='transparent') self.log_textbox.tag_config('highlight', foreground='darkorange') self.log_textbox.tag_config('error', foreground='yellow') self.log_textbox.pack(padx=5, pady=5, expand=True, fill='both') self.log_len = 0 self.log_progress_frame = ctk.CTkFrame(self.log_frame, fg_color='transparent') self.log_progress_frame.pack(padx=10, pady=10, fill='x', expand=False, anchor='center') self.log_edit_btn = ctk.CTkButton( self.log_progress_frame, text=t('editor_button'), width=100, fg_color=self.log_textbox._scrollbar_button_color, command=lambda: self.launch_editor() ) self.log_edit_btn.pack(side='right', padx=(0, 0), pady=0) self.log_stop_btn = ctk.CTkButton( self.log_progress_frame, text=t('stop_button'), fg_color='darkred', hover_color='darkred', width=100, state=ctk.DISABLED, command=lambda: self.on_queue_stop() ) self.log_stop_btn.pack(side='right', padx=(0, 10), pady=0) self.log_progress_bar = ctk.CTkProgressBar(self.log_progress_frame, mode='determinate', fg_color="gray17") self.log_progress_bar.set(0) self.hyperlink = HyperlinkManager(self.log_textbox._textbox) # Queue table self.queue_frame = ctk.CTkFrame(self.tab_queue, fg_color='transparent', border_width=1, corner_radius=0) self.queue_frame.pack(padx=0, pady=0, expand=True, fill='both') self.queue_frame = ctk.CTkFrame(self.queue_frame, fg_color='transparent') self.queue_frame.pack(padx=5, pady=5, fill='both', expand=True) # Scrollable frame for queue entries self.queue_scrollable = ctk.CTkScrollableFrame(self.queue_frame, bg_color='transparent', fg_color='transparent') self.queue_scrollable.pack(fill='both', expand=True, padx=0, pady=(0, 0)) # Controls row at the bottom of the queue tab self.queue_controls_frame = ctk.CTkFrame(self.queue_frame, fg_color='transparent') self.queue_controls_frame.pack(fill='x', side='bottom', padx=0, pady=(0, 0)) self.queue_edit_btn = ctk.CTkButton( self.queue_controls_frame, text=t('editor_button'), width=100, fg_color=self.log_textbox._scrollbar_button_color, command=lambda: self.launch_editor() ) self.queue_edit_btn.pack(side='right', padx=(0, 5), pady=5) self.queue_stop_btn = ctk.CTkButton( self.queue_controls_frame, text=t('stop_button'), fg_color='darkred', hover_color='darkred', width=100, command=lambda: self.on_queue_stop() ) self.queue_stop_btn.pack(side='right', padx=(0, 10), pady=5) self.queue_run_btn = ctk.CTkButton( self.queue_controls_frame, text=t('queue_run_button'), width=100, command=lambda: self.on_queue_run() ) self.queue_run_btn.pack(side='right', padx=(0, 10), pady=5) # Mapping for diff-based queue rows (job_key -> widgets) self.queue_row_widgets = {} self.update_queue_table() self.update_scrollbar_visibility() self.log(translation_error, 'error') # will be empty if no error occurred self.logn(t('welcome_message'), 'highlight') self.log(t('welcome_credits', v=app_version, y=app_year)) self.logn('https://github.com/kaixxx/noScribe', link='https://github.com/kaixxx/noScribe#readme') self.logn(t('welcome_instructions')) # check for new releases if get_config('check_for_update', 'True') == 'True': try: latest_release = json.loads(urllib.request.urlopen( urllib.request.Request('https://api.github.com/repos/kaixxx/noScribe/releases/latest', headers={'Accept': 'application/vnd.github.v3+json'},), timeout=2).read()) latest_release_version = str(latest_release['tag_name']).lstrip('v') if version_higher(latest_release_version, app_version, subversion_level=1) == 1: # Only major release changes like 0.6 ->_0.7 (subversion_level 1) are indicated in the # UI, not smaller subversion like 0.7.3 -> 0.7.4 self.logn(t('new_release', v=latest_release_version), 'highlight') self.logn(str(latest_release['body'])) # release info self.log(t('new_release_download')) self.logn(str(latest_release['html_url']), link=str(latest_release['html_url'])) self.logn() except: pass # Events and Methods def get_whisper_models(self): self.whisper_model_paths = {} def collect_models(dir): for entry in os.listdir(dir): entry_path = os.path.join(dir, entry) if os.path.isdir(entry_path): if entry in self.whisper_model_paths: self.logn(t('err_invalid_model', entry), 'error') else: self.whisper_model_paths[entry]=entry_path # collect system models: collect_models(os.path.join(app_dir, 'models')) # collect user defined models: collect_models(self.user_models_dir) return list(self.whisper_model_paths.keys()) def on_whisper_model_selected(self, value): print(self.option_menu_whisper_model.old_value) print(value) def on_resize(self, event): self.update_scrollbar_visibility() def update_scrollbar_visibility(self): # Get the size of the scroll region and current canvas size canvas = self.scrollable_options._parent_canvas scroll_region_height = canvas.bbox("all")[3] canvas_height = canvas.winfo_height() scrollbar = self.scrollable_options._scrollbar if scroll_region_height > canvas_height: scrollbar.grid() else: scrollbar.grid_remove() # Hide the scrollbar if not needed def update_queue_table(self): """Update the queue table by diffing: update existing rows, add new ones, remove missing.""" if getattr(self, '_headless', False): return current_keys = [] for i in range(len(self.queue.jobs)): job = self.queue.jobs[i] job_key = id(job) current_keys.append(job_key) # Compute display values audio_name = os.path.basename(job.audio_file) if job.audio_file else "No file" status_color = "lightgray" job_tooltip = '' if job.status == JobStatus.WAITING: status_color = "gray" job_tooltip = t('job_tt_waiting') elif job.status in [JobStatus.AUDIO_CONVERSION, JobStatus.SPEAKER_IDENTIFICATION, JobStatus.TRANSCRIPTION]: status_color = "orange" audio_name = '\u23F5 ' + audio_name job_tooltip = t('job_tt_running') elif job.status == JobStatus.CANCELING: status_color = "yellow" audio_name = '\u23F5 ' + audio_name job_tooltip = t('job_tt_canceling') elif job.status == JobStatus.CANCELED: status_color = "yellow" job_tooltip = t('job_tt_canceled') elif job.status == JobStatus.FINISHED: status_color = "lightgreen" job_tooltip = t('job_tt_finished') elif job.status == JobStatus.ERROR: status_color = "yellow" msg = job.error_message if job.error_message else '' job_tooltip = t('job_tt_error', error_msg=msg) # Append a real, concise summary of the job's options try: job_tooltip += '\n\n' + job.format_summary() except Exception: pass status_text = t(str(job.status.value)) btn_color = ctk.ThemeManager.theme['CTkScrollbar']['button_color'] if hasattr(self, 'queue_row_widgets') and job_key in self.queue_row_widgets: # Update existing row row = self.queue_row_widgets[job_key] # Update text directly on the JobEntryFrame canvas row['frame'].set_name_text(audio_name) row['frame'].set_status_text(status_text, status_color) # Update progress bar visibility based on job status is_processing = job.status in [JobStatus.AUDIO_CONVERSION, JobStatus.SPEAKER_IDENTIFICATION, JobStatus.TRANSCRIPTION] if is_processing: row['frame'].set_progress(job.progress, show_progress=True) else: row['frame'].set_progress(0.0, show_progress=False) # Add repeat button for status ERROR and CANCELED only try: if job.status in [JobStatus.ERROR, JobStatus.CANCELED]: if 'repeat_btn' not in row or row['repeat_btn'] is None: repeat_btn = ctk.CTkButton( row['frame'], text='⟲', width=24, height=20, fg_color=btn_color, hover_color='darkred', command=lambda j=job: self._on_queue_row_repeat(j) ) repeat_btn.pack(side='right', padx=(0, 4), pady=5) row['repeat_btn'] = repeat_btn row['repeat_tt'] = CTkToolTip(repeat_btn, text=t('queue_tt_repeat_job')) else: if not row['repeat_btn'].winfo_ismapped(): row['repeat_btn'].pack(side='right', padx=(0, 4), pady=2) row['repeat_btn'].configure(state=ctk.NORMAL, command=lambda j=job: self._on_queue_row_repeat(j)) else: # hide the repeat button if it exists for other states if 'repeat_btn' in row and row['repeat_btn'] is not None: if row['repeat_btn'].winfo_ismapped(): row['repeat_btn'].pack_forget() except Exception: pass # Cancel button for running jobs if 'cancel_btn' in row and row['cancel_btn'] is not None: try: row['cancel_btn'].configure(command=lambda j=job: self._on_queue_row_action(j)) # Color: red if running, gray otherwise if job.status in [JobStatus.AUDIO_CONVERSION, JobStatus.SPEAKER_IDENTIFICATION, JobStatus.TRANSCRIPTION]: row['cancel_btn'].configure(fg_color='darkred', hover_color='darkred') else: row['cancel_btn'].configure(fg_color=btn_color, hover_color='darkred') # Make sure it is visible if not row['cancel_btn'].winfo_ismapped(): row['cancel_btn'].pack(side='right', padx=(0, 6), pady=2) # Update tooltip on the X button to reflect current status if job.status == JobStatus.WAITING: cancel_tt_text = t('queue_tt_remove_waiting') elif job.status in [JobStatus.AUDIO_CONVERSION, JobStatus.SPEAKER_IDENTIFICATION, JobStatus.TRANSCRIPTION, JobStatus.CANCELING]: cancel_tt_text = t('queue_tt_remove_entry') else: cancel_tt_text = t('queue_tt_remove_entry') if 'cancel_tt' in row and row['cancel_tt'] is not None: try: row['cancel_tt'].set_text(cancel_tt_text) except Exception: pass except Exception: pass # Add "open partial" button for failed jobs with partial HTML transcript try: if job.status in [JobStatus.ERROR, JobStatus.CANCELED] and getattr(job, 'has_partial_transcript', False): if 'partial_btn' not in row or row['partial_btn'] is None: partial_btn = ctk.CTkButton( row['frame'], text='✔', width=24, height=20, fg_color=btn_color, hover_color='darkred', command=lambda j=job: self._on_queue_row_open_partial(j) ) partial_btn.pack(side='right', padx=(0, 4), pady=5) row['partial_btn'] = partial_btn row['partial_tt'] = CTkToolTip(partial_btn, text=t('queue_tt_open_partial_job')) else: if not row['partial_btn'].winfo_ismapped(): row['partial_btn'].pack(side='right', padx=(0, 4), pady=2) row['partial_btn'].configure(state=ctk.NORMAL, command=lambda j=job: self._on_queue_row_open_partial(j)) else: # hide the partial button otherwise if 'partial_btn' in row and row['partial_btn'] is not None and row['partial_btn'].winfo_ismapped(): row['partial_btn'].pack_forget() except Exception: pass # Add edit button for finished jobs try: if job.status == JobStatus.FINISHED: if 'edit_btn' not in row or row['edit_btn'] is None: edit_btn = ctk.CTkButton( row['frame'], text='✔', width=24, height=20, fg_color=btn_color, hover_color='darkred', command=lambda j=job: self._on_queue_row_edit(j) ) edit_btn.pack(side='right', padx=(0, 4), pady=5) row['edit_btn'] = edit_btn row['edit_tt'] = CTkToolTip(edit_btn, text=t('queue_tt_edit_job')) else: if not row['edit_btn'].winfo_ismapped(): row['edit_btn'].pack(side='right', padx=(0, 4), pady=2) row['edit_btn'].configure(state=ctk.NORMAL, command=lambda j=job: self._on_queue_row_edit(j)) else: # hide the edit button if it exists for other states if 'edit_btn' in row and row['edit_btn'] is not None: if row['edit_btn'].winfo_ismapped(): row['edit_btn'].pack_forget() except Exception: pass row['status'] = job.status row['tooltip_text'] = job_tooltip # Update tooltip messages if available if 'tooltips' in row: for tt in row['tooltips']: tt.set_text(job_tooltip) else: # Create new row with progress bar background fg_color = ctk.ThemeManager.theme['CTkSegmentedButton']['unselected_color'][1] entry_frame = JobEntryFrame(self.queue_scrollable, progress=job.progress, progress_color=None, fg_color=fg_color) entry_frame.pack(fill='x', padx=(0, 5), pady=2) # Set the text directly on the JobEntryFrame canvas entry_frame.set_name_text(audio_name) entry_frame.set_status_text(status_text, status_color) # Set progress bar visibility based on job status is_processing = job.status in [JobStatus.AUDIO_CONVERSION, JobStatus.SPEAKER_IDENTIFICATION, JobStatus.TRANSCRIPTION] if is_processing: entry_frame.set_progress(job.progress, show_progress=True) else: entry_frame.set_progress(0.0, show_progress=False) # Add small action buttons to job row # X Button cancel_btn = ctk.CTkButton( entry_frame, text='X', width=24, height=20, fg_color=('darkred' if job.status in [JobStatus.AUDIO_CONVERSION, JobStatus.SPEAKER_IDENTIFICATION, JobStatus.TRANSCRIPTION] else btn_color), hover_color=('darkred'), command=lambda j=job: self._on_queue_row_action(j) ) cancel_btn.pack(side='right', padx=(0, 6), pady=5) # Tooltip for X button per status if job.status == JobStatus.WAITING: cancel_tt_text = t('queue_tt_remove_waiting') elif job.status in [JobStatus.AUDIO_CONVERSION, JobStatus.SPEAKER_IDENTIFICATION, JobStatus.TRANSCRIPTION]: cancel_tt_text = t('queue_tt_cancel_running') else: cancel_tt_text = t('queue_tt_remove_entry') cancel_tt = CTkToolTip(cancel_btn, text=cancel_tt_text) # Repeat button (job status canceled or error only) repeat_btn = None repeat_tt = None if job.status in [JobStatus.ERROR, JobStatus.CANCELED]: repeat_btn = ctk.CTkButton( entry_frame, text='⟲', width=24, height=20, fg_color=btn_color, hover_color=('darkred'), command=lambda j=job: self._on_queue_row_repeat(j) ) repeat_btn.pack(side='right', padx=(0, 4), pady=5) repeat_tt = CTkToolTip(repeat_btn, text=t('queue_tt_repeat_job')) # Open partial button (failed job with partial transcript, HTML only) partial_btn = None partial_tt = None if job.status in [JobStatus.ERROR, JobStatus.CANCELED] and getattr(job, 'has_partial_transcript', False): partial_btn = ctk.CTkButton( entry_frame, text='✔', width=24, height=20, fg_color=btn_color, hover_color='darkred', command=lambda j=job: self._on_queue_row_open_partial(j) ) partial_btn.pack(side='right', padx=(0, 4), pady=5) partial_tt = CTkToolTip(partial_btn, text=t('queue_tt_open_partial_job')) # Edit button (finished jobs only) edit_btn = None edit_tt = None if job.status == JobStatus.FINISHED: edit_btn = ctk.CTkButton( entry_frame, text='✔', width=24, height=20, fg_color=btn_color, hover_color='darkred', command=lambda j=job: self._on_queue_row_edit(j) ) edit_btn.pack(side='right', padx=(0, 4), pady=5) edit_tt = CTkToolTip(edit_btn, text=t('queue_tt_edit_job')) # Row tooltip (create once per row) tt_frame = CTkToolTip(entry_frame, text=job_tooltip) #, bg_color='gray') if not hasattr(self, 'queue_row_widgets'): self.queue_row_widgets = {} self.queue_row_widgets[job_key] = { 'frame': entry_frame, 'status': job.status, 'tooltip_text': job_tooltip, 'tooltips': [tt_frame], 'cancel_btn': cancel_btn, 'cancel_tt': cancel_tt, 'repeat_btn': repeat_btn, 'repeat_tt': repeat_tt, 'partial_btn': partial_btn, 'partial_tt': partial_tt, 'edit_btn': edit_btn, 'edit_tt': edit_tt } # Remove rows no longer present if hasattr(self, 'queue_row_widgets'): to_remove = [key for key in list(self.queue_row_widgets.keys()) if key not in current_keys] for key in to_remove: row = self.queue_row_widgets.pop(key) if row['frame'].winfo_exists(): row['frame'].destroy() # Update queue tab title new_name = f'{t("tab_queue")} ({len(self.queue.jobs) - len(self.queue.get_waiting_jobs()) - len(self.queue.get_running_jobs())}/{len(self.queue.jobs)})' old_name = self.tabview._name_list[1] if new_name != old_name: self.tabview.rename(old_name, new_name) if self.tabview.get() == old_name: self.tabview.set(new_name) # Update controls state try: self.update_queue_controls() except Exception: pass def update_queue_controls(self): """Enable/disable and label the queue control buttons based on state.""" if getattr(self, '_headless', False): return try: has_running = len(self.queue.get_running_jobs()) > 0 has_pending = self.queue.has_pending_jobs() # Run button: enabled only if there are pending jobs and nothing is running self.queue_run_btn.configure(text=t('queue_run_button')) if (not has_running) and has_pending: self.queue_run_btn.configure(state=ctk.NORMAL) else: self.queue_run_btn.configure(state=ctk.DISABLED) # Stop button: enabled if something is running or pending if has_running or has_pending: self.queue_stop_btn.configure(state=ctk.NORMAL) else: self.queue_stop_btn.configure(state=ctk.DISABLED) except Exception: pass def on_queue_run(self): """Start processing pending jobs if idle.""" try: has_running = len(self.queue.get_running_jobs()) > 0 has_pending = self.queue.has_pending_jobs() if (not has_running) and has_pending: wkr = Thread(target=self.transcription_worker, args=(), daemon=True) self._worker_threads.append(wkr) wkr.start() self.update_queue_controls() except Exception: pass def on_queue_stop(self, ask_before_canceling=True) -> bool: """Ask for confirmation, then cancel running job and mark all pending jobs as canceled. Returns False if user does not confirm cancelation.""" try: if (ask_before_canceling and (self.queue.is_running() or self.queue.has_pending_jobs()) and not tk.messagebox.askyesno(title='noScribe', message=t('queue_cancel_all_confirm'))): return False # Mark waiting jobs as canceled immediately for job in self.queue.get_waiting_jobs(): try: job.set_canceled(t('err_user_cancelation')) except Exception: job.set_canceled('Canceled by user') # If something is running, reflect canceling state and signal cancel for job in self.queue.get_running_jobs(): if job.status != JobStatus.CANCELING: job.status = JobStatus.CANCELING self.cancel = True self._cancel_job_only = False self.update_queue_table() except Exception: pass return True def _on_queue_row_action(self, job: TranscriptionJob): """Handle click on the small X button for a job row.""" try: if job.status == JobStatus.WAITING: # Confirm deletion of waiting job if tk.messagebox.askyesno(title='noScribe', message=t('queue_remove_waiting')): try: self.queue.jobs.remove(job) except ValueError: pass self.update_queue_table() elif job.status in [JobStatus.AUDIO_CONVERSION, JobStatus.SPEAKER_IDENTIFICATION, JobStatus.TRANSCRIPTION]: # Confirm cancel of running job if tk.messagebox.askyesno(title='noScribe', message=t('transcription_canceled')): self.logn() self.logn(t('start_canceling')) self.update() # reflect canceling state in queue immediately try: job.status = JobStatus.CANCELING self.update_queue_table() except Exception: pass # Only cancel the current job, not the entire queue self._cancel_job_only = True self.cancel = True # Try to terminate active whisper subprocess if present try: if getattr(self, "_mp_proc", None) is not None and self._mp_proc.is_alive(): try: self._mp_proc.terminate() except Exception: pass try: self._mp_proc.join(timeout=0.3) except Exception: pass try: self._mp_proc.close() except Exception: pass finally: self._mp_proc = None self._mp_queue = None else: # Finished, canceling or error -> remove from list after confirmation if tk.messagebox.askyesno(title='noScribe', message=t('queue_remove_entry')): try: self.queue.jobs.remove(job) except ValueError: pass self.update_queue_table() except Exception as e: # Log any UI handling error silently self.logn(f'Queue action error: {e}', 'error') def _on_queue_row_repeat(self, job: TranscriptionJob): """Repeat a job: set to WAITING if others are running, else start immediately.""" try: if job.status not in [JobStatus.ERROR, JobStatus.CANCELED]: return # Confirm override if output file conflicts with other jobs (ignore this job itself) if not self.queue.confirm_output_override(job.transcript_file, ignore_job=job): return # reset job timing and messages job.error_message = None job.error_tb = None job.started_at = None job.finished_at = None job.status = JobStatus.WAITING self.update_queue_table() has_running = len(self.queue.get_running_jobs()) > 0 if has_running: return # no running jobs: start this one immediately try: start_idx = self.queue.jobs.index(job) except ValueError: start_idx = None if start_idx is not None: wkr = Thread(target=self.transcription_worker, kwargs={"start_job_index": start_idx}, daemon=True) self._worker_threads.append(wkr) wkr.start() except Exception as e: self.logn(f'Queue repeat error: {e}', 'error') def _on_queue_row_edit(self, job: TranscriptionJob): self.openLink(f'file://{job.transcript_file}') def _on_queue_row_open_partial(self, job: TranscriptionJob): """Open the partial transcript file (HTML in editor, TXT/VTT via default app).""" try: path = getattr(job, 'transcript_file', '') or '' if not path or not os.path.exists(path): try: self.logn(t('err_partial_not_found'), 'error') except Exception: pass try: tk.messagebox.showerror(title='noScribe', message=t('err_partial_not_found')) except Exception: pass return try: self.logn(t('log_open_partial', file=path)) except Exception: pass self.openLink(f'file://{path}') except Exception: pass def launch_editor(self, file=''): # Launch the editor in a separate process so that in can stay running even if noScribe quits. # Source: https://stackoverflow.com/questions/13243807/popen-waiting-for-child-process-even-when-the-immediate-child-has-terminated/13256908#13256908 # set system/version dependent "start_new_session" analogs if file == '': # get last finished job (if any) jobs = self.queue.get_finished_jobs() if len(jobs) > 0: file = jobs[-1].transcript_file if file == '': # no file or finished job to open if not tk.messagebox.askyesno(title='noScribe', message=t('err_editor_no_file')): return ext = os.path.splitext(file)[1][1:] if file != '' and ext != 'html': # wrong format file = '' if not tk.messagebox.askyesno(title='noScribe', message=t('err_editor_invalid_format')): return program: str = None if platform.system() == 'Windows': program = os.path.join(app_dir, 'noScribeEdit', 'noScribeEdit.exe') elif platform.system() == "Darwin": # = MAC # use local copy in development, installed one if used as an app: program = os.path.join(app_dir, 'noScribeEdit', 'noScribeEdit') if not os.path.exists(program): program = os.path.join(os.sep, 'Applications', 'noScribeEdit.app', 'Contents', 'MacOS', 'noScribeEdit') elif platform.system() == "Linux": if hasattr(sys, "_MEIPASS"): program = os.path.join(sys._MEIPASS, 'noScribeEdit', "noScribeEdit") else: program = os.path.join(app_dir, 'noScribeEdit', "noScribeEdit.py") kwargs = {} if platform.system() == 'Windows': # from msdn [1] CREATE_NEW_PROCESS_GROUP = 0x00000200 # note: could get it from subprocess DETACHED_PROCESS = 0x00000008 # 0x8 | 0x200 == 0x208 kwargs.update(creationflags=DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP) else: # should work on all POSIX systems, Linux and macOS kwargs.update(start_new_session=True) if program is not None and os.path.exists(program): popenargs = [program] if platform.system() == "Linux" and not hasattr(sys, "_MEIPASS"): # only do this, if you run as python script; Linux python vs. executable needs refinement popenargs = [sys.executable, program] if file != '': popenargs.append(file) Popen(popenargs, **kwargs) else: self.logn(t('err_noScribeEdit_not_found'), 'error') def openLink(self, link: str) -> None: if link.startswith('file://') and link.endswith('.html'): self.launch_editor(link[7:]) else: webbrowser.open(link) def log(self, txt: str = '', tags: list = [], where: str = 'both', link: str = '', tb: str = '') -> None: """ Log to main window (where can be 'screen', 'file', or 'both') tb = formatted traceback of the error, only logged to file """ # Handle screen logging if requested and textbox exists if where != 'file': if txt[:-1] != t('welcome_instructions'): print(txt, end='') if not getattr(self, '_headless', False) and hasattr(self, 'log_textbox') and self.log_textbox.winfo_exists(): try: self.log_textbox.configure(state=tk.NORMAL) # To prevent slowing down the UI, limit the content of log_textbox to max 5000 characters if self.log_len > 5000: self.log_textbox.delete("1.0", f"1.0 + {self.log_len - 3000} chars") # keep the last 3000 self.log_len = 3000 if link: tags = tags + self.hyperlink.add(partial(self.openLink, link)) self.log_textbox.insert(tk.END, txt, tags) self.log_textbox.yview_moveto(1) # Scroll to last line self.log_len += len(txt) # Schedule disabling the textbox in the main thread self.log_textbox.after(0, lambda: self.log_textbox.configure(state=tk.DISABLED)) except Exception as e: # Log screen errors only to file to prevent recursion if where == 'both': self.log(f"Error updating log_textbox: {str(e)}\nOriginal error: {txt}", tags='error', where='file', tb=tb) # Handle file logging if requested if where != 'screen' and self.log_file and not self.log_file.closed: try: if tags == 'error': txt = f'ERROR: {txt}' if tb != '': txt = f'{txt}\nTraceback:\n{tb}' self.log_file.write(txt) self.log_file.flush() except Exception as e: # If we get here, both screen and file logging failed # As a last resort, print to stderr to not lose the error import sys print(f"Critical error - both screen and file logging failed: {str(e)}\nOriginal error: {txt}\nOriginal traceback:\n{tb}", file=sys.stderr) def logn(self, txt: str = '', tags: list = [], where: str = 'both', link:str = '', tb: str = '') -> None: """ Log with a newline appended """ self.log(f'{txt}\n', tags, where, link, tb) def logr(self, txt: str = '', tags: list = [], where: str = 'both', link:str = '', tb: str = '') -> None: """ Replace the last line of the log """ if where != 'file' and not getattr(self, '_headless', False) and hasattr(self, 'log_textbox') and self.log_textbox.winfo_exists(): self.log_textbox.configure(state=ctk.NORMAL) tmp_txt = self.log_textbox.get("end-1c linestart", "end-1c") self.log_textbox.delete("end-1c linestart", "end-1c") self.log_len -= len(tmp_txt) self.log(txt, tags, where, link, tb) def create_default_transcript_names(self, dir=None): self.transcript_files_list = [] if 'default_filetype' not in config: config['default_filetype'] = 'html' # Collect audio file names. for f in self.audio_files_list: f = Path(f) if dir: self.transcript_files_list.append(Path(dir) / f'{f.stem}.{config["default_filetype"]}') else: self.transcript_files_list.append(f'{f.with_name(f.stem)}.{config["default_filetype"]}') # Ensure to not override anything and that we have unique file names. # Make sure here that every file is a `Path`. self.transcript_files_list = utils.create_unique_filenames([Path(x) for x in self.transcript_files_list]) if len(self.transcript_files_list) > 1: self.button_transcript_file_name.configure(text=t('multiple_audio_files')) elif len(self.transcript_files_list) == 1: self.button_transcript_file_name.configure(text=self.transcript_files_list[0].name) else: self.button_transcript_file_name.configure(text='') self.logn() log_msg = t('log_transcript_filename') for fn in self.transcript_files_list: log_msg += f'\n{fn}' self.logn(log_msg) def button_audio_file_event(self): fn = tk.filedialog.askopenfilename(initialdir=os.path.dirname(self.audio_files_list[0] if len(self.audio_files_list) > 0 else ''), initialfile=" ".join(f'"{os.path.basename(path)}"' for path in self.audio_files_list), multiple=True) if fn and len(fn) > 0: self.audio_files_list = fn msg = t('log_audio_file_selected') for f in fn: msg += f'\n{f}' self.logn() self.logn(msg) if len(fn) == 1: self.button_audio_file_name.configure(text=os.path.basename(self.audio_files_list[0])) else: self.button_audio_file_name.configure(text=t('multiple_audio_files')) self.create_default_transcript_names() def button_transcript_file_event(self): if len(self.audio_files_list) == 0: # select audio first tk.messagebox.showerror(title='noScribe', message=t('err_no_audio_file')) return if len(self.transcript_files_list) > 0: _initialdir = os.path.dirname(self.transcript_files_list[0]) _initialfile = os.path.basename(self.transcript_files_list[0]) else: _initialdir = '' _initialfile = '' if not ('last_filetype' in config): config['last_filetype'] = 'html' filetypes = [ ('noScribe Transcript','*.html'), ('Text only','*.txt'), ('WebVTT Subtitles (also for EXMARaLDA)', '*.vtt') ] for i, ft in enumerate(filetypes): if ft[1] == f'*.{config["last_filetype"]}': filetypes.insert(0, filetypes.pop(i)) break if len(self.audio_files_list) > 1: # multiple audio files, select an output directory tk.messagebox.showinfo(title='noScribe', message=t('output_dir_selection')) dir = tk.filedialog.askdirectory(title="noScribe", initialdir=_initialdir) if dir: self.create_default_transcript_names(dir) else: return else: # single audio file, select an output file name fn = tk.filedialog.asksaveasfilename(initialdir=_initialdir, initialfile=_initialfile, filetypes=filetypes, defaultextension=config['last_filetype']) if fn: file_ext = os.path.splitext(fn)[1][1:].lower() if not file_ext in ['html', 'txt', 'vtt']: tk.messagebox.showerror(title='noScribe', message=t('err_unsupported_output_format', file_type=file_ext)) return self.transcript_files_list = [fn] self.button_transcript_file_name.configure(text=os.path.basename(fn)) config['last_filetype'] = file_ext else: return self.logn() log_msg = t('log_transcript_filename') for fn in self.transcript_files_list: log_msg += f'\n{fn}' self.logn(log_msg) def set_progress(self, step, value, speaker_detection='none'): """ Update state of the progress bar """ if getattr(self, '_headless', False): return progr = -1 if step == 1: progr = value * 0.05 / 100 elif step == 2: progr = 0.05 # (step 1) progr = progr + (value * 0.45 / 100) elif step == 3: if speaker_detection != 'none': progr = 0.05 + 0.45 # (step 1 + step 2) progr_factor = 0.5 else: progr = 0.05 # (step 1) progr_factor = 0.95 progr = progr + (value * progr_factor / 100) if progr >= 1: progr = 0.99 # whisper sometimes still needs some time to finish even if the progress is already at 100%. This can be confusing, so we never go above 99%... if abs(progr - self.current_progress) < 0.01: # stop updating progress bars if the change is less than 1% (0.01) return self.current_progress = progr # Update log_progress_bar if self.current_progress > 0: self.log_progress_bar.set(self.current_progress) if not self.log_progress_bar.winfo_ismapped(): self.log_progress_bar.pack(padx=(0,10), pady=0, expand=True, fill='x', anchor='sw', side='left') self.log_stop_btn.configure(state=ctk.NORMAL) else: self.log_progress_bar.set(0) if self.log_progress_bar.winfo_ismapped(): self.log_progress_bar.pack_forget() self.log_stop_btn.configure(state=ctk.DISABLED) # Update progress of currently running job in queue table if progr >= 0: running_jobs = self.queue.get_running_jobs() if running_jobs: current_job = running_jobs[0] # Get the first running job current_job.progress = progr # Update the progress bar background for this job job_key = id(current_job) if hasattr(self, 'queue_row_widgets') and job_key in self.queue_row_widgets: row = self.queue_row_widgets[job_key] if hasattr(row['frame'], 'set_progress'): row['frame'].set_progress(progr) def collect_transcription_options(self) -> TranscriptionQueue: """Collect all transcription options from UI and config and creates a TranscriptionQueue for each audio file""" # Validate required inputs if len(self.audio_files_list) == 0: raise ValueError(t('err_no_audio_file')) if len(self.transcript_files_list) == 0: raise ValueError(t('err_no_transcript_file')) # Parse time range from UI start_time = None val = self.entry_start.get() if val != '': start_time = utils.str_to_ms(val) stop_time = None val = self.entry_stop.get() if val != '': stop_time = utils.str_to_ms(val) # Get whisper model path sel_whisper_model = self.option_menu_whisper_model.get() if sel_whisper_model not in self.whisper_model_paths.keys(): raise FileNotFoundError(f"The whisper model '{sel_whisper_model}' does not exist.") whisper_model_path = self.whisper_model_paths[sel_whisper_model] queue = TranscriptionQueue() if len(self.audio_files_list) != len(self.transcript_files_list): self.create_default_transcript_names() for i in range(len(self.audio_files_list)): job = create_transcription_job( audio_file=self.audio_files_list[i], transcript_file=self.transcript_files_list[i], start_time=start_time, stop_time=stop_time, language_name=self.option_menu_language.get(), whisper_model_name=whisper_model_path, # Pass the full path speaker_detection=self.option_menu_speaker.get(), overlapping=self.check_box_overlapping.get(), timestamps=self.check_box_timestamps.get(), disfluencies=self.check_box_disfluencies.get(), pause=self.option_menu_pause.get(), # Pass string value cli_mode=False ) # Handle VTT format warnings in GUI mode if job.file_ext == 'vtt' and (job.pause > 0 or job.overlapping or job.timestamps): self.logn() self.logn(t('err_vtt_invalid_options'), 'error') queue.add_job(job) return queue def transcription_worker(self, start_job_index=None): """Process transcription jobs from the queue""" queue_start_time = datetime.datetime.now() queue_jobs_processed = 0 job = None self.cancel = False try: # Log queue summary summary = self.queue.get_queue_summary() self.logn() self.logn(t('queue_start'), 'highlight') pending = len(self.queue.get_waiting_jobs()) if pending > 0: self.logn(t('queue_start_jobs', total=pending)) else: self.logn(t('queue_none_waiting')) # Process each job in the queue while self.queue.has_pending_jobs(): # If global cancel was requested (via Stop button), cancel all waiting jobs if self.cancel and not self._cancel_job_only: for job in self.queue.get_waiting_jobs(): job.set_canceled(t('err_user_cancelation')) self.update_queue_table() break # Get next job job = None if start_job_index and start_job_index < len(self.queue.jobs): job = self.queue.jobs[start_job_index] if job.status != JobStatus.WAITING: job = None if job is None: job = self.queue.get_next_waiting_job() if not job: break # Process the job try: self.logn() self.logn(t('start_job', audio_file=os.path.basename(job.audio_file)), 'highlight') # Process single job self._process_single_job(job) queue_jobs_processed += 1 job.set_finished() self.update_queue_table() except Exception as e: # Distinguish cancellation from real errors error_msg = job.error_message or str(e) if str(e) == t('err_user_cancelation') or self.cancel: job.set_canceled(t('err_user_cancelation')) else: job.set_error(error_msg) self.update_queue_table() self.logn(error_msg, 'error') traceback_str = job.error_tb or traceback.format_exc() self.logn(f"Job error details: {traceback_str}", where='file') print(f"Job error details: {traceback_str}") finally: # If we were canceling only the current job, reset flags after it stops if self._cancel_job_only: self.cancel = False self._cancel_job_only = False # Log final summary final_summary = self.queue.get_queue_summary() self.logn() self.logn(t('queue_complete'), 'highlight') self.logn(t('total_jobs', total=final_summary['total'])) self.logn(t('completed', finished=final_summary['finished'])) self.logn(t('failed', errors=final_summary['errors'])) self.logn(t('canceled_summary', canceled=final_summary['canceled'])) # Log total processing time total_time = datetime.datetime.now() - queue_start_time total_seconds = "{:02d}".format(int(total_time.total_seconds() % 60)) total_time_str = f'{int(total_time.total_seconds() // 60)}:{total_seconds}' self.logn(t('processing_time', total_time_str=total_time_str)) # open editor if only a single file was processed if not getattr(self, '_headless', False) \ and queue_jobs_processed == 1 \ and job \ and job.file_ext == 'html' \ and job.status == JobStatus.FINISHED \ and get_config('auto_edit_transcript', 'True') == 'True': self.launch_editor(job.transcript_file) elif queue_jobs_processed > 1 and not getattr(self, '_headless', False): # if more than one job has been processed, switch to queue tab for an overview self.tabview.set(self.tabview._name_list[1]) except Exception as e: self.logn(f"Queue processing error: {str(e)}", 'error') traceback_str = traceback.format_exc() self.logn(f"Queue error details: {traceback_str}", where='file') finally: # Hide progress self.set_progress(0, 0) try: self.update_queue_controls() except Exception: pass def _process_single_job(self, job: TranscriptionJob): """Process a single transcription job""" proc_start_time = datetime.datetime.now() job.set_running() self.update_queue_table() tmpdir = TemporaryDirectory('noScribe') tmp_audio_file = os.path.join(tmpdir.name, 'tmp_audio.wav') orig_transcript_file = job.transcript_file try: # Create option info string for logging option_info = '' if job.start > 0: option_info += f'{t("label_start")} {utils.ms_to_str(job.start)} | '.replace(':', '꞉') # replace the normal colon here in the header with a special character so that MAXQDA does not misinterpret it as a time marker in the transcript. if job.stop > 0: option_info += f'{t("label_stop")} {utils.ms_to_str(job.stop)} | '.replace(':', '꞉') option_info += f'{t("label_language")} {job.language_name} ({languages[job.language_name]}) | ' option_info += f'{t("label_speaker")} {job.speaker_detection} | ' option_info += f'{t("label_overlapping")} {job.overlapping} | ' option_info += f'{t("label_timestamps")} {job.timestamps} | ' option_info += f'{t("label_disfluencies")} {job.disfluencies} | ' option_info += f'{t("label_pause")} {job.pause}' # Create log file if not os.path.exists(f'{config_dir}/log'): os.makedirs(f'{config_dir}/log') self.log_file = open(f'{config_dir}/log/{Path(job.transcript_file).stem}.log', 'w', encoding="utf-8") # Log job configuration self.logn(f'whisper beam size: {job.whisper_beam_size}', where='file') self.logn(f'whisper temperature: {job.whisper_temperature}', where='file') self.logn(f'whisper compute type: {job.whisper_compute_type}', where='file') self.logn(f'timestamp_interval: {job.timestamp_interval}', where='file') self.logn(f'timestamp_color: {job.timestamp_color}', where='file') # Log CPU capabilities self.logn("=== CPU FEATURES ===", where="file") if platform.system() == 'Windows': self.logn("System: Windows", where="file") for key, value in cpufeature.CPUFeature.items(): self.logn(' {:24}: {}'.format(key, value), where="file") elif platform.system() == "Darwin": # = MAC self.logn(f"System: MAC {platform.machine()}", where="file") """ if platform.mac_ver()[0] >= '12.3': # MPS needs macOS 12.3+ if job.pyannote_xpu == 'mps': self.logn("macOS version >= 12.3:\nUsing MPS (with PYTORCH_ENABLE_MPS_FALLBACK enabled)", where="file") elif job.pyannote_xpu == 'cpu': self.logn("macOS version >= 12.3:\nUser selected to use CPU (results will be better, but you might wanna make yourself a coffee)", where="file") else: self.logn("macOS version >= 12.3:\nInvalid option for 'pyannote_xpu' in config.yml (should be 'mps' or 'cpu')\nYou might wanna change this\nUsing MPS anyway (with PYTORCH_ENABLE_MPS_FALLBACK enabled)", where="file") else: self.logn("macOS version < 12.3:\nMPS not available: Using CPU\nPerformance might be poor\nConsider updating macOS, if possible", where="file") """ try: #------------------------------------------------------- # 1) Convert Audio self.logn() self.logn(t('start_audio_conversion'), 'highlight') try: # Add audio conversion job. self._ffmpeg_proc = audio.convert.ToWav( Path(job.audio_file), Path(tmp_audio_file), force=True ) self._ffmpeg_proc.open() if job.start > 0: self._ffmpeg_proc.seek(job.start) if job.stop > 0: self._ffmpeg_proc.stop_after(job.stop) # Poll loop to allow responsive cancel during conversion while self._ffmpeg_proc.convert(): if self.cancel: # TODO: replace this with an UserCancelException or # similar. raise Exception(t('err_user_cancelation')) except Exception as e: traceback_str = traceback.format_exc() # Distinguish cancel vs. real error during audio conversion if str(e) == t('err_user_cancelation') or self.cancel: job.set_canceled(t('err_user_cancelation')) self.update_queue_table() raise e else: job.set_error(f"{t('err_converting_audio')}: {e}", traceback_str) self.update_queue_table() raise Exception(t('err_ffmpeg'), job.error_message) from e finally: self._ffmpeg_proc.close() self._ffmpeg_proc = None self.logn(t('audio_conversion_finished')) self.set_progress(1, 100, job.speaker_detection) #------------------------------------------------------- # 2) Speaker identification (diarization) with pyannote # Helper Functions: def overlap_len(ss_start, ss_end, ts_start, ts_end): # ss...: speaker segment start and end in milliseconds (from pyannote) # ts...: transcript segment start and end (from whisper.cpp) # returns overlap percentage, i.e., "0.8" = 80% of the transcript segment overlaps with the speaker segment from pyannote if ts_end < ss_start: # no overlap, ts is before ss return None if ts_start > ss_end: # no overlap, ts is after ss return 0.0 ts_len = ts_end - ts_start if ts_len <= 0: return None # ss & ts have overlap overlap_start = max(ss_start, ts_start) # Whichever starts later overlap_end = min(ss_end, ts_end) # Whichever ends sooner ol_len = overlap_end - overlap_start + 1 return ol_len / ts_len def find_speaker(diarization, transcript_start, transcript_end) -> str: # Looks for the shortest segment in diarization that has at least 80% overlap # with transcript_start - trancript_end. # Returns the speaker name if found. # If only an overlap < 80% is found, this speaker name ist returned. # If no overlap is found, an empty string is returned. spkr = '' overlap_found = 0 overlap_threshold = 0.8 segment_len = 0 is_overlapping = False for segment in diarization: t = overlap_len(segment["start"], segment["end"], transcript_start, transcript_end) if t is None: # we are already after transcript_end break current_segment_len = segment["end"] - segment["start"] # Length of the current segment current_segment_spkr = f'S{segment["label"][8:]}' # shorten the label: "SPEAKER_01" > "S01" if overlap_found >= overlap_threshold: # we already found a fitting segment, compare length now if (t >= overlap_threshold) and (current_segment_len < segment_len): # found a shorter (= better fitting) segment that also overlaps well is_overlapping = True overlap_found = t segment_len = current_segment_len spkr = current_segment_spkr elif t > overlap_found: # no segment with good overlap yet, take this if the overlap is better then previously found overlap_found = t segment_len = current_segment_len spkr = current_segment_spkr if job.overlapping and is_overlapping: return f"//{spkr}" else: return spkr # Start Diarization: if job.speaker_detection != 'none': try: job.status = JobStatus.SPEAKER_IDENTIFICATION self.update_queue_table() self.logn() self.logn(t('start_identifying_speakers'), 'highlight') self.logn(t('loading_pyannote')) # self.set_progress(1, 100, job.speaker_detection) while True: try: diarization = self._run_diarize_subprocess(tmp_audio_file, job) break except Exception as err: if self._handle_cuda_fallback('pyannote', err): self.logn(t('pyannote_cuda_retry'), 'highlight') continue raise # write segments to log file for segment in diarization: line = f'{utils.ms_to_str(job.start + segment["start"], include_ms=True)} - {utils.ms_to_str(job.start + segment["end"], include_ms=True)} {segment["label"]}' self.logn(line, where='file') self.logn() except Exception as e: traceback_str = traceback.format_exc() if str(e) == t('err_user_cancelation') or self.cancel: job.set_canceled(t('err_user_cancelation')) self.update_queue_table() raise Exception(t('err_user_cancelation')) else: job.set_error(f"{t('err_identifying_speakers')}: {e}", traceback_str) self.update_queue_table() raise Exception(job.error_message) #------------------------------------------------------- # 3) Transcribe with faster-whisper job.status = JobStatus.TRANSCRIPTION self.update_queue_table() self.logn() self.logn(t('start_transcription'), 'highlight') self.logn(t('loading_whisper')) info = None transcription_success = False while True: retry_cuda = False # prepare transcript html d = AdvancedHTMLParser.AdvancedHTMLParser() d.parseStr(default_html) # add audio file path: tag = d.createElement("meta") tag.name = "audio_source" tag.content = job.audio_file d.head.appendChild(tag) # add app version: """ # removed because not really necessary tag = d.createElement("meta") tag.name = "noScribe_version" tag.content = app_version d.head.appendChild(tag) """ #add WordSection1 (for line numbers in MS Word) as main_body main_body = d.createElement('div') main_body.addClass('WordSection1') d.body.appendChild(main_body) # header p = d.createElement('p') p.setStyle('font-weight', '600') p.appendText(Path(job.audio_file).stem) # use the name of the audio file (without extension) as the title main_body.appendChild(p) # subheader p = d.createElement('p') s = d.createElement('span') s.setStyle('color', '#909090') s.setStyle('font-size', '0.8em') s.appendText(t('doc_header', version=app_version)) br = d.createElement('br') s.appendChild(br) s.appendText(t('doc_header_audio', file=job.audio_file)) br = d.createElement('br') s.appendChild(br) s.appendText(f'({html.escape(option_info, quote=False)})') p.appendChild(s) main_body.appendChild(p) p = d.createElement('p') main_body.appendChild(p) speaker = '' prev_speaker = '' last_auto_save = datetime.datetime.now() def save_doc(): nonlocal last_auto_save txt = '' if job.file_ext == 'html': txt = d.asHTML() elif job.file_ext == 'txt': txt = utils.html_to_text(d.asHTML(), use_only_body=True) elif job.file_ext == 'vtt': txt = utils.html_to_webvtt(d.asHTML()) else: raise TypeError(f'Invalid file type "{job.file_ext}".') try: if txt != '': with open(job.transcript_file, 'w', encoding="utf-8") as f: f.write(txt) f.flush() last_auto_save = datetime.datetime.now() except Exception: # other error while saving, maybe the file is already open in Word and cannot be overwritten # try saving to a different filename try: job.transcript_file = utils.create_unique_filenames([Path(job.transcript_file)])[0] except RuntimeError as e: # File name already exists and a new one could not # be found. raise RuntimeError(t('rescue_saving_failed')) from e # `job.transcript_file` is for sure a `Path` here as we # called `create_unique_filenames`. job.transcript_file.write_text(txt, encoding="utf-8") self.logn() self.logn(t('rescue_saving', file=job.transcript_file), 'error', link=f'file://{job.transcript_file}') last_auto_save = datetime.datetime.now() # Prepare VAD data locally for pause adjustment (audio is 16kHz mono after ffmpeg conversion) try: job.vad_threshold = float(config['voice_activity_detection_threshold']) except Exception: config['voice_activity_detection_threshold'] = '0.5' job.vad_threshold = 0.5 sampling_rate = 16000 audio_array = decode_audio(tmp_audio_file, sampling_rate=sampling_rate) duration = audio_array.shape[0] / sampling_rate try: vad_parameters = VadOptions(min_silence_duration_ms=500, threshold=job.vad_threshold, speech_pad_ms=0) except TypeError: vad_parameters = VadOptions(min_silence_duration_ms=500, onset=job.vad_threshold, speech_pad_ms=0) speech_chunks = get_speech_timestamps(audio_array, vad_parameters) def adjust_for_pause(segment): """Adjusts start and end of segment if it falls into a pause identified by the VAD""" pause_extend = 0.2 # extend the pauses by 200ms to make the detection more robust original_start = segment.start original_end = segment.end # iterate through the pauses and adjust segment boundaries accordingly for i in range(0, len(speech_chunks)): pause_start = (speech_chunks[i]['end'] / sampling_rate) - pause_extend if i == (len(speech_chunks) - 1): pause_end = duration + pause_extend # last segment, pause till the end else: pause_end = (speech_chunks[i+1]['start'] / sampling_rate) + pause_extend if pause_start > segment.end: break # we moved beyond the segment, stop going further if segment.start > pause_start and segment.start < pause_end: segment.start = pause_end - pause_extend if segment.end > pause_start and segment.end < pause_end: segment.end = pause_start + pause_extend # Safety check: if pause adjustments caused start >= end # (e.g. short segment fully inside a pause), revert to # original boundaries to avoid negative durations (#253) if segment.start >= segment.end: segment.start = original_start segment.end = original_end return segment # Run Faster-Whisper in a spawned subprocess and stream segments last_segment_end = 0 last_timestamp_ms = 0 first_segment = True def on_segment(seg): nonlocal first_segment, last_segment_end, last_timestamp_ms, p, speaker, prev_speaker # Map dict to simple object-like for existing code class _Seg: __slots__ = ("start", "end", "text", "words") def __init__(self, d): self.start = d.get('start') self.end = d.get('end') self.text = d.get('text') self.words = d.get('words') segment = _Seg(seg) segment = adjust_for_pause(segment) # get time of the segment in milliseconds start = round(segment.start * 1000.0) end = round(segment.end * 1000.0) # if we skipped a part at the beginning of the audio we have to add this here again, otherwise the timestamps will not match the original audio: orig_audio_start = job.start + start orig_audio_end = job.start + end if job.timestamps: ts = utils.ms_to_str(orig_audio_start) ts = f'[{ts}]' # check for pauses and mark them in the transcript if (job.pause > 0) and (start - last_segment_end >= job.pause * 1000): # (more than x seconds with no speech) pause_len = round((start - last_segment_end)/1000) if pause_len >= 60: # longer than 60 seconds pause_str = ' ' + t('pause_minutes', minutes=round(pause_len/60)) elif pause_len >= 10: # longer than 10 seconds pause_str = ' ' + t('pause_seconds', seconds=pause_len) else: # less than 10 seconds pause_str = ' (' + (job.pause_marker * pause_len) + ')' if first_segment: pause_str = pause_str.lstrip() + ' ' orig_audio_start_pause = job.start + last_segment_end orig_audio_end_pause = job.start + start a = d.createElement('a') a.name = f'ts_{orig_audio_start_pause}_{orig_audio_end_pause}_{speaker}' a.appendText(pause_str) p.appendChild(a) self.log(pause_str) if first_segment: self.logn() self.logn() last_segment_end = end # write text to the doc # diarization (speaker detection)? seg_text = segment.text seg_html = html.escape(seg_text, quote=False) if job.speaker_detection != 'none': new_speaker = find_speaker(diarization, start, end) if (speaker != new_speaker) and (new_speaker != ''): # speaker change if new_speaker[:2] == '//': # is overlapping speech, create no new paragraph prev_speaker = speaker speaker = new_speaker seg_text = f' {speaker}:{seg_text}' seg_html = html.escape(seg_text, quote=False) elif (speaker[:2] == '//') and (new_speaker == prev_speaker): # was overlapping speech and we are returning to the previous speaker speaker = new_speaker seg_text = f'//{seg_text}' seg_html = html.escape(seg_text, quote=False) else: # new speaker, not overlapping if speaker[:2] == '//': # was overlapping speech, mark the end last_elem = p.lastElementChild if last_elem: last_elem.appendText('//') else: p.appendText('//') self.log('//') p = d.createElement('p') main_body.appendChild(p) if not first_segment: self.logn() self.logn() speaker = new_speaker # add timestamp if job.timestamps: seg_html = f'{speaker}: {ts}{html.escape(seg_text, quote=False)}' seg_text = f'{speaker}: {ts}{seg_text}' last_timestamp_ms = start else: if job.file_ext != 'vtt': # in vtt files, speaker names are added as special voice tags so skip this here seg_text = f'{speaker}:{seg_text}' seg_html = html.escape(seg_text, quote=False) else: seg_html = html.escape(seg_text, quote=False).lstrip() seg_text = f'{speaker}:{seg_text}' else: # same speaker if job.timestamps: if (start - last_timestamp_ms) > job.timestamp_interval: seg_html = f' {ts}{html.escape(seg_text, quote=False)}' seg_text = f' {ts}{seg_text}' last_timestamp_ms = start else: seg_html = html.escape(seg_text, quote=False) else: # no speaker detection if job.timestamps and (first_segment or (start - last_timestamp_ms) > job.timestamp_interval): seg_html = f' {ts}{html.escape(seg_text, quote=False)}' seg_text = f' {ts}{seg_text}' last_timestamp_ms = start else: seg_html = html.escape(seg_text, quote=False) # avoid leading whitespace in first paragraph if first_segment: seg_text = seg_text.lstrip() seg_html = seg_html.lstrip() # Create bookmark with audio timestamps start to end and add the current segment. a_html = f'{seg_html}' a = d.createElementFromHTML(a_html) p.appendChild(a) self.log(seg_text) first_segment = False # auto save periodically nonlocal last_auto_save if job.auto_save: if (datetime.datetime.now() - last_auto_save).total_seconds() > 5: save_doc() job.has_partial_transcript = True # per-segment progress based on total duration try: progr = round((segment.end/duration) * 100) self.set_progress(3, progr, job.speaker_detection) except Exception: pass try: info = self._run_whisper_subprocess_stream(tmp_audio_file, job, on_segment) transcription_success = True # if self.cancel: # raise Exception(t('err_user_cancelation')) job.has_partial_transcript = False # transcript is finished self.logn() self.logn() self.logn(t('transcription_finished'), 'highlight') except Exception as err: if self._handle_cuda_fallback('whisper', err): retry_cuda = True else: raise finally: if not first_segment: save_doc() job.has_partial_transcript = job.status != JobStatus.FINISHED else: job.has_partial_transcript = False if transcription_success: if job.transcript_file != orig_transcript_file: # used alternative filename because saving under the initial name failed self.logn( t('rescue_saving', file=job.transcript_file), link=f'file://{job.transcript_file}' ) else: self.logn( t('transcription_saved', file=job.transcript_file), link=f'file://{job.transcript_file}' ) if retry_cuda: self.logn(t('whisper_cuda_retry'), 'highlight') continue else: break # log duration of the whole process proc_time = datetime.datetime.now() - proc_start_time proc_seconds = "{:02d}".format(int(proc_time.total_seconds() % 60)) proc_time_str = f'{int(proc_time.total_seconds() // 60)}:{proc_seconds}' self.logn(t('trancription_time', duration=proc_time_str)) finally: self.log_file.close() self.log_file = None finally: # hide progress self.set_progress(0, 0) def create_job(self, enqueue=False): try: show_queue_tab = enqueue # Collect transcription options from UI new_queue = self.collect_transcription_options() # Confirm override if output file conflicts with jobs in queue for job in new_queue.jobs: if self.queue.has_output_conflict(job.transcript_file): if not self.queue.confirm_output_override(job.transcript_file): return else: break # Add the jobs to the queue for job in new_queue.jobs: self.queue.add_job(job) if not enqueue and not self.queue.is_running(): # Start transcription worker with the queue wkr = Thread(target=self.transcription_worker, kwargs={"start_job_index": len(self.queue.jobs) - 1}, daemon=True) self._worker_threads.append(wkr) wkr.start() enqueue = True else: # just add it to the queue show_queue_tab = True self.logn() self.logn(t('queue_added_job', audio_file=os.path.basename(job.audio_file)), 'highlight') self.update_queue_table() if show_queue_tab: try: self.tabview.set(self.tabview._name_list[1]) # Switch to queue tab for visual feedback except Exception: pass except (ValueError, FileNotFoundError) as e: # Handle validation errors from collect_transcription_options self.logn(str(e), 'error') tk.messagebox.showerror(title='noScribe', message=str(e)) except Exception as e: # Handle unexpected errors self.logn(f'Error starting transcription: {str(e)}', 'error') tk.messagebox.showerror(title='noScribe', message=f'Error starting transcription: {str(e)}') def _handle_cuda_fallback(self, component: str, error: Exception) -> bool: global force_pyannote_cpu global force_whisper_cpu message = str(error).strip() if not _is_cuda_error_message(message): return False if component == 'pyannote': if force_pyannote_cpu: return False prompt = t('pyannote_cuda_error_prompt', error=message) if tk.messagebox.askyesno(title='noScribe', message=prompt): force_pyannote_cpu = True config['force_pyannote_cpu'] = 'true' save_config() return True return False if component == 'whisper': if force_whisper_cpu: return False prompt = t('whisper_cuda_error_prompt', error=message) if tk.messagebox.askyesno(title='noScribe', message=prompt): force_whisper_cpu = True config['force_whisper_cpu'] = 'true' save_config() return True return False return False def _run_whisper_subprocess_stream(self, tmp_audio_file: str, job, on_segment): """Spawn a subprocess to run Faster-Whisper and stream segments. Calls on_segment(dict) for each segment streamed by the child. Returns a simple info object (duration at least). """ global force_whisper_cpu # Language code for non-auto/multilingual language_code = None if job.language_name not in ('Auto', 'Multilingual'): try: language_code = languages[job.language_name] except Exception: language_code = None # VAD threshold from config try: vad_threshold = float(config.get('voice_activity_detection_threshold', '0.5')) except Exception: vad_threshold = 0.5 args = { "model_name_or_path": job.whisper_model, "device": 'cpu' if force_whisper_cpu else 'auto', "compute_type": job.whisper_compute_type, "cpu_threads": number_threads, "local_files_only": True, "audio_path": tmp_audio_file, "language_name": job.language_name, "language_code": language_code, "disfluencies": job.disfluencies, "beam_size": 5, "word_timestamps": True, "vad_filter": True, "vad_threshold": vad_threshold, "locale": app_locale, } # Spawn child process using spawn start method ctx = mp.get_context("spawn") q = ctx.Queue() from whisper_mp_worker import whisper_proc_entrypoint proc = ctx.Process(target=whisper_proc_entrypoint, args=(args, q)) proc.start() # Expose to allow cancel to terminate the child self._mp_proc = proc self._mp_queue = q info = None try: while True: try: msg = q.get(timeout=0.1) except pyqueue.Empty: if self.cancel: # User requested cancel; terminate child try: proc.terminate() except Exception: pass raise Exception(t('err_user_cancelation')) if not proc.is_alive(): # Process died without sending result exitcode = proc.exitcode self.logn(f"Transcription worker exited unexpectedly (code {exitcode}).", 'error') raise Exception('Subprocess terminated unexpectedly') continue mtype = msg.get("type") if isinstance(msg, dict) else None if mtype == "log": level = msg.get("level", "info") txt = msg.get("msg", "") if level == 'error': self.logn(txt, 'error') else: self.logn(txt) elif mtype == "progress": pct = msg.get("pct") detail = msg.get("detail") try: if pct is not None: self.set_progress(3, float(pct), job.speaker_detection) except Exception: pass elif mtype == "segment": seg = msg.get("segment") or {} try: on_segment(seg) except Exception as e: # If on_segment fails, stop child and raise try: proc.terminate() except Exception: pass raise elif mtype == "result": if msg.get("ok"): info = msg.get("info", {}) else: err = msg.get('error', 'Transcription failed') trc = msg.get('trace') self.logn(f"Transcription failed: {err}", 'error') if trc: self.logn(trc, where='file') raise Exception(err) break # keep looping until we get a result finally: try: proc.join(timeout=0.2) except Exception: pass if proc.is_alive(): try: proc.terminate() except Exception: pass try: proc.close() except Exception: pass try: q.close() q.join_thread() except Exception: pass # Clear exposed handles self._mp_proc = None self._mp_queue = None class _Info: __slots__ = ("duration",) def __init__(self, d): self.duration = d.get('duration') info_obj = _Info(info or {}) return info_obj def _run_diarize_subprocess(self, tmp_audio_file: str, job): """Spawn a subprocess to run diarization and return list of segments. Streams child logs/progress back to GUI and honors cancel. """ global force_pyannote_cpu ctx = mp.get_context("spawn") q = ctx.Queue() from pyannote_mp_worker import pyannote_proc_entrypoint args = { "device": 'cpu' if force_pyannote_cpu else '', "audio_path": tmp_audio_file, "num_speakers": (int(job.speaker_detection) if str(job.speaker_detection).isdigit() else None), } proc = ctx.Process(target=pyannote_proc_entrypoint, args=(args, q)) proc.start() # Keep handles for cancel self._mp_proc = proc self._mp_queue = q diarization = None try: while True: try: msg = q.get(timeout=0.1) except pyqueue.Empty: if self.cancel: try: proc.terminate() except Exception: pass raise Exception(t('err_user_cancelation')) if not proc.is_alive(): exitcode = proc.exitcode self.logn(f"Diarization worker exited unexpectedly (code {exitcode}). UI remains responsive.", 'error') raise Exception('Subprocess terminated unexpectedly') continue mtype = msg.get("type") if isinstance(msg, dict) else None if mtype == "log": txt = msg.get("msg", "") self.logn('PyAnnote ' + txt, where='file') elif mtype == "progress": step_name = str(msg.get("step", "")) progress_percent = int(msg.get("pct", 0)) self.logr(f'{step_name}: {progress_percent}%') if step_name == 'segmentation': self.set_progress(2, progress_percent * 0.3, job.speaker_detection) elif step_name == 'embeddings': self.set_progress(2, 30 + (progress_percent * 0.7), job.speaker_detection) elif mtype == "result": if msg.get("ok"): diarization = msg.get("segments", []) else: err = msg.get('error', 'Diarization failed') trc = msg.get('trace') self.logn(f"PyAnnote error: {err}", 'error') if trc: self.logn(trc, where='file') raise Exception(err) break finally: try: proc.join(timeout=0.2) except Exception: pass if proc.is_alive(): try: proc.terminate() except Exception: pass try: proc.close() except Exception: pass try: q.close() q.join_thread() except Exception: pass self._mp_proc = None self._mp_queue = None return diarization or [] def on_closing(self): # (see: https://stackoverflow.com/questions/111155/how-do-i-handle-the-window-close-event-in-tkinter) global force_pyannote_cpu global force_whisper_cpu #if messagebox.askokcancel("Quit", "Do you want to quit?"): # Stop all running jobs: try: if not self.on_queue_stop(ask_before_canceling=True): return # user has aborted cancelation of waiting jobs except: pass # Signal shutdown and try to stop background activity gracefully self._shutting_down = True self.cancel = True try: # Terminate active multiprocessing child (diarization/whisper) if present try: if getattr(self, "_mp_proc", None) is not None and self._mp_proc.is_alive(): try: self._mp_proc.terminate() except Exception: pass try: self._mp_proc.join(timeout=1.0) except Exception: pass finally: try: if getattr(self, "_mp_queue", None) is not None: try: self._mp_queue.close() except Exception: pass try: self._mp_queue.join_thread() except Exception: pass finally: self._mp_proc = None self._mp_queue = None # Terminate ffmpeg if currently converting try: if getattr(self, "_ffmpeg_proc", None): self._ffmpeg_proc.close() except Exception as e: raise Exception(t('err_user_cancelation')) from e finally: self._ffmpeg_proc = None # Join worker threads briefly to give them a chance to exit try: for th in list(getattr(self, "_worker_threads", [])): try: th.join(timeout=2.0) except Exception: pass except Exception: pass except Exception: pass # remember some settings for the next run try: config['last_language'] = self.option_menu_language.get() config['last_speaker'] = self.option_menu_speaker.get() config['last_whisper_model'] = self.option_menu_whisper_model.get() config['last_pause'] = self.option_menu_pause.get() config['last_overlapping'] = self.check_box_overlapping.get() config['last_timestamps'] = self.check_box_timestamps.get() config['last_disfluencies'] = self.check_box_disfluencies.get() config['force_pyannote_cpu'] = str(force_pyannote_cpu) config['force_whisper_cpu'] = str(force_whisper_cpu) save_config() finally: try: self.quit() except Exception: pass self.destroy() class HeadlessApp(App): def __init__(self): # Do not initialize Tk/CTk to avoid DISPLAY requirements _init_app_state(self) self._headless = True def __getattr__(self, name): # Avoid Tk attribute delegation recursion when CTk isn't initialized raise AttributeError(name) def _cleanup_app(app): """Cleanup app instance for proper process exit in CLI mode.""" try: # Signal shutdown to stop background activity app._shutting_down = True app.cancel = True # Terminate active multiprocessing child (diarization/whisper) if present if getattr(app, "_mp_proc", None) is not None: try: if app._mp_proc.is_alive(): app._mp_proc.terminate() app._mp_proc.join(timeout=1.0) except Exception: pass finally: app._mp_proc = None # Close multiprocessing queue if getattr(app, "_mp_queue", None) is not None: try: app._mp_queue.close() app._mp_queue.join_thread() except Exception: pass finally: app._mp_queue = None # Terminate ffmpeg if currently converting if getattr(app, "_ffmpeg_proc", None) is not None: try: if app._ffmpeg_proc.poll() is None: app._ffmpeg_proc.terminate() app._ffmpeg_proc.wait(timeout=1.0) except Exception: try: app._ffmpeg_proc.kill() except Exception: pass finally: app._ffmpeg_proc = None # Join worker threads briefly for th in list(getattr(app, "_worker_threads", [])): try: th.join(timeout=0.5) except Exception: pass except Exception: pass if not getattr(app, '_headless', False): try: app.quit() except Exception: pass try: app.destroy() except Exception: pass def run_cli_mode(args): """Run noScribe in CLI mode""" app = None try: # Create a headless app instance (no GUI initialization) app = HeadlessApp() # Validate and set the whisper model available_models = app.get_whisper_models() if args.model: if args.model not in available_models: print(f"Error: Model '{args.model}' not found.") print(f"Available models: {', '.join(available_models)}") return 1 else: # Use default model if 'precise' in available_models: args.model = 'precise' elif available_models: args.model = available_models[0] else: print("Error: No Whisper models found.") return 1 # Create job from CLI arguments job = create_job_from_cli_args(args) # Set the whisper model path job.whisper_model = app.whisper_model_paths[args.model] # Validate files if not os.path.exists(job.audio_file): print(f"Error: Audio file '{job.audio_file}' not found.") return 1 # Check output directory exists output_dir = os.path.dirname(os.path.abspath(job.transcript_file)) if not os.path.exists(output_dir): try: os.makedirs(output_dir) except Exception as e: print(f"Error: Cannot create output directory '{output_dir}': {e}") return 1 # Add the job to the queue app.queue.add_job(job) print(f"Starting transcription of '{job.audio_file}'...") print(f"Output will be saved to '{job.transcript_file}'") print(f"Language: {job.language_name}") print(f"Model: {args.model}") print(f"Speaker detection: {job.speaker_detection}") print() # Start transcription worker with the queue app.transcription_worker() # Check results final_summary = app.queue.get_queue_summary() if final_summary['finished'] > 0: print(f"\nTranscription completed successfully!") print(f"Output saved to: {job.transcript_file}") return 0 else: if final_summary.get('canceled', 0) > 0: print(f"\nTranscription canceled by user.") return 1 print(f"\nTranscription failed!") failed_jobs = app.queue.get_failed_jobs() if failed_jobs: print(f"Error: {failed_jobs[0].error_message}") return 1 except Exception as e: print(f"Error: {str(e)}") return 1 finally: if app is not None: _cleanup_app(app) def show_available_models(): """Show available Whisper models""" app = None try: # Create headless app instance to get models app = HeadlessApp() models = app.get_whisper_models() print("Available Whisper models:") for model in models: print(f" - {model}") if not models: print(" No models found. Please check your installation.") except Exception as e: print(f"Error getting models: {str(e)}") finally: if app is not None: _cleanup_app(app) if __name__ == "__main__": # Parse command line arguments args = parse_cli_args() # Handle special case: show available models if args.help_models: show_available_models() sys.exit(0) # If explicit headless requested, run pure CLI mode if getattr(args, 'no_gui', False): if args.audio_file and args.output_file: exit_code = run_cli_mode(args) sys.exit(exit_code) else: print("Error: --no-gui requires both audio_file and output_file.") print("Usage: python noScribe.py [options] --no-gui") sys.exit(1) # Default: show GUI, even with CLI args app = App() # If arguments were provided, prefill and optionally auto-start try: # Prefill selected model if provided desired_model_name = None available_models = app.get_whisper_models() if getattr(args, 'model', None): if args.model in available_models: desired_model_name = args.model else: app.logn(f"Warning: Model '{args.model}' not found. Using default GUI selection.") if desired_model_name: try: app.option_menu_whisper_model.set(desired_model_name) except Exception: pass # Prefill files if provided if getattr(args, 'audio_file', None): app.audio_files_list = [args.audio_file] try: app.button_audio_file_name.configure(text=os.path.basename(args.audio_file)) except Exception: pass app.logn() app.logn(t('log_audio_file_selected') + f'\n{args.audio_file}') if getattr(args, 'output_file', None): app.transcript_files_list = [args.output_file] try: app.button_transcript_file_name.configure(text=os.path.basename(args.output_file)) except Exception: pass app.logn() app.logn(t('log_transcript_filename') + f'\n{args.output_file}') # Prefill other options if provided if getattr(args, 'start', None): app.entry_start.delete(0, 'end') app.entry_start.insert(0, args.start) if getattr(args, 'stop', None): app.entry_stop.delete(0, 'end') app.entry_stop.insert(0, args.stop) if getattr(args, 'language', None): app.option_menu_language.set(args.language) if getattr(args, 'pause', None): app.option_menu_pause.set(args.pause) if getattr(args, 'speaker_detection', None): app.option_menu_speaker.set(args.speaker_detection) if getattr(args, 'overlapping', None) is not None: if args.overlapping: app.check_box_overlapping.select() else: app.check_box_overlapping.deselect() if getattr(args, 'disfluencies', None) is not None: if args.disfluencies: app.check_box_disfluencies.select() else: app.check_box_disfluencies.deselect() if getattr(args, 'timestamps', None) is not None: if args.timestamps: app.check_box_timestamps.select() else: app.check_box_timestamps.deselect() # If both files provided, create a job and auto-start in GUI if len(app.audio_files_list) > 0 and len(app.transcript_files_list) > 0: # Start the job app.create_job() except Exception as e: # Non-fatal: continue to show GUI print(f"Warning: Failed to prefill GUI from CLI args: {e}") # Enter GUI main loop app.mainloop()