Improve Audacity connections

Replace pipeclient with much simpler audacity_controller
Better error checking
Deal with Audacity going away
Fixes #264
This commit is contained in:
Keith Edmunds 2024-11-27 10:54:04 +00:00
parent ac2e811ed6
commit 28897500c8
7 changed files with 190 additions and 414 deletions

158
app/audacity_controller.py Normal file
View File

@ -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()

View File

@ -438,6 +438,14 @@ class _Music:
self.player = None
class ApplicationError(Exception):
"""
Custom exception
"""
pass
@singleton
@dataclass
class MusicMusterSignals(QObject):

View File

@ -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

View File

@ -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"""

View File

@ -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:
<http://www.gnu.org/licenses/old-licenses/gpl-2.0.html />
"""
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()

View File

@ -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"""