Skip to content

Commit a08d404

Browse files
committed
Introducing HeadlessApp for true headless mode
1 parent c4c9f59 commit a08d404

File tree

1 file changed

+123
-36
lines changed

1 file changed

+123
-36
lines changed

noScribe.py

Lines changed: 123 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
10231049
class 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

34493533
def 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

34663553
if __name__ == "__main__":
34673554
# Parse command line arguments

0 commit comments

Comments
 (0)