diff --git a/camilladsp/__init__.py b/camilladsp/__init__.py index ca9e848..41a6c47 100644 --- a/camilladsp/__init__.py +++ b/camilladsp/__init__.py @@ -2,8 +2,11 @@ Python library for communicating with CamillaDSP. """ -from camilladsp.camilladsp import CamillaClient, CamillaError +from camilladsp.camilladsp import CamillaClient from camilladsp.datastructures import ( ProcessingState, StopReason, + CamillaError, ) + +from camilladsp.versions import VERSION diff --git a/camilladsp/camilladsp.py b/camilladsp/camilladsp.py index fffcd62..8f82d90 100644 --- a/camilladsp/camilladsp.py +++ b/camilladsp/camilladsp.py @@ -11,907 +11,15 @@ Reading the main volume is then done by calling `my_client.volume.main()`. """ -import json -import math -from typing import Dict, Tuple, List, Optional, Union, TypedDict -from threading import Lock -import yaml -from websocket import create_connection, WebSocket # type: ignore - -from .datastructures import ( - ProcessingState, - StopReason, - _STANDARD_RATES, - _state_from_string, - _reason_from_reply, -) - -VERSION = "3.0.0" - - -class CamillaError(ValueError): - """ - A class representing errors returned by CamillaDSP. - """ - - -class _CamillaWS: - def __init__(self, host: str, port: int): - """ - Create a new CamillaWS. - - Args: - host (str): Hostname where CamillaDSP runs. - port (int): Port number of the CamillaDSP websocket server. - """ - self._host = host - self._port = int(port) - self._ws: Optional[WebSocket] = None - self.cdsp_version: Optional[Tuple[str, str, str]] = None - self._lock = Lock() - - def query(self, command: str, arg=None): - """ - Send a command and return the response. - - Args: - command (str): The command to send. - arg: Parameter to send with the command. - - Returns: - Any | None: The return value for commands that return values, None for others. - """ - if self._ws is None: - raise IOError("Not connected to CamillaDSP") - if arg is not None: - query = json.dumps({command: arg}) - else: - query = json.dumps(command) - try: - with self._lock: - self._ws.send(query) - rawrepl = self._ws.recv() - except Exception as err: - self._ws = None - raise IOError("Lost connection to CamillaDSP") from err - return self._handle_reply(command, rawrepl) - - def _handle_reply(self, command: str, rawreply: Union[str, bytes]): - try: - reply = json.loads(rawreply) - value = None - if command in reply: - state = reply[command]["result"] - if "value" in reply[command]: - value = reply[command]["value"] - if state == "Error" and value is not None: - raise CamillaError(value) - if state == "Error" and value is None: - raise CamillaError("Command returned an error") - if state == "Ok" and value is not None: - return value - return None - raise IOError(f"Invalid response received: {rawreply!r}") - except json.JSONDecodeError as err: - raise IOError(f"Invalid response received: {rawreply!r}") from err - - def _update_version(self, resp: str): - version = resp.split(".", 3) - if len(version) < 3: - version.extend([""] * (3 - len(version))) - self.cdsp_version = (version[0], version[1], version[2]) - - def connect(self): - """ - Connect to the websocket of CamillaDSP. - """ - try: - with self._lock: - self._ws = create_connection(f"ws://{self._host}:{self._port}") - rawvers = self.query("GetVersion") - self._update_version(rawvers) - except Exception as _e: - self._ws = None - raise - - def disconnect(self): - """ - Close the connection to the websocket. - """ - if self._ws is not None: - try: - with self._lock: - self._ws.close() - except Exception as _e: # pylint: disable=broad-exception-caught - pass - self._ws = None - - def is_connected(self): - """ - Is websocket connected? - - Returns: - bool: True if connected, False otherwise. - """ - return self._ws is not None - - -class _CommandGroup: - """ - Collection of methods - """ - - # pylint: disable=too-few-public-methods - - def __init__(self, client: _CamillaWS): - self.client = client - - -class Status(_CommandGroup): - """ - Collection of methods for reading status - """ - - def rate_adjust(self) -> float: - """ - Get current value for rate adjust, 1.0 means 1:1 resampling. - - Returns: - float: Rate adjust value. - """ - adj = self.client.query("GetRateAdjust") - return float(adj) - - def buffer_level(self) -> int: - """ - Get current buffer level of the playback device. - - Returns: - int: Buffer level in frames. - """ - level = self.client.query("GetBufferLevel") - return int(level) - - def clipped_samples(self) -> int: - """ - Get number of clipped samples since the config was loaded. - - Returns: - int: Number of clipped samples. - """ - clipped = self.client.query("GetClippedSamples") - return int(clipped) - - def processing_load(self) -> float: - """ - Get processing load in percent. - - Returns: - float: Current load. - """ - load = self.client.query("GetProcessingLoad") - return float(load) - - -class Levels(_CommandGroup): - """ - Collection of methods for level monitoring - """ - - def range(self) -> float: - """ - Get signal range for the last processed chunk. Full scale is 2.0. - """ - sigrange = self.client.query("GetSignalRange") - return float(sigrange) - - def range_decibel(self) -> float: - """ - Get current signal range in dB for the last processed chunk. - Full scale is 0 dB. - """ - sigrange = self.range() - if sigrange > 0.0: - range_decibel = 20.0 * math.log10(sigrange / 2.0) - else: - range_decibel = -1000 - return range_decibel - - def capture_rms(self) -> List[float]: - """ - Get capture signal level rms in dB for the last processed chunk. - Full scale is 0 dB. Returns a list with one element per channel. - """ - sigrms = self.client.query("GetCaptureSignalRms") - return sigrms - - def playback_rms(self) -> List[float]: - """ - Get playback signal level rms in dB for the last processed chunk. - Full scale is 0 dB. Returns a list with one element per channel. - """ - sigrms = self.client.query("GetPlaybackSignalRms") - return sigrms - - def capture_peak(self) -> List[float]: - """ - Get capture signal level peak in dB for the last processed chunk. - Full scale is 0 dB. Returns a list with one element per channel. - """ - sigpeak = self.client.query("GetCaptureSignalPeak") - return sigpeak - - def playback_peak(self) -> List[float]: - """ - Get playback signal level peak in dB for the last processed chunk. - Full scale is 0 dB. Returns a list with one element per channel. - """ - sigpeak = self.client.query("GetPlaybackSignalPeak") - return sigpeak - - def playback_peak_since(self, interval: float) -> List[float]: - """ - Get playback signal level peak in dB for the last `interval` seconds. - Full scale is 0 dB. Returns a list with one element per channel. - - Args: - interval (float): Length of interval in seconds. - """ - sigpeak = self.client.query("GetPlaybackSignalPeakSince", arg=float(interval)) - return sigpeak - - def playback_rms_since(self, interval: float) -> List[float]: - """ - Get playback signal level rms in dB for the last `interval` seconds. - Full scale is 0 dB. Returns a list with one element per channel. - - Args: - interval (float): Length of interval in seconds. - """ - sigrms = self.client.query("GetPlaybackSignalRmsSince", arg=float(interval)) - return sigrms - - def capture_peak_since(self, interval: float) -> List[float]: - """ - Get capture signal level peak in dB for the last `interval` seconds. - Full scale is 0 dB. Returns a list with one element per channel. - - Args: - interval (float): Length of interval in seconds. - """ - sigpeak = self.client.query("GetCaptureSignalPeakSince", arg=float(interval)) - return sigpeak - - def capture_rms_since(self, interval: float) -> List[float]: - """ - Get capture signal level rms in dB for the last `interval` seconds. - Full scale is 0 dB. Returns a list with one element per channel. - - Args: - interval (float): Length of interval in seconds. - """ - sigrms = self.client.query("GetCaptureSignalRmsSince", arg=float(interval)) - return sigrms - - def playback_peak_since_last(self) -> List[float]: - """ - Get playback signal level peak in dB since the last read by the same client. - Full scale is 0 dB. Returns a list with one element per channel. - """ - sigpeak = self.client.query("GetPlaybackSignalPeakSinceLast") - return sigpeak - - def playback_rms_since_last(self) -> List[float]: - """ - Get playback signal level rms in dB since the last read by the same client. - Full scale is 0 dB. Returns a list with one element per channel. - """ - sigrms = self.client.query("GetPlaybackSignalRmsSinceLast") - return sigrms - - def capture_peak_since_last(self) -> List[float]: - """ - Get capture signal level peak in dB since the last read by the same client. - Full scale is 0 dB. Returns a list with one element per channel. - """ - sigpeak = self.client.query("GetCaptureSignalPeakSinceLast") - return sigpeak - - def capture_rms_since_last(self) -> List[float]: - """ - Get capture signal level rms in dB since the last read by the same client. - Full scale is 0 dB. Returns a list with one element per channel. - """ - sigrms = self.client.query("GetCaptureSignalRmsSinceLast") - return sigrms - - def levels(self) -> Dict[str, List[float]]: - """ - Get all signal levels in dB for the last processed chunk. - Full scale is 0 dB. - The values are returned as a json object with keys - `playback_peak`, `playback_rms`, `capture_peak` and `capture_rms`. - Each dict item is a list with one element per channel. - """ - siglevels = self.client.query("GetSignalLevels") - return siglevels - - def levels_since(self, interval: float) -> Dict[str, List[float]]: - """ - Get all signal levels in dB for the last `interval` seconds. - Full scale is 0 dB. - The values are returned as a json object with keys - `playback_peak`, `playback_rms`, `capture_peak` and `capture_rms`. - Each dict item is a list with one element per channel. - - Args: - interval (float): Length of interval in seconds. - """ - siglevels = self.client.query("GetSignalLevelsSince", arg=float(interval)) - return siglevels - - def levels_since_last(self) -> Dict[str, List[float]]: - """ - Get all signal levels in dB since the last read by the same client. - Full scale is 0 dB. - The values are returned as a json object with keys - `playback_peak`, `playback_rms`, `capture_peak` and `capture_rms`. - Each dict item is a list with one element per channel. - """ - siglevels = self.client.query("GetSignalLevelsSinceLast") - return siglevels - - def peaks_since_start(self) -> Dict[str, List[float]]: - """ - Get the playback and capture peak level since processing started. - The values are returned as a json object with keys `playback` and `capture`. - """ - peaks = self.client.query("GetSignalPeaksSinceStart") - return peaks - - def reset_peaks_since_start(self): - """ - Reset the peak level values. - """ - self.client.query("ResetSignalPeaksSinceStart") - - -class Config(_CommandGroup): - """ - Collection of methods for configuration management - """ - - def file_path(self) -> Optional[str]: - """ - Get path to current config file. - - Returns: - str | None: Path to config file, or None. - """ - name = self.client.query("GetConfigFilePath") - return name - - def set_file_path(self, value: str): - """ - Set path to config file, without loading it. - Call `reload()` to apply the new config file. - - Args: - value (str): Path to config file. - """ - self.client.query("SetConfigFilePath", arg=value) - - def active_raw(self) -> Optional[str]: - """ - Get the active configuration in raw yaml format (as a string). - - Returns: - str | None: Current config as a raw yaml string, or None. - """ - config_string = self.client.query("GetConfig") - return config_string - - def set_active_raw(self, config_string: str): - """ - Upload and apply a new configuration in raw yaml format (as a string). - - Args: - config_string (str): Config as yaml string. - """ - self.client.query("SetConfig", arg=config_string) - - def active_json(self) -> Optional[str]: - """ - Get the active configuration in raw json format (as a string). - - Returns: - str | None: Current config as a raw json string, or None. - """ - config_string = self.client.query("GetConfigJson") - return config_string - - def set_active_json(self, config_string: str): - """ - Upload and apply a new configuration in raw json format (as a string). - - Args: - config_string (str): Config as json string. - """ - self.client.query("SetConfigJson", arg=config_string) - - def active(self) -> Optional[Dict]: - """ - Get the active configuration as a Python object. - - Returns: - Dict | None: Current config as a Python dict, or None. - """ - config_string = self.active_raw() - if config_string is None: - return None - config_object = yaml.safe_load(config_string) - return config_object - - def previous(self) -> Optional[Dict]: - """ - Get the previously active configuration as a Python object. - - Returns: - Dict | None: Previous config as a Python dict, or None. - """ - config_string = self.client.query("GetPreviousConfig") - config_object = yaml.safe_load(config_string) - return config_object - - def parse_yaml(self, config_string: str) -> Dict: - """ - Parse a config from yaml string and return the contents - as a Python object, with defaults filled out with their default values. - - Args: - config_string (str): A config as raw yaml string. - - Returns: - Dict | None: Parsed config as a Python dict. - """ - config_raw = self.client.query("ReadConfig", arg=config_string) - config_object = yaml.safe_load(config_raw) - return config_object - - def read_and_parse_file(self, filename: str) -> Dict: - """ - Read and parse a config file from disk and return the contents as a Python object. - - Args: - filename (str): Path to a config file. - - Returns: - Dict | None: Parsed config as a Python dict. - """ - config_raw = self.client.query("ReadConfigFile", arg=filename) - config = yaml.safe_load(config_raw) - return config - - def set_active(self, config_object: Dict): - """ - Upload and apply a new configuration from a Python object. - - Args: - config_object (Dict): A configuration as a Python dict. - """ - config_raw = yaml.dump(config_object) - self.set_active_raw(config_raw) - - def validate(self, config_object: Dict) -> Dict: - """ - Validate a configuration object. - Returns the validated config with all optional fields filled with defaults. - Raises a CamillaError on errors. - - Args: - config_object (Dict): A configuration as a Python dict. - - Returns: - Dict | None: Validated config as a Python dict. - """ - config_string = yaml.dump(config_object) - validated_string = self.client.query("ValidateConfig", arg=config_string) - validated_object = yaml.safe_load(validated_string) - return validated_object - - def title(self) -> Optional[str]: - """ - Get the title of the active configuration. - - Returns: - str | None: Config title if defined, else None. - """ - title = self.client.query("GetConfigTitle") - return title - - def description(self) -> Optional[str]: - """ - Get the title of the active configuration. - - Returns: - str | None: Config description if defined, else None. - """ - desc = self.client.query("GetConfigDescription") - return desc - - -class Fader(TypedDict): - """ - Class for type annotation of fader volume and mute settings. - """ - - volume: float - mute: bool - - -class Volume(_CommandGroup): - """ - Collection of methods for volume and mute control - """ - - default_min_vol = -150.0 - default_max_vol = 50.0 - - def all(self) -> List[Fader]: - """ - Get volume and mute for all faders with a single call. - - Returns: - List[Fader]: A list of one object per fader, each with `volume` and `mute` properties. - """ - faders = self.client.query("GetFaders") - return faders - - def main_volume(self) -> float: - """ - Get current main volume setting in dB. - Equivalent to calling `volume(0)`. - - Returns: - float: Current volume setting. - """ - vol = self.client.query("GetVolume") - return float(vol) - - def set_main_volume(self, value: float): - """ - Set main volume in dB. - Equivalent to calling `set_volume(0)`. - - Args: - value (float): New volume in dB. - """ - self.client.query("SetVolume", arg=float(value)) - - def volume(self, fader: int) -> float: - """ - Get current volume setting for the given fader in dB. - - Args: - fader (int): Fader to read. - Selected using an integer, 0 for `Main` and 1 to 4 for `Aux1` to `Aux4`. - - Returns: - float: Current volume setting. - """ - _fader, vol = self.client.query("GetFaderVolume", arg=int(fader)) - return float(vol) - - def set_volume(self, fader: int, vol: float): - """ - Set volume for the given fader in dB. - - Args: - fader (int): Fader to control. - Selected using an integer, 0 for `Main` and 1 to 4 for `Aux1` to `Aux4`. - vol (float): New volume setting. - """ - self.client.query("SetFaderVolume", arg=(int(fader), float(vol))) - - def set_volume_external(self, fader: int, vol: float): - """ - Special command for setting the volume when a "Loudness" filter - is being combined with an external volume control (without a "Volume" filter). - Set volume for the given fader in dB. - - Args: - fader (int): Fader to control. - Selected using an integer, 0 for `Main` and 1 to 4 for `Aux1` to `Aux4`. - vol (float): New volume setting. - """ - self.client.query("SetFaderExternalVolume", arg=(int(fader), float(vol))) - - def adjust_volume( - self, - fader: int, - value: float, - min_limit: Optional[float] = None, - max_limit: Optional[float] = None, - ) -> float: - """ - Adjust volume for the given fader in dB. - Positive values increase the volume, negative decrease. - The resulting volume is limited to the range -150 to +50 dB. - This default range can be reduced via the optional - `min_limit` and/or `max_limit` arguments. - - Args: - fader (int): Fader to control. - Selected using an integer, 0 for `Main` and 1 to 4 for `Aux1` to `Aux4`. - value (float): Volume adjustment in dB. - min_limit (float): Lower volume limit to clamp volume at. - max_limit (float): Upper volume limit to clamp volume at. - - - Returns: - float: New volume setting. - """ - arg: Tuple[int, Union[float, Tuple[float, float, float]]] - if max_limit is not None or min_limit is not None: - maxlim = max_limit if max_limit is not None else self.default_max_vol - minlim = min_limit if min_limit is not None else self.default_min_vol - arg = (int(fader), (float(value), float(minlim), float(maxlim))) - else: - arg = (int(fader), float(value)) - _fader, new_vol = self.client.query("AdjustFaderVolume", arg=arg) - return float(new_vol) - - def main_mute(self) -> bool: - """ - Get current main mute setting. - Equivalent to calling `mute(0)`. - - Returns: - bool: True if muted, False otherwise. - """ - mute = self.client.query("GetMute") - return bool(mute) - - def set_main_mute(self, value: bool): - """ - Set main mute, true or false. - Equivalent to calling `set_mute(0)`. - - Args: - value (bool): New mute setting. - """ - self.client.query("SetMute", arg=bool(value)) - - def mute(self, fader: int) -> bool: - """ - Get current mute setting for a fader. - - Args: - fader (int): Fader to read. - Selected using an integer, 0 for `Main` and 1 to 4 for `Aux1` to `Aux4`. - - Returns: - bool: True if muted, False otherwise. - """ - _fader, mute = self.client.query("GetFaderMute", arg=int(fader)) - return bool(mute) - - def set_mute(self, fader: int, value: bool): - """ - Set mute status for a fader, true or false. - - Args: - fader (int): Fader to control. - Selected using an integer, 0 for `Main` and 1 to 4 for `Aux1` to `Aux4`. - value (bool): New mute setting. - """ - self.client.query("SetFaderMute", arg=(int(fader), bool(value))) - - def toggle_mute(self, fader: int) -> bool: - """ - Toggle mute status for a fader. - - Args: - fader (int): Fader to control. - Selected using an integer, 0 for `Main` and 1 to 4 for `Aux1` to `Aux4`. - - Returns: - bool: True if the new status is muted, False otherwise. - """ - _fader, new_mute = self.client.query("ToggleFaderMute", arg=int(fader)) - return new_mute - - -class RateMonitor(_CommandGroup): - """ - Methods for rate monitoring - """ - - def capture_raw(self) -> int: - """ - Get current capture rate, raw value. - - Returns: - int: The current raw capture rate. - """ - rate = self.client.query("GetCaptureRate") - return int(rate) - - def capture(self) -> Optional[int]: - """ - Get current capture rate. - Returns the nearest common rate, as long as it's within +-4% of the measured value. - - Returns: - int: The current capture rate. - """ - rate = self.capture_raw() - if 0.96 * _STANDARD_RATES[0] < rate < 1.04 * _STANDARD_RATES[-1]: - nearest = min(_STANDARD_RATES, key=lambda val: abs(val - rate)) - if 0.96 < rate / nearest < 1.04: - return nearest - return None - - -class Settings(_CommandGroup): - """ - Methods for various settings - """ - - def update_interval(self) -> int: - """ - Get current update interval in ms. - - Returns: - int: Current update interval. - """ - interval = self.client.query("GetUpdateInterval") - return int(interval) - - def set_update_interval(self, value: int): - """ - Set current update interval in ms. - - Args: - value (int): New update interval. - """ - self.client.query("SetUpdateInterval", arg=value) - - -class General(_CommandGroup): - """ - Basic commands - """ - - # ========================= CamillaDSP state ========================= - - def state(self) -> Optional[ProcessingState]: - """ - Get current processing state. - - Returns: - ProcessingState | None: Current processing state. - """ - state = self.client.query("GetState") - return _state_from_string(state) - - def stop_reason(self) -> StopReason: - """ - Get reason why processing stopped. - - Returns: - StopReason: Stop reason enum variant. - """ - reason = self.client.query("GetStopReason") - return _reason_from_reply(reason) - - # ========================= Basic commands ========================= - - def stop(self): - """ - Stop processing and wait for new config if wait mode is active, else exit. - """ - self.client.query("Stop") - - def exit(self): - """ - Stop processing and exit. - """ - self.client.query("Exit") - - def reload(self): - """ - Reload config from disk. - """ - self.client.query("Reload") - - def supported_device_types(self) -> Tuple[List[str], List[str]]: - """ - Read what device types the running CamillaDSP process supports. - Returns a tuple with two lists of device types, - the first for playback and the second for capture. - - Returns: - Tuple[List[str], List[str]]: A tuple containing two lists, - with the supported playback and capture device types. - """ - (playback, capture) = self.client.query("GetSupportedDeviceTypes") - return (playback, capture) - - def state_file_path(self) -> Optional[str]: - """ - Get path to current state file. - - Returns: - str | None: Path to state file, or None. - """ - path = self.client.query("GetStateFilePath") - return path - - def state_file_updated(self) -> bool: - """ - Check if all changes have been saved to the state file. - - Returns: - bool: True if all changes are saved. - """ - updated = self.client.query("GetStateFileUpdated") - return updated - - def list_playback_devices(self, value: str) -> List[Tuple[str, str]]: - """ - List the available playback devices for a given backend. - Returns a list of tuples. Returns the system name and - a descriptive name for each device. - For some backends, those two names are identical. - - Returns: - List[Tuple[str, str]: A list containing tuples of two strings, - with system device name and a descriptive name. - """ - devs = self.client.query("GetAvailablePlaybackDevices", arg=value) - return devs - - def list_capture_devices(self, value: str) -> List[Tuple[str, str]]: - """ - List the available capture devices for a given backend. - Returns a list of tuples. Returns the system name and - a descriptive name for each device. - For some backends, those two names are identical. - - Returns: - List[Tuple[str, str]: A list containing tuples of two strings, - with system device name and a descriptive name. - """ - devs = self.client.query("GetAvailableCaptureDevices", arg=value) - return devs - - -class Versions(_CommandGroup): - """ - Version info - """ - - def camilladsp(self) -> Optional[Tuple[str, str, str]]: - """ - Read CamillaDSP version. - - Returns: - Tuple[List[str], List[str]] | None: A tuple containing the CamillaDSP version, - as (major, minor, patch). - """ - return self.client.cdsp_version - - def library(self) -> Tuple[str, str, str]: - """ - Read pyCamillaDSP library version. - - Returns: - Tuple[List[str], List[str]] | None: A tuple containing the pyCamillaDSP version, - as (major, minor, patch). - """ - ver = VERSION.split(".") - return (ver[0], ver[1], ver[2]) +from .camillaws import _CamillaWS +from .volume import Volume +from .levels import Levels +from .ratemonitor import RateMonitor +from .general import General +from .config import Config +from .settings import Settings +from .status import Status +from .versions import Versions class CamillaClient(_CamillaWS): diff --git a/camilladsp/camillaws.py b/camilladsp/camillaws.py new file mode 100644 index 0000000..e45498c --- /dev/null +++ b/camilladsp/camillaws.py @@ -0,0 +1,113 @@ +""" +Python library for communicating with CamillaDSP. + +This module contains the websocket connection class. +""" + +from typing import Tuple, Optional, Union +from threading import Lock +import json +from websocket import create_connection, WebSocket # type: ignore + +from .datastructures import CamillaError + + +class _CamillaWS: + def __init__(self, host: str, port: int): + """ + Create a new CamillaWS. + + Args: + host (str): Hostname where CamillaDSP runs. + port (int): Port number of the CamillaDSP websocket server. + """ + self._host = host + self._port = int(port) + self._ws: Optional[WebSocket] = None + self.cdsp_version: Optional[Tuple[str, str, str]] = None + self._lock = Lock() + + def query(self, command: str, arg=None): + """ + Send a command and return the response. + + Args: + command (str): The command to send. + arg: Parameter to send with the command. + + Returns: + Any | None: The return value for commands that return values, None for others. + """ + if self._ws is None: + raise IOError("Not connected to CamillaDSP") + if arg is not None: + query = json.dumps({command: arg}) + else: + query = json.dumps(command) + try: + with self._lock: + self._ws.send(query) + rawrepl = self._ws.recv() + except Exception as err: + self._ws = None + raise IOError("Lost connection to CamillaDSP") from err + return self._handle_reply(command, rawrepl) + + def _handle_reply(self, command: str, rawreply: Union[str, bytes]): + try: + reply = json.loads(rawreply) + value = None + if command in reply: + state = reply[command]["result"] + if "value" in reply[command]: + value = reply[command]["value"] + if state == "Error" and value is not None: + raise CamillaError(value) + if state == "Error" and value is None: + raise CamillaError("Command returned an error") + if state == "Ok" and value is not None: + return value + return None + raise IOError(f"Invalid response received: {rawreply!r}") + except json.JSONDecodeError as err: + raise IOError(f"Invalid response received: {rawreply!r}") from err + + def _update_version(self, resp: str): + version = resp.split(".", 3) + if len(version) < 3: + version.extend([""] * (3 - len(version))) + self.cdsp_version = (version[0], version[1], version[2]) + + def connect(self): + """ + Connect to the websocket of CamillaDSP. + """ + try: + with self._lock: + self._ws = create_connection(f"ws://{self._host}:{self._port}") + rawvers = self.query("GetVersion") + self._update_version(rawvers) + except Exception as _e: + self._ws = None + raise + + def disconnect(self): + """ + Close the connection to the websocket. + """ + if self._ws is not None: + try: + with self._lock: + self._ws.close() + except Exception as _e: # pylint: disable=broad-exception-caught + pass + self._ws = None + + def is_connected(self): + """ + Is websocket connected? + + Returns: + bool: True if connected, False otherwise. + """ + return self._ws is not None diff --git a/camilladsp/commandgroup.py b/camilladsp/commandgroup.py new file mode 100644 index 0000000..54509f1 --- /dev/null +++ b/camilladsp/commandgroup.py @@ -0,0 +1,18 @@ +""" +Python library for communicating with CamillaDSP. + +This module contains the base class for command groups. +""" + +from .camillaws import _CamillaWS + + +class _CommandGroup: + """ + Collection of methods + """ + + # pylint: disable=too-few-public-methods + + def __init__(self, client: _CamillaWS): + self.client = client diff --git a/camilladsp/config.py b/camilladsp/config.py new file mode 100644 index 0000000..172a3fa --- /dev/null +++ b/camilladsp/config.py @@ -0,0 +1,175 @@ +""" +Python library for communicating with CamillaDSP. + +This module contains commands for managind configs. +""" + +from typing import Dict, Optional + +import yaml + +from .commandgroup import _CommandGroup + + +class Config(_CommandGroup): + """ + Collection of methods for configuration management + """ + + def file_path(self) -> Optional[str]: + """ + Get path to current config file. + + Returns: + str | None: Path to config file, or None. + """ + name = self.client.query("GetConfigFilePath") + return name + + def set_file_path(self, value: str): + """ + Set path to config file, without loading it. + Call `reload()` to apply the new config file. + + Args: + value (str): Path to config file. + """ + self.client.query("SetConfigFilePath", arg=value) + + def active_raw(self) -> Optional[str]: + """ + Get the active configuration in raw yaml format (as a string). + + Returns: + str | None: Current config as a raw yaml string, or None. + """ + config_string = self.client.query("GetConfig") + return config_string + + def set_active_raw(self, config_string: str): + """ + Upload and apply a new configuration in raw yaml format (as a string). + + Args: + config_string (str): Config as yaml string. + """ + self.client.query("SetConfig", arg=config_string) + + def active_json(self) -> Optional[str]: + """ + Get the active configuration in raw json format (as a string). + + Returns: + str | None: Current config as a raw json string, or None. + """ + config_string = self.client.query("GetConfigJson") + return config_string + + def set_active_json(self, config_string: str): + """ + Upload and apply a new configuration in raw json format (as a string). + + Args: + config_string (str): Config as json string. + """ + self.client.query("SetConfigJson", arg=config_string) + + def active(self) -> Optional[Dict]: + """ + Get the active configuration as a Python object. + + Returns: + Dict | None: Current config as a Python dict, or None. + """ + config_string = self.active_raw() + if config_string is None: + return None + config_object = yaml.safe_load(config_string) + return config_object + + def previous(self) -> Optional[Dict]: + """ + Get the previously active configuration as a Python object. + + Returns: + Dict | None: Previous config as a Python dict, or None. + """ + config_string = self.client.query("GetPreviousConfig") + config_object = yaml.safe_load(config_string) + return config_object + + def parse_yaml(self, config_string: str) -> Dict: + """ + Parse a config from yaml string and return the contents + as a Python object, with defaults filled out with their default values. + + Args: + config_string (str): A config as raw yaml string. + + Returns: + Dict | None: Parsed config as a Python dict. + """ + config_raw = self.client.query("ReadConfig", arg=config_string) + config_object = yaml.safe_load(config_raw) + return config_object + + def read_and_parse_file(self, filename: str) -> Dict: + """ + Read and parse a config file from disk and return the contents as a Python object. + + Args: + filename (str): Path to a config file. + + Returns: + Dict | None: Parsed config as a Python dict. + """ + config_raw = self.client.query("ReadConfigFile", arg=filename) + config = yaml.safe_load(config_raw) + return config + + def set_active(self, config_object: Dict): + """ + Upload and apply a new configuration from a Python object. + + Args: + config_object (Dict): A configuration as a Python dict. + """ + config_raw = yaml.dump(config_object) + self.set_active_raw(config_raw) + + def validate(self, config_object: Dict) -> Dict: + """ + Validate a configuration object. + Returns the validated config with all optional fields filled with defaults. + Raises a CamillaError on errors. + + Args: + config_object (Dict): A configuration as a Python dict. + + Returns: + Dict | None: Validated config as a Python dict. + """ + config_string = yaml.dump(config_object) + validated_string = self.client.query("ValidateConfig", arg=config_string) + validated_object = yaml.safe_load(validated_string) + return validated_object + + def title(self) -> Optional[str]: + """ + Get the title of the active configuration. + + Returns: + str | None: Config title if defined, else None. + """ + title = self.client.query("GetConfigTitle") + return title + + def description(self) -> Optional[str]: + """ + Get the title of the active configuration. + + Returns: + str | None: Config description if defined, else None. + """ + desc = self.client.query("GetConfigDescription") + return desc diff --git a/camilladsp/datastructures.py b/camilladsp/datastructures.py index 8f66aec..89beb94 100644 --- a/camilladsp/datastructures.py +++ b/camilladsp/datastructures.py @@ -3,7 +3,7 @@ """ from enum import Enum, auto -from typing import Optional +from typing import Optional, TypedDict _STANDARD_RATES = ( 8000, @@ -128,3 +128,18 @@ def _reason_from_reply(value): raise ValueError(f"Invalid value for StopReason: {value}") reasonenum.set_data(data) return reasonenum + + +class CamillaError(ValueError): + """ + A class representing errors returned by CamillaDSP. + """ + + +class Fader(TypedDict): + """ + Class for type annotation of fader volume and mute settings. + """ + + volume: float + mute: bool diff --git a/camilladsp/general.py b/camilladsp/general.py new file mode 100644 index 0000000..66376db --- /dev/null +++ b/camilladsp/general.py @@ -0,0 +1,124 @@ +""" +Python library for communicating with CamillaDSP. + +This module contains commands of general nature. +""" + +from typing import Tuple, List, Optional + +from .commandgroup import _CommandGroup +from .datastructures import ( + ProcessingState, + StopReason, + _state_from_string, + _reason_from_reply, +) + + +class General(_CommandGroup): + """ + Basic commands + """ + + # ========================= CamillaDSP state ========================= + + def state(self) -> Optional[ProcessingState]: + """ + Get current processing state. + + Returns: + ProcessingState | None: Current processing state. + """ + state = self.client.query("GetState") + return _state_from_string(state) + + def stop_reason(self) -> StopReason: + """ + Get reason why processing stopped. + + Returns: + StopReason: Stop reason enum variant. + """ + reason = self.client.query("GetStopReason") + return _reason_from_reply(reason) + + # ========================= Basic commands ========================= + + def stop(self): + """ + Stop processing and wait for new config if wait mode is active, else exit. + """ + self.client.query("Stop") + + def exit(self): + """ + Stop processing and exit. + """ + self.client.query("Exit") + + def reload(self): + """ + Reload config from disk. + """ + self.client.query("Reload") + + def supported_device_types(self) -> Tuple[List[str], List[str]]: + """ + Read what device types the running CamillaDSP process supports. + Returns a tuple with two lists of device types, + the first for playback and the second for capture. + + Returns: + Tuple[List[str], List[str]]: A tuple containing two lists, + with the supported playback and capture device types. + """ + (playback, capture) = self.client.query("GetSupportedDeviceTypes") + return (playback, capture) + + def state_file_path(self) -> Optional[str]: + """ + Get path to current state file. + + Returns: + str | None: Path to state file, or None. + """ + path = self.client.query("GetStateFilePath") + return path + + def state_file_updated(self) -> bool: + """ + Check if all changes have been saved to the state file. + + Returns: + bool: True if all changes are saved. + """ + updated = self.client.query("GetStateFileUpdated") + return updated + + def list_playback_devices(self, value: str) -> List[Tuple[str, str]]: + """ + List the available playback devices for a given backend. + Returns a list of tuples. Returns the system name and + a descriptive name for each device. + For some backends, those two names are identical. + + Returns: + List[Tuple[str, str]: A list containing tuples of two strings, + with system device name and a descriptive name. + """ + devs = self.client.query("GetAvailablePlaybackDevices", arg=value) + return devs + + def list_capture_devices(self, value: str) -> List[Tuple[str, str]]: + """ + List the available capture devices for a given backend. + Returns a list of tuples. Returns the system name and + a descriptive name for each device. + For some backends, those two names are identical. + + Returns: + List[Tuple[str, str]: A list containing tuples of two strings, + with system device name and a descriptive name. + """ + devs = self.client.query("GetAvailableCaptureDevices", arg=value) + return devs diff --git a/camilladsp/levels.py b/camilladsp/levels.py new file mode 100644 index 0000000..b69814e --- /dev/null +++ b/camilladsp/levels.py @@ -0,0 +1,193 @@ +""" +Python library for communicating with CamillaDSP. + +This module contains commands for reading levels. +""" + +from typing import Dict, List +import math + +from .commandgroup import _CommandGroup + + +class Levels(_CommandGroup): + """ + Collection of methods for level monitoring + """ + + def range(self) -> float: + """ + Get signal range for the last processed chunk. Full scale is 2.0. + """ + sigrange = self.client.query("GetSignalRange") + return float(sigrange) + + def range_decibel(self) -> float: + """ + Get current signal range in dB for the last processed chunk. + Full scale is 0 dB. + """ + sigrange = self.range() + if sigrange > 0.0: + range_decibel = 20.0 * math.log10(sigrange / 2.0) + else: + range_decibel = -1000 + return range_decibel + + def capture_rms(self) -> List[float]: + """ + Get capture signal level rms in dB for the last processed chunk. + Full scale is 0 dB. Returns a list with one element per channel. + """ + sigrms = self.client.query("GetCaptureSignalRms") + return sigrms + + def playback_rms(self) -> List[float]: + """ + Get playback signal level rms in dB for the last processed chunk. + Full scale is 0 dB. Returns a list with one element per channel. + """ + sigrms = self.client.query("GetPlaybackSignalRms") + return sigrms + + def capture_peak(self) -> List[float]: + """ + Get capture signal level peak in dB for the last processed chunk. + Full scale is 0 dB. Returns a list with one element per channel. + """ + sigpeak = self.client.query("GetCaptureSignalPeak") + return sigpeak + + def playback_peak(self) -> List[float]: + """ + Get playback signal level peak in dB for the last processed chunk. + Full scale is 0 dB. Returns a list with one element per channel. + """ + sigpeak = self.client.query("GetPlaybackSignalPeak") + return sigpeak + + def playback_peak_since(self, interval: float) -> List[float]: + """ + Get playback signal level peak in dB for the last `interval` seconds. + Full scale is 0 dB. Returns a list with one element per channel. + + Args: + interval (float): Length of interval in seconds. + """ + sigpeak = self.client.query("GetPlaybackSignalPeakSince", arg=float(interval)) + return sigpeak + + def playback_rms_since(self, interval: float) -> List[float]: + """ + Get playback signal level rms in dB for the last `interval` seconds. + Full scale is 0 dB. Returns a list with one element per channel. + + Args: + interval (float): Length of interval in seconds. + """ + sigrms = self.client.query("GetPlaybackSignalRmsSince", arg=float(interval)) + return sigrms + + def capture_peak_since(self, interval: float) -> List[float]: + """ + Get capture signal level peak in dB for the last `interval` seconds. + Full scale is 0 dB. Returns a list with one element per channel. + + Args: + interval (float): Length of interval in seconds. + """ + sigpeak = self.client.query("GetCaptureSignalPeakSince", arg=float(interval)) + return sigpeak + + def capture_rms_since(self, interval: float) -> List[float]: + """ + Get capture signal level rms in dB for the last `interval` seconds. + Full scale is 0 dB. Returns a list with one element per channel. + + Args: + interval (float): Length of interval in seconds. + """ + sigrms = self.client.query("GetCaptureSignalRmsSince", arg=float(interval)) + return sigrms + + def playback_peak_since_last(self) -> List[float]: + """ + Get playback signal level peak in dB since the last read by the same client. + Full scale is 0 dB. Returns a list with one element per channel. + """ + sigpeak = self.client.query("GetPlaybackSignalPeakSinceLast") + return sigpeak + + def playback_rms_since_last(self) -> List[float]: + """ + Get playback signal level rms in dB since the last read by the same client. + Full scale is 0 dB. Returns a list with one element per channel. + """ + sigrms = self.client.query("GetPlaybackSignalRmsSinceLast") + return sigrms + + def capture_peak_since_last(self) -> List[float]: + """ + Get capture signal level peak in dB since the last read by the same client. + Full scale is 0 dB. Returns a list with one element per channel. + """ + sigpeak = self.client.query("GetCaptureSignalPeakSinceLast") + return sigpeak + + def capture_rms_since_last(self) -> List[float]: + """ + Get capture signal level rms in dB since the last read by the same client. + Full scale is 0 dB. Returns a list with one element per channel. + """ + sigrms = self.client.query("GetCaptureSignalRmsSinceLast") + return sigrms + + def levels(self) -> Dict[str, List[float]]: + """ + Get all signal levels in dB for the last processed chunk. + Full scale is 0 dB. + The values are returned as a json object with keys + `playback_peak`, `playback_rms`, `capture_peak` and `capture_rms`. + Each dict item is a list with one element per channel. + """ + siglevels = self.client.query("GetSignalLevels") + return siglevels + + def levels_since(self, interval: float) -> Dict[str, List[float]]: + """ + Get all signal levels in dB for the last `interval` seconds. + Full scale is 0 dB. + The values are returned as a json object with keys + `playback_peak`, `playback_rms`, `capture_peak` and `capture_rms`. + Each dict item is a list with one element per channel. + + Args: + interval (float): Length of interval in seconds. + """ + siglevels = self.client.query("GetSignalLevelsSince", arg=float(interval)) + return siglevels + + def levels_since_last(self) -> Dict[str, List[float]]: + """ + Get all signal levels in dB since the last read by the same client. + Full scale is 0 dB. + The values are returned as a json object with keys + `playback_peak`, `playback_rms`, `capture_peak` and `capture_rms`. + Each dict item is a list with one element per channel. + """ + siglevels = self.client.query("GetSignalLevelsSinceLast") + return siglevels + + def peaks_since_start(self) -> Dict[str, List[float]]: + """ + Get the playback and capture peak level since processing started. + The values are returned as a json object with keys `playback` and `capture`. + """ + peaks = self.client.query("GetSignalPeaksSinceStart") + return peaks + + def reset_peaks_since_start(self): + """ + Reset the peak level values. + """ + self.client.query("ResetSignalPeaksSinceStart") diff --git a/camilladsp/ratemonitor.py b/camilladsp/ratemonitor.py new file mode 100644 index 0000000..3edbb13 --- /dev/null +++ b/camilladsp/ratemonitor.py @@ -0,0 +1,41 @@ +""" +Python library for communicating with CamillaDSP. + +This module contains commands for rate monitoring. +""" + +from typing import Optional + +from .commandgroup import _CommandGroup +from .datastructures import _STANDARD_RATES + + +class RateMonitor(_CommandGroup): + """ + Methods for rate monitoring + """ + + def capture_raw(self) -> int: + """ + Get current capture rate, raw value. + + Returns: + int: The current raw capture rate. + """ + rate = self.client.query("GetCaptureRate") + return int(rate) + + def capture(self) -> Optional[int]: + """ + Get current capture rate. + Returns the nearest common rate, as long as it's within +-4% of the measured value. + + Returns: + int: The current capture rate. + """ + rate = self.capture_raw() + if 0.96 * _STANDARD_RATES[0] < rate < 1.04 * _STANDARD_RATES[-1]: + nearest = min(_STANDARD_RATES, key=lambda val: abs(val - rate)) + if 0.96 < rate / nearest < 1.04: + return nearest + return None diff --git a/camilladsp/settings.py b/camilladsp/settings.py new file mode 100644 index 0000000..728d247 --- /dev/null +++ b/camilladsp/settings.py @@ -0,0 +1,32 @@ +""" +Python library for communicating with CamillaDSP. + +This module contains commands for various settings. +""" + +from .commandgroup import _CommandGroup + + +class Settings(_CommandGroup): + """ + Methods for various settings + """ + + def update_interval(self) -> int: + """ + Get current update interval in ms. + + Returns: + int: Current update interval. + """ + interval = self.client.query("GetUpdateInterval") + return int(interval) + + def set_update_interval(self, value: int): + """ + Set current update interval in ms. + + Args: + value (int): New update interval. + """ + self.client.query("SetUpdateInterval", arg=value) diff --git a/camilladsp/status.py b/camilladsp/status.py new file mode 100644 index 0000000..73b2e8d --- /dev/null +++ b/camilladsp/status.py @@ -0,0 +1,53 @@ +""" +Python library for communicating with CamillaDSP. + +This module contains commands for reading status. +""" + +from .commandgroup import _CommandGroup + + +class Status(_CommandGroup): + """ + Collection of methods for reading status + """ + + def rate_adjust(self) -> float: + """ + Get current value for rate adjust, 1.0 means 1:1 resampling. + + Returns: + float: Rate adjust value. + """ + adj = self.client.query("GetRateAdjust") + return float(adj) + + def buffer_level(self) -> int: + """ + Get current buffer level of the playback device. + + Returns: + int: Buffer level in frames. + """ + level = self.client.query("GetBufferLevel") + return int(level) + + def clipped_samples(self) -> int: + """ + Get number of clipped samples since the config was loaded. + + Returns: + int: Number of clipped samples. + """ + clipped = self.client.query("GetClippedSamples") + return int(clipped) + + def processing_load(self) -> float: + """ + Get processing load in percent. + + Returns: + float: Current load. + """ + load = self.client.query("GetProcessingLoad") + return float(load) diff --git a/camilladsp/versions.py b/camilladsp/versions.py new file mode 100644 index 0000000..fbebe89 --- /dev/null +++ b/camilladsp/versions.py @@ -0,0 +1,39 @@ +""" +Python library for communicating with CamillaDSP. + +This module contains commands for reading version information. +""" + +from typing import Tuple, Optional + + +from .commandgroup import _CommandGroup + +VERSION = "3.0.0" + + +class Versions(_CommandGroup): + """ + Version info + """ + + def camilladsp(self) -> Optional[Tuple[str, str, str]]: + """ + Read CamillaDSP version. + + Returns: + Tuple[List[str], List[str]] | None: A tuple containing the CamillaDSP version, + as (major, minor, patch). + """ + return self.client.cdsp_version + + def library(self) -> Tuple[str, str, str]: + """ + Read pyCamillaDSP library version. + + Returns: + Tuple[List[str], List[str]] | None: A tuple containing the pyCamillaDSP version, + as (major, minor, patch). + """ + ver = VERSION.split(".") + return (ver[0], ver[1], ver[2]) diff --git a/camilladsp/volume.py b/camilladsp/volume.py new file mode 100644 index 0000000..9e30240 --- /dev/null +++ b/camilladsp/volume.py @@ -0,0 +1,183 @@ +""" +Python library for communicating with CamillaDSP. + +This module contains commands for mute and volume control. +""" + +from typing import Tuple, List, Optional, Union + +from .commandgroup import _CommandGroup +from .datastructures import Fader + + +class Volume(_CommandGroup): + """ + Collection of methods for volume and mute control + """ + + default_min_vol = -150.0 + default_max_vol = 50.0 + + def all(self) -> List[Fader]: + """ + Get volume and mute for all faders with a single call. + + Returns: + List[Fader]: A list of one object per fader, each with `volume` and `mute` properties. + """ + faders = self.client.query("GetFaders") + return faders + + def main_volume(self) -> float: + """ + Get current main volume setting in dB. + Equivalent to calling `volume(0)`. + + Returns: + float: Current volume setting. + """ + vol = self.client.query("GetVolume") + return float(vol) + + def set_main_volume(self, value: float): + """ + Set main volume in dB. + Equivalent to calling `set_volume(0)`. + + Args: + value (float): New volume in dB. + """ + self.client.query("SetVolume", arg=float(value)) + + def volume(self, fader: int) -> float: + """ + Get current volume setting for the given fader in dB. + + Args: + fader (int): Fader to read. + Selected using an integer, 0 for `Main` and 1 to 4 for `Aux1` to `Aux4`. + + Returns: + float: Current volume setting. + """ + _fader, vol = self.client.query("GetFaderVolume", arg=int(fader)) + return float(vol) + + def set_volume(self, fader: int, vol: float): + """ + Set volume for the given fader in dB. + + Args: + fader (int): Fader to control. + Selected using an integer, 0 for `Main` and 1 to 4 for `Aux1` to `Aux4`. + vol (float): New volume setting. + """ + self.client.query("SetFaderVolume", arg=(int(fader), float(vol))) + + def set_volume_external(self, fader: int, vol: float): + """ + Special command for setting the volume when a "Loudness" filter + is being combined with an external volume control (without a "Volume" filter). + Set volume for the given fader in dB. + + Args: + fader (int): Fader to control. + Selected using an integer, 0 for `Main` and 1 to 4 for `Aux1` to `Aux4`. + vol (float): New volume setting. + """ + self.client.query("SetFaderExternalVolume", arg=(int(fader), float(vol))) + + def adjust_volume( + self, + fader: int, + value: float, + min_limit: Optional[float] = None, + max_limit: Optional[float] = None, + ) -> float: + """ + Adjust volume for the given fader in dB. + Positive values increase the volume, negative decrease. + The resulting volume is limited to the range -150 to +50 dB. + This default range can be reduced via the optional + `min_limit` and/or `max_limit` arguments. + + Args: + fader (int): Fader to control. + Selected using an integer, 0 for `Main` and 1 to 4 for `Aux1` to `Aux4`. + value (float): Volume adjustment in dB. + min_limit (float): Lower volume limit to clamp volume at. + max_limit (float): Upper volume limit to clamp volume at. + + + Returns: + float: New volume setting. + """ + arg: Tuple[int, Union[float, Tuple[float, float, float]]] + if max_limit is not None or min_limit is not None: + maxlim = max_limit if max_limit is not None else self.default_max_vol + minlim = min_limit if min_limit is not None else self.default_min_vol + arg = (int(fader), (float(value), float(minlim), float(maxlim))) + else: + arg = (int(fader), float(value)) + _fader, new_vol = self.client.query("AdjustFaderVolume", arg=arg) + return float(new_vol) + + def main_mute(self) -> bool: + """ + Get current main mute setting. + Equivalent to calling `mute(0)`. + + Returns: + bool: True if muted, False otherwise. + """ + mute = self.client.query("GetMute") + return bool(mute) + + def set_main_mute(self, value: bool): + """ + Set main mute, true or false. + Equivalent to calling `set_mute(0)`. + + Args: + value (bool): New mute setting. + """ + self.client.query("SetMute", arg=bool(value)) + + def mute(self, fader: int) -> bool: + """ + Get current mute setting for a fader. + + Args: + fader (int): Fader to read. + Selected using an integer, 0 for `Main` and 1 to 4 for `Aux1` to `Aux4`. + + Returns: + bool: True if muted, False otherwise. + """ + _fader, mute = self.client.query("GetFaderMute", arg=int(fader)) + return bool(mute) + + def set_mute(self, fader: int, value: bool): + """ + Set mute status for a fader, true or false. + + Args: + fader (int): Fader to control. + Selected using an integer, 0 for `Main` and 1 to 4 for `Aux1` to `Aux4`. + value (bool): New mute setting. + """ + self.client.query("SetFaderMute", arg=(int(fader), bool(value))) + + def toggle_mute(self, fader: int) -> bool: + """ + Toggle mute status for a fader. + + Args: + fader (int): Fader to control. + Selected using an integer, 0 for `Main` and 1 to 4 for `Aux1` to `Aux4`. + + Returns: + bool: True if the new status is muted, False otherwise. + """ + _fader, new_mute = self.client.query("ToggleFaderMute", arg=int(fader)) + return new_mute diff --git a/docs/config.md b/docs/config.md index a080c95..e17662f 100644 --- a/docs/config.md +++ b/docs/config.md @@ -4,4 +4,4 @@ This class is accessed via the `config` property on a `CamillaClient` instance. It provides methods for managing the configuration. ## class: `Config` -::: camilladsp.camilladsp.Config \ No newline at end of file +::: camilladsp.config.Config \ No newline at end of file diff --git a/docs/errors.md b/docs/errors.md index 8c55f89..89f9a8d 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -1,7 +1,7 @@ # Errors -The custom exception [CamillaError][camilladsp.camilladsp.CamillaError] is raised when CamillaDSP replies to a command with an error message. The error message is given as the message of the exception. +The custom exception [CamillaError][camilladsp.datastructures.CamillaError] is raised when CamillaDSP replies to a command with an error message. The error message is given as the message of the exception. Different exceptions are raised in different situations. Consider the following example: ```python @@ -25,4 +25,4 @@ except IOError as e: This happens if the CamillaDSP process exits or is restarted. ## CamillaError -::: camilladsp.camilladsp.CamillaError \ No newline at end of file +::: camilladsp.datastructures.CamillaError \ No newline at end of file diff --git a/docs/general.md b/docs/general.md index 533a722..0fe8ef2 100644 --- a/docs/general.md +++ b/docs/general.md @@ -4,4 +4,4 @@ This class is accessed via the `general` property on a `CamillaClient` instance. It provides the basic methods such as starting and stopping processing. ## class: `General` -::: camilladsp.camilladsp.General +::: camilladsp.general.General diff --git a/docs/index.md b/docs/index.md index ac6edc6..aac0db4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -7,22 +7,22 @@ This class handles the communication over websocket with the CamillaDSP process. The various commands are grouped on helper classes that are instantiated by the CamillaClient class. -For example volume controls are handled by the `Volume` class. +For example volume and mute controls are handled by the `Volume` class. These methods are accessible via the `volume` property of the CamillaClient. -Reading the main volume is then done by calling `my_client.volume.main()`. +Reading the main volume is then done by calling `my_client.volume.main_volume()`. Methods for reading a value are named the same as the name of the value, while methods for writing have a `set_` prefix. -For example the method for reading the main volume is called `main`, -and the method for changing the main volume is called `set_main`. +For example the method for reading the main volume is called `main_volume`, +and the method for changing the main volume is called `set_main_volume`. Example: ```py client = CamillaClient("localhost", 1234) client.connect() -volume = client.volume.main() -mute = client.mute.main() +volume = client.volume.main_volume() +mute = client.volume.main_mute() state = client.general.state() capture_levels = client.levels.capture_rms() ``` @@ -30,101 +30,91 @@ capture_levels = client.levels.capture_rms() ## Command group classes | Class | Via property | Description | |--------------|----------|-------------| -| [General][camilladsp.camilladsp.General] | `general` | Basics, for example starting and stopping processing | -| [Status][camilladsp.camilladsp.Status] | `status` | Reading status parameters such as buffer levels | -| [Config][camilladsp.camilladsp.Config] | `config` | Managing the configuration | -| [Volume][camilladsp.camilladsp.Volume] | `volume` | Volume and mute controls | -| [Levels][camilladsp.camilladsp.Levels] | `levels` | Reading signal levels | -| [RateMonitor][camilladsp.camilladsp.RateMonitor] | `rate` | Reading the sample rate montitor | -| [Settings][camilladsp.camilladsp.Settings] | `settings` | Websocket server settings | -| [Versions][camilladsp.camilladsp.Versions] | `versions` | Read software versions | +| [General][camilladsp.general.General] | `general` | Basics, for example starting and stopping processing | +| [Status][camilladsp.status.Status] | `status` | Reading status parameters such as buffer levels | +| [Config][camilladsp.config.Config] | `config` | Managing the configuration | +| [Volume][camilladsp.volume.Volume] | `volume` | Volume and mute controls | +| [Levels][camilladsp.levels.Levels] | `levels` | Reading signal levels | +| [RateMonitor][camilladsp.ratemonitor.RateMonitor] | `rate` | Reading the sample rate montitor | +| [Settings][camilladsp.settings.Settings] | `settings` | Websocket server settings | +| [Versions][camilladsp.versions.Versions] | `versions` | Read software versions | ## All commands -### [General][camilladsp.camilladsp.General] +### [General][camilladsp.general.General] These commands are accessed via the [general][camilladsp.CamillaClient.general] property of a [CamillaClient][camilladsp.CamillaClient] instance. -::: camilladsp.camilladsp.General +::: camilladsp.general.General options: show_bases: false show_source: false show_docstring_parameters: false show_docstring_returns: false -### [Status][camilladsp.camilladsp.Status] +### [Status][camilladsp.status.Status] These commands are accessed via the [status][camilladsp.CamillaClient.status] property of a [CamillaClient][camilladsp.CamillaClient] instance. -::: camilladsp.camilladsp.Status +::: camilladsp.status.Status options: show_bases: false show_source: false show_docstring_parameters: false show_docstring_returns: false -### [Config][camilladsp.camilladsp.Config] +### [Config][camilladsp.config.Config] These commands are accessed via the [config][camilladsp.CamillaClient.config] property of a [CamillaClient][camilladsp.CamillaClient] instance. -::: camilladsp.camilladsp.Config +::: camilladsp.config.Config options: show_bases: false show_source: false show_docstring_parameters: false show_docstring_returns: false -### [Volume][camilladsp.camilladsp.Volume] +### [Volume][camilladsp.volume.Volume] These commands are accessed via the [volume][camilladsp.CamillaClient.volume] property of a [CamillaClient][camilladsp.CamillaClient] instance. -::: camilladsp.camilladsp.Volume +::: camilladsp.volume.Volume options: show_bases: false show_source: false show_docstring_parameters: false show_docstring_returns: false -### [Mute][camilladsp.camilladsp.Mute] -These commands are accessed via the [mute][camilladsp.CamillaClient.mute] -property of a [CamillaClient][camilladsp.CamillaClient] instance. -::: camilladsp.camilladsp.Mute - options: - show_bases: false - show_source: false - show_docstring_parameters: false - show_docstring_returns: false - -### [Levels][camilladsp.camilladsp.Levels] +### [Levels][camilladsp.levels.Levels] These commands are accessed via the [levels][camilladsp.CamillaClient.levels] property of a [CamillaClient][camilladsp.CamillaClient] instance. -::: camilladsp.camilladsp.Levels +::: camilladsp.levels.Levels options: show_bases: false show_source: false show_docstring_parameters: false show_docstring_returns: false -### [RateMonitor][camilladsp.camilladsp.RateMonitor] +### [RateMonitor][camilladsp.ratemonitor.RateMonitor] These commands are accessed via the [rate][camilladsp.CamillaClient.rate] property of a [CamillaClient][camilladsp.CamillaClient] instance. -::: camilladsp.camilladsp.RateMonitor +::: camilladsp.ratemonitor.RateMonitor options: show_bases: false show_source: false show_docstring_parameters: false show_docstring_returns: false -### [Settings][camilladsp.camilladsp.Settings] +### [Settings][camilladsp.settings.Settings] These commands are accessed via the [settings][camilladsp.CamillaClient.settings] property of a [CamillaClient][camilladsp.CamillaClient] instance. -::: camilladsp.camilladsp.Settings +::: camilladsp.settings.Settings options: show_bases: false show_source: false show_docstring_parameters: false show_docstring_returns: false -### [Versions][camilladsp.camilladsp.Versions] +### [Versions][camilladsp.versions.Versions] These commands are accessed via the [versions][camilladsp.CamillaClient.versions] property of a [CamillaClient][camilladsp.CamillaClient] instance. -::: camilladsp.camilladsp.Versions +::: camilladsp.versions.Versions options: show_bases: false show_source: falses diff --git a/docs/levels.md b/docs/levels.md index d8e7365..2efe685 100644 --- a/docs/levels.md +++ b/docs/levels.md @@ -4,4 +4,4 @@ This class is accessed via the `levels` property on a `CamillaClient` instance. It provides methods for reading signal levels. ## class: `Levels` -::: camilladsp.camilladsp.Levels \ No newline at end of file +::: camilladsp.levels.Levels \ No newline at end of file diff --git a/docs/rate.md b/docs/rate.md index 250bc4b..bacb561 100644 --- a/docs/rate.md +++ b/docs/rate.md @@ -4,4 +4,4 @@ This class is accessed via the `rate` property on a `CamillaClient` instance. It provides methods for reading the output of the sample rate monitoring. ## class: `RateMonitor` -::: camilladsp.camilladsp.RateMonitor \ No newline at end of file +::: camilladsp.ratemonitor.RateMonitor \ No newline at end of file diff --git a/docs/settings.md b/docs/settings.md index fe4629e..3780425 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -4,4 +4,4 @@ This class is accessed via the `settings` property on a `CamillaClient` instance It provides methods for reading and writing settings of the websocket server. ## class: `Settings` -::: camilladsp.camilladsp.Settings \ No newline at end of file +::: camilladsp.settings.Settings \ No newline at end of file diff --git a/docs/status.md b/docs/status.md index 5b48234..4c73192 100644 --- a/docs/status.md +++ b/docs/status.md @@ -4,4 +4,4 @@ This class is accessed via the `status` property on a `CamillaClient` instance. It provides methods for reading various status parameters. ## class: `Status` -::: camilladsp.camilladsp.Status \ No newline at end of file +::: camilladsp.status.Status \ No newline at end of file diff --git a/docs/versions.md b/docs/versions.md index fe8c73f..2815e30 100644 --- a/docs/versions.md +++ b/docs/versions.md @@ -4,4 +4,4 @@ This class is accessed via the `versions` property on a `CamillaClient` instance It provides methods for reading the software versions. ## class: `Versions` -::: camilladsp.camilladsp.Versions \ No newline at end of file +::: camilladsp.versions.Versions \ No newline at end of file diff --git a/docs/volume.md b/docs/volume.md index 109a8e6..4596e2e 100644 --- a/docs/volume.md +++ b/docs/volume.md @@ -4,4 +4,4 @@ This class is accessed via the `volume` property on a `CamillaClient` instance. It provides methods for reading and setting the volume and mute controls. ## class: `Volume` -::: camilladsp.camilladsp.Volume \ No newline at end of file +::: camilladsp.volume.Volume \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 843e910..2ea650e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -6,6 +6,8 @@ theme: plugins: - mkdocstrings + - autorefs: + resolve_closest: true nav: - index.md diff --git a/pyproject.toml b/pyproject.toml index 0a9ace3..78f916a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,9 +21,9 @@ dynamic = ["version"] license = {file = "LICENSE.txt"} [tool.setuptools.dynamic] -version = {attr = "camilladsp.camilladsp.VERSION"} +version = {attr = "camilladsp.versions.VERSION"} [project.optional-dependencies] dev = ["black >= 24.0.0", "pylint >= 2.17", "mypy >= 1.0", "pytest >= 7.0"] -docs = ["mkdocs", "mkdocs-material", "mkdocstrings"] +docs = ["mkdocs", "mkdocs-material", "mkdocstrings[python]"] diff --git a/tests/test_camillaws.py b/tests/test_camillaws.py index dde8994..f5979ad 100644 --- a/tests/test_camillaws.py +++ b/tests/test_camillaws.py @@ -1,4 +1,4 @@ -from camilladsp.camilladsp import StopReason +from camilladsp import StopReason import pytest from unittest.mock import MagicMock, patch import camilladsp @@ -110,7 +110,7 @@ def camilla_mockws(): ws_dummy = DummyWS() connection.send = MagicMock(side_effect=ws_dummy.send) connection.recv = MagicMock(side_effect=ws_dummy.recv) - with patch("camilladsp.camilladsp.create_connection", create_connection): + with patch("camilladsp.camillaws.create_connection", create_connection): cdsp = camilladsp.camilladsp.CamillaClient("localhost", 1234) cdsp.dummyws = ws_dummy cdsp.mockconnection = connection @@ -146,9 +146,7 @@ def test_connect(camilla_mockws): assert camilla_mockws.is_connected() assert camilla_mockws.general.state() == camilladsp.ProcessingState.INACTIVE assert camilla_mockws.versions.camilladsp() == ("0", "3", "2") - assert camilla_mockws.versions.library() == tuple( - camilladsp.camilladsp.VERSION.split(".") - ) + assert camilla_mockws.versions.library() == tuple(camilladsp.VERSION.split(".")) camilla_mockws.disconnect() assert not camilla_mockws.is_connected() @@ -323,14 +321,20 @@ def test_queries_adv(camilla_mockquery_yaml): camilla_mockquery_yaml.config.previous() camilla_mockquery_yaml.query.assert_called_with("GetPreviousConfig") + def test_queries_customreplies(camilla_mockquery): - camilla_mockquery.query.return_value = [0, -12.0] + camilla_mockquery.query.return_value = [0, -12.0] camilla_mockquery.volume.adjust_volume(0, -5.0) camilla_mockquery.query.assert_called_with("AdjustFaderVolume", arg=(0, -5.0)) camilla_mockquery.volume.adjust_volume(0, -5.0, min_limit=-20, max_limit=3.0) - camilla_mockquery.query.assert_called_with("AdjustFaderVolume", arg=(0, (-5.0, -20.0, 3.0))) + camilla_mockquery.query.assert_called_with( + "AdjustFaderVolume", arg=(0, (-5.0, -20.0, 3.0)) + ) camilla_mockquery.volume.adjust_volume(0, -5.0, min_limit=-20) - camilla_mockquery.query.assert_called_with("AdjustFaderVolume", arg=(0, (-5.0, -20.0, 50.0))) + camilla_mockquery.query.assert_called_with( + "AdjustFaderVolume", arg=(0, (-5.0, -20.0, 50.0)) + ) camilla_mockquery.volume.adjust_volume(0, -5.0, max_limit=3.0) - camilla_mockquery.query.assert_called_with("AdjustFaderVolume", arg=(0, (-5.0, -150.0, 3.0))) - + camilla_mockquery.query.assert_called_with( + "AdjustFaderVolume", arg=(0, (-5.0, -150.0, 3.0)) + )