@@ -1020,34 +1020,39 @@ def _update_progress_display(self):
10201020 font = ("" , font_size )
10211021 )
10221022
1023+ def _init_app_state (app ):
1024+ app ._headless = False
1025+ app .user_models_dir = os .path .join (config_dir , 'whisper_models' )
1026+ os .makedirs (app .user_models_dir , exist_ok = True )
1027+ whisper_models_readme = os .path .join (app .user_models_dir , 'readme.txt' )
1028+ if not os .path .exists (whisper_models_readme ):
1029+ with open (whisper_models_readme , 'w' ) as file :
1030+ file .write ('You can download custom Whisper-models for the transcription into this folder. \n '
1031+ 'See here for more information: https://github.com/kaixxx/noScribe/wiki/Add-custom-Whisper-models-for-transcription' )
1032+
1033+ app .queue = TranscriptionQueue ()
1034+ app .audio_files_list = []
1035+ app .transcript_files_list = []
1036+ app .log_file = None
1037+ app .cancel = False # if set to True, transcription will be canceled
1038+ # If True, cancel only the currently running job (triggered from queue row "X")
1039+ app ._cancel_job_only = False
1040+ app .current_progress = - 1
1041+ # Track background activity for robust shutdown
1042+ app ._worker_threads = []
1043+ app ._mp_proc = None
1044+ app ._mp_queue = None
1045+ app ._ffmpeg_proc = None
1046+ app ._shutting_down = False
1047+
1048+
10231049class App (ctk .CTk ):
10241050 def __init__ (self ):
10251051 super ().__init__ ()
10261052
10271053 self .protocol ("WM_DELETE_WINDOW" , self .on_closing )
1028-
1029- self .user_models_dir = os .path .join (config_dir , 'whisper_models' )
1030- os .makedirs (self .user_models_dir , exist_ok = True )
1031- whisper_models_readme = os .path .join (self .user_models_dir , 'readme.txt' )
1032- if not os .path .exists (whisper_models_readme ):
1033- with open (whisper_models_readme , 'w' ) as file :
1034- file .write ('You can download custom Whisper-models for the transcription into this folder. \n '
1035- 'See here for more information: https://github.com/kaixxx/noScribe/wiki/Add-custom-Whisper-models-for-transcription' )
1036-
1037- self .queue = TranscriptionQueue ()
1038- self .audio_files_list = []
1039- self .transcript_files_list = []
1040- self .log_file = None
1041- self .cancel = False # if set to True, transcription will be canceled
1042- # If True, cancel only the currently running job (triggered from queue row "X")
1043- self ._cancel_job_only = False
1044- self .current_progress = - 1
1045- # Track background activity for robust shutdown
1046- self ._worker_threads = []
1047- self ._mp_proc = None
1048- self ._mp_queue = None
1049- self ._ffmpeg_proc = None
1050- self ._shutting_down = False
1054+
1055+ _init_app_state (self )
10511056
10521057 # configure window
10531058 self .title ('noScribe - ' + t ('app_header' ))
@@ -1500,6 +1505,8 @@ def update_scrollbar_visibility(self):
15001505
15011506 def update_queue_table (self ):
15021507 """Update the queue table by diffing: update existing rows, add new ones, remove missing."""
1508+ if getattr (self , '_headless' , False ):
1509+ return
15031510 current_keys = []
15041511 for i in range (len (self .queue .jobs )):
15051512 job = self .queue .jobs [i ]
@@ -1801,6 +1808,8 @@ def update_queue_table(self):
18011808
18021809 def update_queue_controls (self ):
18031810 """Enable/disable and label the queue control buttons based on state."""
1811+ if getattr (self , '_headless' , False ):
1812+ return
18041813 try :
18051814 has_running = len (self .queue .get_running_jobs ()) > 0
18061815 has_pending = self .queue .has_pending_jobs ()
@@ -2041,7 +2050,7 @@ def log(self, txt: str = '', tags: list = [], where: str = 'both', link: str = '
20412050 if where != 'file' :
20422051 if txt [:- 1 ] != t ('welcome_instructions' ):
20432052 print (txt , end = '' )
2044- if hasattr (self , 'log_textbox' ) and self .log_textbox .winfo_exists ():
2053+ if not getattr ( self , '_headless' , False ) and hasattr (self , 'log_textbox' ) and self .log_textbox .winfo_exists ():
20452054 try :
20462055 self .log_textbox .configure (state = tk .NORMAL )
20472056 # To prevent slowing down the UI, limit the content of log_textbox to max 5000 characters
@@ -2084,7 +2093,7 @@ def logn(self, txt: str = '', tags: list = [], where: str = 'both', link:str = '
20842093
20852094 def logr (self , txt : str = '' , tags : list = [], where : str = 'both' , link :str = '' , tb : str = '' ) -> None :
20862095 """ Replace the last line of the log """
2087- if where != 'file' :
2096+ if where != 'file' and not getattr ( self , '_headless' , False ) and hasattr ( self , 'log_textbox' ) and self . log_textbox . winfo_exists () :
20882097 self .log_textbox .configure (state = ctk .NORMAL )
20892098 tmp_txt = self .log_textbox .get ("end-1c linestart" , "end-1c" )
20902099 self .log_textbox .delete ("end-1c linestart" , "end-1c" )
@@ -2193,6 +2202,8 @@ def button_transcript_file_event(self):
21932202
21942203 def set_progress (self , step , value , speaker_detection = 'none' ):
21952204 """ Update state of the progress bar """
2205+ if getattr (self , '_headless' , False ):
2206+ return
21962207 progr = - 1
21972208 if step == 1 :
21982209 progr = value * 0.05 / 100
@@ -2379,13 +2390,14 @@ def transcription_worker(self, start_job_index=None):
23792390 self .logn (t ('processing_time' , total_time_str = total_time_str ))
23802391
23812392 # open editor if only a single file was processed
2382- if queue_jobs_processed == 1 \
2393+ if not getattr (self , '_headless' , False ) \
2394+ and queue_jobs_processed == 1 \
23832395 and job \
23842396 and job .file_ext == 'html' \
23852397 and job .status == JobStatus .FINISHED \
23862398 and get_config ('auto_edit_transcript' , 'True' ) == 'True' :
23872399 self .launch_editor (job .transcript_file )
2388- elif queue_jobs_processed > 1 :
2400+ elif queue_jobs_processed > 1 and not getattr ( self , '_headless' , False ) :
23892401 # if more than one job has been processed, switch to queue tab for an overview
23902402 self .tabview .set (self .tabview ._name_list [1 ])
23912403
@@ -3365,16 +3377,85 @@ def on_closing(self):
33653377 pass
33663378 self .destroy ()
33673379
3368- def run_cli_mode (args ):
3369- """Run noScribe in CLI mode"""
3380+
3381+ class HeadlessApp (App ):
3382+ def __init__ (self ):
3383+ # Do not initialize Tk/CTk to avoid DISPLAY requirements
3384+ _init_app_state (self )
3385+ self ._headless = True
3386+
3387+ def __getattr__ (self , name ):
3388+ # Avoid Tk attribute delegation recursion when CTk isn't initialized
3389+ raise AttributeError (name )
3390+
3391+
3392+ def _cleanup_app (app ):
3393+ """Cleanup app instance for proper process exit in CLI mode."""
33703394 try :
3371- # Create a minimal app instance to access model paths and logging
3372- app = App ()
3373- # Hide GUI window for headless execution
3395+ # Signal shutdown to stop background activity
3396+ app ._shutting_down = True
3397+ app .cancel = True
3398+
3399+ # Terminate active multiprocessing child (diarization/whisper) if present
3400+ if getattr (app , "_mp_proc" , None ) is not None :
3401+ try :
3402+ if app ._mp_proc .is_alive ():
3403+ app ._mp_proc .terminate ()
3404+ app ._mp_proc .join (timeout = 1.0 )
3405+ except Exception :
3406+ pass
3407+ finally :
3408+ app ._mp_proc = None
3409+
3410+ # Close multiprocessing queue
3411+ if getattr (app , "_mp_queue" , None ) is not None :
3412+ try :
3413+ app ._mp_queue .close ()
3414+ app ._mp_queue .join_thread ()
3415+ except Exception :
3416+ pass
3417+ finally :
3418+ app ._mp_queue = None
3419+
3420+ # Terminate ffmpeg if currently converting
3421+ if getattr (app , "_ffmpeg_proc" , None ) is not None :
3422+ try :
3423+ if app ._ffmpeg_proc .poll () is None :
3424+ app ._ffmpeg_proc .terminate ()
3425+ app ._ffmpeg_proc .wait (timeout = 1.0 )
3426+ except Exception :
3427+ try :
3428+ app ._ffmpeg_proc .kill ()
3429+ except Exception :
3430+ pass
3431+ finally :
3432+ app ._ffmpeg_proc = None
3433+
3434+ # Join worker threads briefly
3435+ for th in list (getattr (app , "_worker_threads" , [])):
3436+ try :
3437+ th .join (timeout = 0.5 )
3438+ except Exception :
3439+ pass
3440+ except Exception :
3441+ pass
3442+
3443+ if not getattr (app , '_headless' , False ):
3444+ try :
3445+ app .quit ()
3446+ except Exception :
3447+ pass
33743448 try :
3375- app .withdraw ()
3449+ app .destroy ()
33763450 except Exception :
33773451 pass
3452+
3453+ def run_cli_mode (args ):
3454+ """Run noScribe in CLI mode"""
3455+ app = None
3456+ try :
3457+ # Create a headless app instance (no GUI initialization)
3458+ app = HeadlessApp ()
33783459
33793460 # Validate and set the whisper model
33803461 available_models = app .get_whisper_models ()
@@ -3445,12 +3526,16 @@ def run_cli_mode(args):
34453526 except Exception as e :
34463527 print (f"Error: { str (e )} " )
34473528 return 1
3529+ finally :
3530+ if app is not None :
3531+ _cleanup_app (app )
34483532
34493533def show_available_models ():
34503534 """Show available Whisper models"""
3535+ app = None
34513536 try :
3452- # Create minimal app instance to get models
3453- app = App ()
3537+ # Create headless app instance to get models
3538+ app = HeadlessApp ()
34543539 models = app .get_whisper_models ()
34553540
34563541 print ("Available Whisper models:" )
@@ -3459,9 +3544,11 @@ def show_available_models():
34593544
34603545 if not models :
34613546 print (" No models found. Please check your installation." )
3462-
34633547 except Exception as e :
34643548 print (f"Error getting models: { str (e )} " )
3549+ finally :
3550+ if app is not None :
3551+ _cleanup_app (app )
34653552
34663553if __name__ == "__main__" :
34673554 # Parse command line arguments
0 commit comments