diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dcb9fd..185ed48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ - Improve chaptered audio generation by outputting directly as `m4b` instead of converting from `wav`. - Ignore chapter markers and single newlines when calculating text length, improving the accuracy of the text length calculation. - Prevent cancellation if process is at 99%, ensuring the process is not interrupted at the last moment. +- Improved process handling for subpprocess calls, ensuring better management of subprocesses. - Added `.opus` as output format for generated audio files. - Added "Playing..." indicator for "Preview" button in the voice mixer. diff --git a/abogen/conversion.py b/abogen/conversion.py index 22a67fb..ae12af6 100644 --- a/abogen/conversion.py +++ b/abogen/conversion.py @@ -8,14 +8,12 @@ from platformdirs import user_desktop_dir from PyQt5.QtCore import QThread, pyqtSignal, Qt from PyQt5.QtWidgets import QCheckBox, QVBoxLayout, QDialog, QLabel, QDialogButtonBox import soundfile as sf -from utils import clean_text +from utils import clean_text, create_process from constants import PROGRAM_NAME, LANGUAGE_DESCRIPTIONS, SAMPLE_VOICE_TEXTS from voice_formulas import get_new_voice import static_ffmpeg import threading # for efficient waiting import subprocess -import sys -import platform def get_sample_voice_text(lang_code): @@ -509,7 +507,7 @@ class ConversionThread(QThread): ) if self.output_format == "opus": static_ffmpeg.add_paths() - proc = subprocess.Popen( + proc = create_process( [ "ffmpeg", "-y", @@ -529,6 +527,7 @@ class ConversionThread(QThread): chapter_out_path, ], stdin=subprocess.PIPE, + text=False # Ensure binary stdin for audio data ) proc.stdin.write(chapter_audio.astype("float32").tobytes()) proc.stdin.close() @@ -605,7 +604,7 @@ class ConversionThread(QThread): ] try: print(f"Executing FFMPEG for Opus: {' '.join(ffmpeg_cmd_opus)}") - process = subprocess.Popen(ffmpeg_cmd_opus, stdin=subprocess.PIPE) + process = create_process(ffmpeg_cmd_opus, stdin=subprocess.PIPE, text=False) # Ensure binary stdin process.stdin.write(audio_data_np.astype("float32").tobytes()) process.stdin.close() if process.wait() == 0: @@ -716,7 +715,7 @@ class ConversionThread(QThread): self.log_updated.emit(f"Generating audio with chapters...\n") print(f"Executing FFMPEG for M4B: {' '.join(ffmpeg_cmd)}") - process = subprocess.Popen(ffmpeg_cmd, stdin=subprocess.PIPE) + process = create_process(ffmpeg_cmd, stdin=subprocess.PIPE, text=False) # Ensure binary stdin process.stdin.write(audio_data_np.astype("float32").tobytes()) process.stdin.close() return_code = process.wait() @@ -725,13 +724,13 @@ class ConversionThread(QThread): return output_m4b_path else: self.log_updated.emit( - (f"FFmpeg failed to create M4B (return code {return_code}). Falling back to WAV.\n", "red") + (f"FFmpeg failed to create M4B (return code {return_code}).\n\nFalling back to WAV.\n", "red") ) sf.write(final_wav_path, audio_data_np, 24000, format="wav") return final_wav_path except Exception as e: - self.log_updated.emit((f"Error during M4B generation: {str(e)}. Falling back to WAV.\n", "red")) + self.log_updated.emit((f"Error during M4B generation: {str(e)}.\n\nFalling back to WAV.\n", "red")) try: sf.write(final_wav_path, audio_data_np, 24000, format="wav") return final_wav_path diff --git a/abogen/gui.py b/abogen/gui.py index 7f1cd1d..d3452b7 100644 --- a/abogen/gui.py +++ b/abogen/gui.py @@ -695,7 +695,7 @@ class abogen(QWidget): ("wav", "wav"), ("flac", "flac"), ("mp3", "mp3"), - ("opus", "opus (best)"), + ("opus", "opus (best compression)"), ("m4b", "m4b (with chapters)"), ]: self.format_combo.addItem(label, key) @@ -1814,8 +1814,9 @@ class abogen(QWidget): def add_shortcut_to_desktop(self): """Create a desktop shortcut to this program using PowerShell.""" - import sys, subprocess + import sys from platformdirs import user_desktop_dir + from utils import create_process try: # where to put the .lnk @@ -1834,47 +1835,37 @@ class abogen(QWidget): # icon (fallback to exe if missing) icon = get_resource_path("abogen.assets", "icon.ico") if not icon or not os.path.exists(icon): - icon = target + icon = target # Create a more direct PowerShell command + shortcut_ps = shortcut_path.replace("'", "''").replace("\\", "\\\\") + target_ps = target.replace("'", "''").replace("\\", "\\\\") + workdir_ps = os.path.dirname(target).replace("'", "''").replace("\\", "\\\\") + icon_ps = icon.replace("'", "''").replace("\\", "\\\\") + # Create PowerShell script as a single line with no line breaks (more reliable) + ps_cmd = f"$s=New-Object -ComObject WScript.Shell; $lnk=$s.CreateShortcut('{shortcut_ps}'); $lnk.TargetPath='{target_ps}'; $lnk.WorkingDirectory='{workdir_ps}'; $lnk.IconLocation='{icon_ps}'; $lnk.Save()" + + # Run PowerShell with the command directly + proc = create_process("powershell -NoProfile -ExecutionPolicy Bypass -Command \"" + ps_cmd + "\"") + proc.wait() + + if proc.returncode == 0: + QMessageBox.information( + self, + "Shortcut Created", + f"Shortcut created on desktop:\n{shortcut_path}", + ) + else: + QMessageBox.critical( + self, + "Shortcut Error", + f"PowerShell failed with exit code: {proc.returncode}" + ) - # PowerShell command to make the shortcut - ps_cmd = r""" - $s = New-Object -ComObject WScript.Shell - $lnk = $s.CreateShortcut('{lnk}') - $lnk.TargetPath = '{tgt}' - $lnk.WorkingDirectory = '{cwd}' - $lnk.IconLocation = '{ico}' - $lnk.Save() - """.format( - lnk=shortcut_path.replace("'", "''"), - tgt=target.replace("'", "''"), - cwd=os.path.dirname(target).replace("'", "''"), - ico=icon.replace("'", "''"), - ).strip() - - subprocess.run( - ["powershell", "-NoProfile", "-Command", ps_cmd], - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - - QMessageBox.information( - self, - "Shortcut Created", - f"Shortcut created on desktop:\n{shortcut_path}", - ) - - except subprocess.CalledProcessError as e: - QMessageBox.critical( - self, - "Shortcut Error", - f"PowerShell failed:\n{e.stderr.decode(errors='ignore')}", - ) except Exception as e: QMessageBox.critical( self, "Shortcut Error", f"Could not create shortcut:\n{e}" ) + def toggle_check_updates(self, checked): self.config["check_updates"] = checked save_config(self.config) diff --git a/abogen/utils.py b/abogen/utils.py index 58aab35..9282588 100644 --- a/abogen/utils.py +++ b/abogen/utils.py @@ -1,4 +1,5 @@ import os +import sys import json import warnings import platform @@ -102,6 +103,86 @@ def clean_text(text, *args, **kwargs): return text +default_encoding = sys.getfilesystemencoding() + +def create_process(cmd, stdin=None, text=True): + import logging + logger = logging.getLogger(__name__) + + # Configure root logger to output to console if not already configured + root = logging.getLogger() + if not root.handlers: + handler = logging.StreamHandler(sys.stdout) + formatter = logging.Formatter('%(message)s') + handler.setFormatter(formatter) + root.addHandler(handler) + root.setLevel(logging.INFO) + + kwargs = { + "shell": True, + "stdout": subprocess.PIPE, + "stderr": subprocess.STDOUT, + "bufsize": 1, # Line buffered + } + + if text: + # Configure for text I/O + kwargs["text"] = True + kwargs["encoding"] = default_encoding + kwargs["errors"] = "replace" + else: + # Configure for binary I/O + kwargs["text"] = False + # For binary mode, 'encoding' and 'errors' arguments must not be passed to Popen + + if stdin is not None: + kwargs["stdin"] = stdin + + if platform.system() == "Windows": + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + startupinfo.wShowWindow = subprocess.SW_HIDE + kwargs.update( + {"startupinfo": startupinfo, "creationflags": subprocess.CREATE_NO_WINDOW} + ) + + # Print the command being executed + print(f"Executing: {cmd if isinstance(cmd, str) else ' '.join(cmd)}") + + proc = subprocess.Popen(cmd, **kwargs) + + # Stream output to console in real-time + if proc.stdout: + def _stream_output(stream): + if text: + # For text mode, read character by character for real-time output + while True: + char = stream.read(1) + if not char: + break + # Direct write to stdout for immediate feedback + sys.stdout.write(char) + sys.stdout.flush() + else: + # For binary mode, read small chunks + while True: + chunk = stream.read(1) # Read byte by byte for real-time output + if not chunk: + break + try: + # Try to decode binary data for display + sys.stdout.write(chunk.decode(default_encoding, errors='replace')) + sys.stdout.flush() + except Exception: + pass + stream.close() + + # Start a daemon thread to handle output streaming + Thread(target=_stream_output, args=(proc.stdout,), daemon=True).start() + + return proc + + def load_config(): try: with open(get_user_config_path(), "r", encoding="utf-8") as f: @@ -148,21 +229,15 @@ def prevent_sleep_start(): 0x80000000 | 0x00000001 | 0x00000040 ) # ES_CONTINUOUS | ES_SYSTEM_REQUIRED | ES_AWAYMODE_REQUIRED elif system == "Darwin": - _sleep_procs["Darwin"] = subprocess.Popen(["caffeinate"]) + _sleep_procs["Darwin"] = create_process("caffeinate") elif system == "Linux": try: - _sleep_procs["Linux"] = subprocess.Popen( - [ - "systemd-inhibit", - "--what=sleep", - "--why=TextToAudiobook conversion", - "sleep", - "999999", - ] + _sleep_procs["Linux"] = create_process( + "systemd-inhibit --what=sleep --why=TextToAudiobook conversion sleep 999999" ) except Exception: try: - subprocess.Popen(["xdg-screensaver", "reset"]) + create_process("xdg-screensaver reset") except Exception: pass