From 28897500c80c9c0484e10c77dbb61149fed4b095 Mon Sep 17 00:00:00 2001 From: Keith Edmunds Date: Wed, 27 Nov 2024 10:54:04 +0000 Subject: [PATCH] Improve Audacity connections Replace pipeclient with much simpler audacity_controller Better error checking Deal with Audacity going away Fixes #264 --- app/audacity_controller.py | 158 +++++++++++ app/classes.py | 8 + app/config.py | 4 +- app/musicmuster.py | 15 -- app/pipeclient.py | 313 ---------------------- app/playlists.py | 106 ++------ audacity_control.py => audacity_tester.py | 0 7 files changed, 190 insertions(+), 414 deletions(-) create mode 100644 app/audacity_controller.py delete mode 100755 app/pipeclient.py rename audacity_control.py => audacity_tester.py (100%) diff --git a/app/audacity_controller.py b/app/audacity_controller.py new file mode 100644 index 0000000..3c453df --- /dev/null +++ b/app/audacity_controller.py @@ -0,0 +1,158 @@ +# Standard library imports +import os +import psutil +import socket +import select + +# PyQt imports + +# Third party imports + +# App imports +from classes import ApplicationError +from config import Config +from log import log + + +class AudacityController: + def __init__( + self, + method: str = "pipe", + socket_host: str = "localhost", + socket_port: int = 12345, + timeout: int = Config.AUDACITY_TIMEOUT_SECONDS, + ) -> None: + """ + Initialize the AudacityController. + :param method: Communication method ('pipe' or 'socket'). + :param socket_host: Host for socket connection (if using sockets). + :param socket_port: Port for socket connection (if using sockets). + :param timeout: Timeout in seconds for pipe operations. + """ + + self.method = method + self.path: str = "" + self.timeout = timeout + if method == "pipe": + user_uid = os.getuid() # Get the user's UID + self.pipe_to = f"/tmp/audacity_script_pipe.to.{user_uid}" + self.pipe_from = f"/tmp/audacity_script_pipe.from.{user_uid}" + if not (os.path.exists(self.pipe_to) and os.path.exists(self.pipe_from)): + raise ApplicationError( + "AudacityController: Audacity pipes not found. Ensure scripting is enabled " + f"and pipes exist at {self.pipe_to} and {self.pipe_from}." + ) + elif method == "socket": + self.socket_host = socket_host + self.socket_port = socket_port + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + self.sock.connect((self.socket_host, self.socket_port)) + self.sock.settimeout(self.timeout) + except socket.error as e: + raise ApplicationError(f"Failed to connect to Audacity socket: {e}") + else: + raise ApplicationError("Invalid method. Use 'pipe' or 'socket'.") + + def close(self): + """ + Close the connection (for sockets). + """ + if self.method == "socket": + self.sock.close() + + def export(self) -> None: + """ + Export file from Audacity + """ + + log.info("export()") + + self._sanity_check() + + select_status = self._send_command("SelectAll") + log.info(f"{select_status=}") + + export_cmd = f'Export2: Filename="{self.path}" NumChannels=2' + export_status = self._send_command(export_cmd) + log.info(f"{export_status=}") + self.path = "" + if not export_status.startswith("Exported"): + raise ApplicationError(f"Error writing from Audacity: {export_status=}") + + def open(self, path: str) -> None: + """ + Open path in Audacity. Escape filename. + """ + + log.info(f"open({path=})") + + self._sanity_check() + + escaped_path = path.replace('"', '\\"') + cmd = f'Import2: Filename="{escaped_path}"' + status = self._send_command(cmd) + self.path = path + + log.info(f"_open_in_audacity {path=}, {status=}") + + def _sanity_check(self) -> None: + """ + Check Audactity running and basic connectivity. + """ + + log.info("_sanity_check") + + # Check Audacity is running + if "audacity" not in [i.name() for i in psutil.process_iter()]: + log.warning("Audactity not running") + raise ApplicationError("Audacity is not running") + + # Check pipes exist + if self.method == "pipe": + if not (os.path.exists(self.pipe_to) and os.path.exists(self.pipe_from)): + raise ApplicationError( + "AudacityController: Audacity pipes not found. Ensure scripting is enabled " + f"and pipes exist at {self.pipe_to} and {self.pipe_from}." + ) + + # Send test command to Audacity + response = self._send_command(Config.AUDACITY_TEST_COMMAND) + if response != Config.AUDACITY_TEST_RESPONSE: + raise ApplicationError( + "Error testing Audacity connectivity\n" + f"Sent: {Config.AUDACITY_TEST_COMMAND}" + f"Received: {response}" + ) + + def _send_command(self, command: str) -> str: + """ + Send a command to Audacity. + :param command: Command to send (e.g., 'SelectAll'). + :return: Response from Audacity. + """ + log.info(f"_send_command({command=})") + + if self.method == "pipe": + try: + with open(self.pipe_to, "w") as to_pipe: + to_pipe.write(command + "\n") + with open(self.pipe_from, "r") as from_pipe: + ready, _, _ = select.select([from_pipe], [], [], self.timeout) + if ready: + response = from_pipe.readline() + else: + raise TimeoutError( + f"Timeout waiting for response from {self.pipe_from}" + ) + except Exception as e: + raise RuntimeError(f"Error communicating with Audacity via pipes: {e}") + elif self.method == "socket": + try: + self.sock.sendall((command + "\n").encode("utf-8")) + response = self.sock.recv(1024).decode("utf-8") + except socket.timeout: + raise TimeoutError("Timeout waiting for response from Audacity socket.") + except Exception as e: + raise RuntimeError(f"Error communicating with Audacity via socket: {e}") + return response.strip() diff --git a/app/classes.py b/app/classes.py index 9401326..5399dea 100644 --- a/app/classes.py +++ b/app/classes.py @@ -438,6 +438,14 @@ class _Music: self.player = None +class ApplicationError(Exception): + """ + Custom exception + """ + + pass + + @singleton @dataclass class MusicMusterSignals(QObject): diff --git a/app/config.py b/app/config.py index ab5c6dd..fa26ea5 100644 --- a/app/config.py +++ b/app/config.py @@ -12,7 +12,9 @@ from typing import List, Optional class Config(object): - AUDACITY_TIMEOUT_TENTHS = 100 + AUDACITY_TEST_COMMAND = "Message" + AUDACITY_TEST_RESPONSE = "Some message" + AUDACITY_TIMEOUT_SECONDS = 20 AUDIO_SEGMENT_CHUNK_SIZE = 10 BITRATE_LOW_THRESHOLD = 192 BITRATE_OK_THRESHOLD = 300 diff --git a/app/musicmuster.py b/app/musicmuster.py index ceec98d..f4227ca 100755 --- a/app/musicmuster.py +++ b/app/musicmuster.py @@ -44,7 +44,6 @@ from PyQt6.QtWidgets import ( ) # Third party imports -import pipeclient from pygame import mixer from sqlalchemy.exc import IntegrityError from sqlalchemy.orm.session import Session @@ -290,9 +289,6 @@ class Window(QMainWindow, Ui_MainWindow): self.active_proxy_model = lambda: self.tabPlaylist.currentWidget().model() self.move_source_rows: Optional[List[int]] = None self.move_source_model: Optional[PlaylistProxyModel] = None - self.audacity_file_path: Optional[str] = None - self.audacity_client: Optional[pipeclient.PipeClient] = None - self.initialise_audacity() self.disable_selection_timing = False self.clock_counter = 0 @@ -851,17 +847,6 @@ class Window(QMainWindow, Ui_MainWindow): return True - def initialise_audacity(self) -> None: - """ - Initialise access to audacity - """ - - try: - self.audacity_client = pipeclient.PipeClient() - log.debug(f"{hex(id(self.audacity_client))=}") - except RuntimeError as e: - log.error(f"Unable to initialise Audacity: {str(e)}") - def insert_header(self) -> None: """Show dialog box to enter header text and add to playlist""" diff --git a/app/pipeclient.py b/app/pipeclient.py deleted file mode 100755 index 858b3c5..0000000 --- a/app/pipeclient.py +++ /dev/null @@ -1,313 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -"""Automate Audacity via mod-script-pipe. - -Pipe Client may be used as a command-line script to send commands to -Audacity via the mod-script-pipe interface, or loaded as a module. -Requires Python 3. -(Python 2.7 is now obsolete, so no longer supported) - -====================== -Command Line Interface -====================== - - usage: pipeclient.py [-h] [-t] [-s ] [-d] - -Arguments ---------- - -h,--help: optional - show short help and exit - -t, --timeout: float, optional - timeout for reply in seconds (default: 10) - -s, --show-time: bool, optional - show command execution time (default: True) - -d, --docs: optional - show this documentation and exit - -Example -------- - $ python3 pipeclient.py -t 20 -s False - - Launches command line interface with 20 second time-out for - returned message, and don't show the execution time. - - When prompted, enter the command to send (not quoted), or 'Q' to quit. - - $ Enter command or 'Q' to quit: GetInfo: Type=Tracks Format=LISP - -============ -Module Usage -============ - -Note that on a critical error (such as broken pipe), the module just exits. -If a more graceful shutdown is required, replace the sys.exit()'s with -exceptions. - -Example -------- - - # Import the module: - >>> import pipeclient - - # Create a client instance: - >>> client = pipeclient.PipeClient() - - # Send a command: - >>> client.write("Command", timer=True) - - # Read the last reply: - >>> print(client.read()) - -See Also --------- -PipeClient.write : Write a command to _write_pipe. -PipeClient.read : Read Audacity's reply from pipe. - -Copyright Steve Daulton 2018 -Released under terms of the GNU General Public License version 2: - - -""" - -from io import TextIOWrapper -import os -import sys -import threading -import time -import errno -import argparse - -from typing import Optional - -if sys.version_info[0] < 3: - raise RuntimeError("PipeClient Error: Python 3.x required") - -# Platform specific constants -if sys.platform == "win32": - WRITE_NAME: str = "\\\\.\\pipe\\ToSrvPipe" - READ_NAME: str = "\\\\.\\pipe\\FromSrvPipe" - EOL: str = "\r\n\0" -else: - # Linux or Mac - PIPE_BASE: str = "/tmp/audacity_script_pipe." - WRITE_NAME: str = PIPE_BASE + "to." + str(os.getuid()) - READ_NAME: str = PIPE_BASE + "from." + str(os.getuid()) - EOL: str = "\n" - - -class PipeClient: - """Write / read client access to Audacity via named pipes. - - Normally there should be just one instance of this class. If - more instances are created, they all share the same state. - - __init__ calls _write_thread_start() and _read_thread_start() on - first instantiation. - - Parameters - ---------- - None - - Attributes - ---------- - reader_pipe_broken : event object - Set if pipe reader fails. Audacity may have crashed - reply_ready : event object - flag cleared when command sent and set when response received - timer : bool - When true, time the command execution (default False) - reply : string - message received when Audacity completes the command - - See Also - -------- - write : Write a command to _write_pipe. - read : Read Audacity's reply from pipe. - - """ - - reader_pipe_broken = threading.Event() - reply_ready = threading.Event() - - _shared_state: dict = {} - - def __new__(cls, *p, **k): - self = object.__new__(cls, *p, **k) - self.__dict__ = cls._shared_state - return self - - def __init__(self) -> None: - self.timer: bool = False - self._start_time: float = 0 - self._write_pipe: Optional[TextIOWrapper] = None - self.reply: str = "" - if not self._write_pipe: - self._write_thread_start() - self._read_thread_start() - - def _write_thread_start(self) -> None: - """Start _write_pipe thread""" - # Pipe is opened in a new thread so that we don't - # freeze if Audacity is not running. - write_thread = threading.Thread(target=self._write_pipe_open) - write_thread.daemon = True - write_thread.start() - # Allow a little time for connection to be made. - time.sleep(0.1) - if not self._write_pipe: - raise RuntimeError("PipeClientError: Write pipe cannot be opened.") - - def _write_pipe_open(self) -> None: - """Open _write_pipe.""" - self._write_pipe = open(WRITE_NAME, "w") - - def _read_thread_start(self) -> None: - """Start read_pipe thread.""" - read_thread = threading.Thread(target=self._reader) - read_thread.daemon = True - read_thread.start() - - def write(self, command: str, timer: Optional[bool] = False) -> None: - """Write a command to _write_pipe. - - Parameters - ---------- - command : string - The command to send to Audacity - timer : bool, optional - If true, time the execution of the command - - Example - ------- - write("GetInfo: Type=Labels", timer=True): - - """ - - # If write pipe not defined, return - if self._write_pipe is None: - return - - if timer: - self.timer = timer - self._write_pipe.write(command + EOL) - # Check that read pipe is alive - if PipeClient.reader_pipe_broken.is_set(): - raise RuntimeError("PipeClient: Read-pipe error.") - try: - self._write_pipe.flush() - if self.timer: - self._start_time = time.time() - self.reply = "" - PipeClient.reply_ready.clear() - except IOError as err: - if err.errno == errno.EPIPE: - raise RuntimeError("PipeClient: Write-pipe error.") - else: - raise - - def _reader(self) -> None: - """Read FIFO in worker thread.""" - # Thread will wait at this read until it connects. - # Connection should occur as soon as _write_pipe has connected. - with open(READ_NAME, "r") as read_pipe: - message = "" - pipe_ok = True - while pipe_ok: - line = read_pipe.readline() - # Stop timer as soon as we get first line of response. - stop_time = time.time() - while pipe_ok and line != "\n": - message += line - line = read_pipe.readline() - if line == "": - # No data in read_pipe indicates that the pipe - # is broken (Audacity may have crashed). - PipeClient.reader_pipe_broken.set() - pipe_ok = False - if self.timer: - xtime = (stop_time - self._start_time) * 1000 - message += f"Execution time: {xtime:.2f}ms" - self.reply = message - PipeClient.reply_ready.set() - message = "" - - def read(self) -> str: - """Read Audacity's reply from pipe. - - Returns - ------- - string - The reply from the last command sent to Audacity, or null string - if reply not received. Null string usually indicates that Audacity - is still processing the last command. - - """ - if not PipeClient.reply_ready.is_set(): - return "" - return self.reply - - -def bool_from_string(strval: str) -> bool: - """Return boolean value from string""" - if strval.lower() in ("true", "t", "1", "yes", "y"): - return True - if strval.lower() in ("false", "f", "0", "no", "n"): - return False - raise argparse.ArgumentTypeError("Boolean value expected.") - - -def main() -> None: - """Interactive command-line for PipeClient""" - - parser = argparse.ArgumentParser() - parser.add_argument( - "-t", - "--timeout", - type=float, - metavar="", - default=10, - help="timeout for reply in seconds (default: 10", - ) - parser.add_argument( - "-s", - "--show-time", - metavar="True/False", - nargs="?", - type=bool_from_string, - const="t", - default="t", - dest="show", - help="show command execution time (default: True)", - ) - parser.add_argument( - "-d", "--docs", action="store_true", help="show documentation and exit" - ) - args = parser.parse_args() - - if args.docs: - print(__doc__) - sys.exit(0) - - client: PipeClient = PipeClient() - while True: - reply: str = "" - message: str = input("\nEnter command or 'Q' to quit: ") - start = time.time() - if message.upper() == "Q": - sys.exit(0) - elif message == "": - pass - else: - client.write(message, timer=args.show) - while reply == "": - time.sleep(0.1) # allow time for reply - if time.time() - start > args.timeout: - reply = "PipeClient: Reply timed-out." - else: - reply = client.read() - print(reply) - - -if __name__ == "__main__": - main() diff --git a/app/playlists.py b/app/playlists.py index c144917..42c9384 100644 --- a/app/playlists.py +++ b/app/playlists.py @@ -1,6 +1,5 @@ # Standard library imports from typing import Callable, cast, List, Optional, TYPE_CHECKING -import psutil import time # PyQt imports @@ -35,7 +34,8 @@ from PyQt6.QtWidgets import ( # Third party imports # App imports -from classes import Col, MusicMusterSignals, TrackInfo, track_sequence +from audacity_controller import AudacityController +from classes import ApplicationError, Col, MusicMusterSignals, TrackInfo, track_sequence from config import Config from dialogs import TrackSelectDialog from helpers import ( @@ -233,6 +233,13 @@ class PlaylistTab(QTableView): self.setModel(self.proxy_model) self._set_column_widths() + # Set up for Audacity + try: + self.ac = AudacityController() + except ApplicationError as e: + self.ac = None + show_warning(self.musicmuster, "Audacity error", str(e)) + # Stretch last column *after* setting column widths which is # *much* faster h_header = self.horizontalHeader() @@ -404,52 +411,6 @@ class PlaylistTab(QTableView): dlg.exec() session.commit() - def _audactity_command(self, cmd: str) -> bool: - """ - Send cmd to Audacity and monitor for response. Return True if successful - else False. - """ - - log.debug(f"_audacity({cmd=})") - - # Notify user if audacity not running - if "audacity" not in [i.name() for i in psutil.process_iter()]: - log.warning("Audactity not running") - show_warning(self.musicmuster, "Audacity", "Audacity is not running") - return False - - if not self.musicmuster.audacity_client: - self.musicmuster.initialise_audacity() - if not self.musicmuster.audacity_client: - log.error("Unable to access Audacity client") - return False - - self.musicmuster.audacity_client.write(cmd, timer=True) - - reply = "" - count = 0 - while reply == "" and count < Config.AUDACITY_TIMEOUT_TENTHS: - time.sleep(0.1) - reply = self.musicmuster.audacity_client.read() - count += 1 - - log.debug(f"_audactity_command: {count=}, {reply=}") - status = False - timing = "" - msgs = reply.split("\n") - for msg in msgs: - if msg == "BatchCommand finished: OK": - status = True - elif msg.startswith("Execution time:"): - timing = msg - if not status: - log.error(f"_audactity_command {msgs=}") - return False - if timing: - log.debug(f"_audactity_command {timing=}") - - return True - def _build_context_menu(self, item: QTableWidgetItem) -> None: """Used to process context (right-click) menu, which is defined here""" @@ -473,7 +434,7 @@ class PlaylistTab(QTableView): # Open/import in/from Audacity if track_row and not this_is_current_row: - if track_path == self.musicmuster.audacity_file_path: + if track_path == self.ac.path: # This track was opened in Audacity self._add_context_menu( "Update from Audacity", @@ -575,7 +536,7 @@ class PlaylistTab(QTableView): that we have an edit open. """ - self.musicmuster.audacity_file_path = None + self.ac.path = None def clear_selection(self) -> None: """Unselect all tracks and reset drag mode""" @@ -718,31 +679,12 @@ class PlaylistTab(QTableView): Import current Audacity track to passed row """ - path = self.source_model.get_row_track_path(row_number) - if not path: - log.error(f"_import_from_audacity: can't get path for {row_number=}") - return - - select_cmd = "SelectAll:" - status = self._audactity_command(select_cmd) - if not status: - log.error(f"_import_from_audacity select {status=}") - show_warning( - self.musicmuster, "Audacity", "Error selecting track in Audacity" - ) - return - - export_cmd = f'Export2: Filename="{path}" NumChannels=2' - status = self._audactity_command(export_cmd) - if not status: - log.error(f"_import_from_audacity export {status=}") - show_warning( - self.musicmuster, "Audacity", "Error exporting track from Audacity" - ) - return - - self.musicmuster.audacity_file_path = None - self._rescan(row_number) + try: + self.ac.export() + self._rescan(row_number) + except ApplicationError as e: + show_warning(self.musicmuster, "Audacity error", str(e)) + self._cancel_audacity() def _info_row(self, row_number: int) -> None: """Display popup with info re row""" @@ -774,21 +716,15 @@ class PlaylistTab(QTableView): Open track in passed row in Audacity """ - if not self.musicmuster.audacity_client: - self.musicmuster.initialise_audacity() - path = self.source_model.get_row_track_path(row_number) if not path: log.error(f"_open_in_audacity: can't get path for {row_number=}") return - escaped_path = path.replace('"', '\\"') - cmd = f'Import2: Filename="{escaped_path}"' - status = self._audactity_command(cmd) - if status: - self.musicmuster.audacity_file_path = path - - log.debug(f"_open_in_audacity {path=}, {status=}") + try: + self.ac.open(path) + except ApplicationError as e: + show_warning(self.musicmuster, "Audacity error", str(e)) def _rescan(self, row_number: int) -> None: """Rescan track""" diff --git a/audacity_control.py b/audacity_tester.py similarity index 100% rename from audacity_control.py rename to audacity_tester.py