diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 045d18f..2f3745c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,20 +5,30 @@ on: [push] jobs: test: runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.8', '3.11'] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v3 with: - python-version: '3.x' + python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip - pip install PyYAML - pip install websocket_client - pip install . + pip install ".[dev]" - name: Test with pytest run: | pip install pytest pip install pytest-cov pytest --cov=camilladsp --cov-report=html + - name: pylint + run: | + pylint camilladsp + - name: Lint types + run: | + mypy --install-types --non-interactive camilladsp + - name: Lint formatting + run: | + black . --check --verbose \ No newline at end of file diff --git a/README.md b/README.md index 2af8099..2a2b1d4 100644 --- a/README.md +++ b/README.md @@ -2,170 +2,9 @@ # pyCamillaDSP Companion Python library for CamillaDSP. -Works with CamillaDSP version 1.0.0 and up. +Works with CamillaDSP version 2.0.0 and up. -Download the library, either by `git clone` or by downloading a zip file of the code. Then unpack the files, go to the folder containing the `setup.py` file and run: -```sh -pip install . -``` -Note that on some systems the command is `pip3` instead of `pip`. - -## Dependencies -pyCamillaDSP requires python 3.6 or newer and the package websocket-client. - -These are the names of the packages needed: - -| Distribution | python | websocket-client | -|-----------------|---------|--------------------------| -| Fedora | python3 | python3-websocket-client | -| Debian/Raspbian | python3 | python3-websocket | -| Arch | python | python-websocket-client | -| pip | - | websocket_client | -| Anaconda | - | websocket_client | - -### Linux -Most linux distributions have Python 3.6 or newer installed by default. Use the normal package manager to install the packages. - -### Windows -Use Anaconda: https://www.anaconda.com/products/individual. Then use Anaconda Navigator to install `websocket_client`. - -### macOS -On macOS use either Anaconda or Homebrew. The Anaconda procedure is the same as for Windows. - -For Homebrew, install Python with `brew install python`, after which you can install the needed packages with pip, `pip3 install websocket_client`. - -## Communicating with the CamillaDSP process -This library provides an easy way to communicate with CamillaDSP via a websocket. - -Simple example to connect to CamillaDSP to read the version (assuming CamillaDSP is running on the same machine and listening on port 1234): -```python -from camilladsp import CamillaConnection - -cdsp = CamillaConnection("127.0.0.1", 1234) -cdsp.connect() -print("Version: {}".format(cdsp.get_version())) -``` - -### Classes -All communication functionality is provided by the class CamillaConnection. The contructor accepts two arguments: host and port. -``` -CamillaConnection(host, port) -``` - -### Exceptions - -The custom exception `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 -from camilladsp import CamillaConnection, CamillaError -cdsp = CamillaConnection("127.0.0.1", 1234) - -myconfig = # get a config from somewhere -try: - cdsp.connect() - cdsp.validate_config(myconfig) -except ConnectionRefusedError as e: - print("Can't connect to CamillaDSP, is it running? Error:", e) -except CamillaError as e: - print("CamillaDSP replied with error:", e) -except IOError as e: - print("Websocket is not connected:", e) -``` -- `ConnectionRefusedError` means that CamillaDSP isn't responding on the given host and port. -- `CamillaError` means that the command was sent and CamillaDSP replied with an error. -- `IOError` can mean a few things, but the most likely is that the websocket connection was lost. This happens if the CamillaDSP process exits or is restarted. - - -## Methods - -The CamillaConnection class provides the following methods - -### Basics - -| Method | Description | -|----------|---------------| -|`connect()` | Connect to the Websocket server. Must be called before any other method can be used.| -|`disconnect()` | Close the connection to the websocket.| -|`is_connected()` | Is websocket connected? Returns True or False.| -|`get_version()` | Read CamillaDSP version, returns a tuple with 3 elements.| -|`get_library_version()` | Read pyCamillaDSP version, returns a tuple with 3 elements.| -|`get_state()` | Get current processing state. Returns a ProcessingState enum value, see "Enums" below.| -|`get_stop_reason()` | Get the reason that processing stopped. Returns a StopReason enum value, see "Enums" below. | -|`def get_supported_device_types()`| 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. | -|`stop()` | Stop processing and wait for new config if wait mode is active, else exit. | -|`exit()` | Stop processing and exit.| - - -### Config handling -| Method | Description | -|----------|---------------| -|`reload()` | Reload config from disk.| -|`get_config_name()` | Get path to current config file.| -|`set_config_name(value)` | Set path to config file.| -|`get_config_raw()` | Get the active configuration in yaml format as a string.| -|`set_config_raw(value)` | Upload a new configuration in yaml format as a string.| -|`get_config()` | Get the active configuration as an object.| -|`get_previous_config()` | Get the previously active configuration as an object.| -|`set_config(config)` | Upload a new configuration from an object.| -|`validate_config(config)` | Validate a configuration object. Returns the validated config with all optional fields filled with defaults. Raises a CamillaError on errors.| -|`read_config_file(path)` | Read a config file from `path`. Returns the loaded config with all optional fields filled with defaults. Raises a CamillaError on errors.| -|`read_config(config)` | Read a config from yaml string and return the contents as an object, with defaults filled out with their default values.| - -### Reading status -| Method | Description | -|----------|---------------| -|`get_signal_range()` | Get current signal range.| -|`get_signal_range_dB()` | Get current signal range in dB.| -|`get_capture_signal_rms()` | Get capture signal level rms in dB. Full scale is 0 dB. Returns a list with one element per channel.| -|`get_playback_signal_rms()` | Get playback signal level rms in dB. Full scale is 0 dB. Returns a list with one element per channel.| -|`get_capture_signal_peak()` | Get capture signal level peak in dB. Full scale is 0 dB. Returns a list with one element per channel.| -|`get_playback_signal_peak()` | Get playback signal level peak in dB. Full scale is 0 dB. Returns a list with one element per channel.| -|`get_capture_rate_raw()` | Get current capture rate, raw value.| -|`get_capture_rate()` | Get current capture rate. Returns the nearest common rate, as long as it's within +-4% of the measured value.| -|`get_update_interval()` | Get current update interval in ms.| -|`set_update_interval(value)` | Set current update interval in ms.| -|`get_rate_adjust()` | Get current value for rate adjust.| -|`get_buffer_level()` | Get current buffer level of the playback device.| -|`get_clipped_samples()` | Get number of clipped samples since the config was loaded.| - -### Volume control -| Method | Description | -|----------|---------------| -|`get_volume()` | Get current volume setting in dB.| -|`set_volume(value)` | Set volume in dB.| -|`get_mute()` | Get current mute setting.| -|`set_mute(value)` | Set mute, true or false.| - - -## Enums - -### ProcessingState -- RUNNING: Processing is running. -- PAUSED: Processing is paused. -- INACTIVE: CamillaDSP is inactive, and waiting for a new config to be supplied. -- STARTING: The processing is being set up. -- STALLED: The processing is stalled because the capture device isn't providing any data. - -### StopReason -- NONE: Processing hasn't stopped yet. -- DONE: The capture device reached the end of the stream. -- CAPTUREERROR: The capture device encountered an error. -- PLAYBACKERROR: The playback device encountered an error. -- CAPTUREFORMATCHANGE: The sample format of the capture device changed. -- PLAYBACKFORMATCHANGE: The sample format of the playback device changed. - -The StopReason enums also carry additional data: -- CAPTUREERROR and PLAYBACKERROR: Carries the error message as a string. -- CAPTUREFORMATCHANGE and PLAYBACKFORMATCHANGE: Carries the estimated new sample rate as an integer. A value of 0 means the new rate is unknown. - -The additional data can be accessed by reading the `data` property: -```python -reason = cdsp.get_stop_reason() -if reason == StopReason.CAPTUREERROR: - error_msg = reason.data - print(f"Capture failed, error: {error_msg}) -``` +See the [published documentation](https://henquist.github.io/pycamilladsp/)for descriptions of all classes and methods. # Included examples: @@ -183,15 +22,58 @@ python get_config.py 1234 ``` ## set_volume -Set the volume control to a new value. First argument is websocket port, second is new volume in dB. -For this to work, CamillaDSP must be running a configuration that has Volume filters in the pipeline for every channel. +Set the main volume control to a new value. First argument is websocket port, second is new volume in dB. ```sh python set_volume.py 1234 -12.3 ``` ## play_wav -Play a wav file. This example reads a configuration from a file, updates the capture device fto point at a given .wav file, and sends this modified config to CamillaDSP. +Play a wav file. This example reads a configuration from a file, updates the capture device to point at a given .wav file, and sends this modified config to CamillaDSP. Usage example: ```sh python play_wav.py 1234 /path/to/wavtest.yml /path/to/music.wav ``` + +# Development info +## Code quality +This library uses the following tools to ensure code quality: +- pylint +- mypy +- black +- pytest + +Check out the code and install the optional development depenencies with pip: + +```console +pip install ".[dev]" +mypy --install-types +``` + +Run the entire suite of checks and tests: +```console +pylint camilladsp +mypy camilladsp +black camilladsp +pytest +``` + +## Documentation +The documentation is generated by mkdocs and mkdocstrings. +It is generated from the templates stored in `docs`. +The content is autogenerated from the docstrings in the code. + +Install the optional dependencies for docs generation: +```console +pip install ".[docs]" +``` + +Preview the documentation: + +```console +mkdocs serve +```` + +Publish to Github pages: +```console +mkdocs gh-deploy +``` \ No newline at end of file diff --git a/camilladsp/__init__.py b/camilladsp/__init__.py index bc33ba3..ca9e848 100644 --- a/camilladsp/__init__.py +++ b/camilladsp/__init__.py @@ -1 +1,9 @@ -from camilladsp.camilladsp import CamillaConnection, CamillaError, ProcessingState, StopReason +""" +Python library for communicating with CamillaDSP. +""" + +from camilladsp.camilladsp import CamillaClient, CamillaError +from camilladsp.datastructures import ( + ProcessingState, + StopReason, +) diff --git a/camilladsp/camilladsp.py b/camilladsp/camilladsp.py index bef3d11..bdcfae7 100644 --- a/camilladsp/camilladsp.py +++ b/camilladsp/camilladsp.py @@ -1,139 +1,83 @@ -import yaml +""" +Python library for communicating with CamillaDSP. + +The main component is the `CamillaClient` class. +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. +These methods are accessible via the `volume` property of the CamillaClient. +Reading the main volume is then done by calling `my_client.volume.main()`. +""" + + import json -from websocket import create_connection import math +from typing import Dict, Tuple, List, Optional from threading import Lock -from enum import Enum, auto - -VERSION = (1, 0, 0) - -STANDARD_RATES = ( - 8000, - 11025, - 16000, - 22050, - 32000, - 44100, - 48000, - 88200, - 96000, - 176400, - 192000, - 352800, - 384000, - 705600, - 768000, +import yaml +from websocket import create_connection, WebSocket # type: ignore + +from .datastructures import ( + ProcessingState, + StopReason, + _STANDARD_RATES, + _state_from_string, + _reason_from_reply, ) -class ProcessingState(Enum): - RUNNING = auto() - PAUSED = auto() - INACTIVE = auto() - STARTING = auto() - STALLED = auto() - -def _state_from_string(value): - if value == "Running": - return ProcessingState.RUNNING - elif value == "Paused": - return ProcessingState.PAUSED - elif value == "Inactive": - return ProcessingState.INACTIVE - elif value == "Starting": - return ProcessingState.STARTING - elif value == "Stalled": - return ProcessingState.STALLED - return None - - -class StopReason(Enum): - NONE = auto() - DONE = auto() - CAPTUREERROR = auto() - PLAYBACKERROR = auto() - CAPTUREFORMATCHANGE = auto() - PLAYBACKFORMATCHANGE = auto() - - def __new__(cls, value): - obj = object.__new__(cls) - obj._value_ = value - obj._data = None - return obj - - def set_data(self, value): - self._data = value - - @property - def data(self): - return self._data - -def _reason_from_reply(value): - if isinstance(value, dict): - reason, data = next(iter(value.items())) - else: - reason = value - data = None - - if reason == "None": - reasonenum = StopReason.NONE - elif reason == "Done": - reasonenum = StopReason.DONE - elif reason == "CaptureError": - reasonenum = StopReason.CAPTUREERROR - elif reason == "PlaybackError": - reasonenum = StopReason.PLAYBACKERROR - elif reason == "CaptureFormatChange": - reasonenum = StopReason.CAPTUREFORMATCHANGE - elif reason == "PlaybackFormatChange": - reasonenum = StopReason.PLAYBACKFORMATCHANGE - else: - raise ValueError(f"Invalid value for StopReason: {value}") - reasonenum.set_data(data) - return reasonenum - +VERSION = "2.0.0" class CamillaError(ValueError): """ - A class representing errors returned by CamillaDSP + A class representing errors returned by CamillaDSP. """ - pass - -class CamillaConnection: - """ - Class for communicating with CamillaDSP. - """ - - def __init__(self, host, port): +class _CamillaWS: + def __init__(self, host: str, port: int): """ - Connect to CamillaDSP on the specified host and port. + 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 = None - self._version = None + self._ws: Optional[WebSocket] = None + self.cdsp_version: Optional[Tuple[str, str, str]] = None self._lock = Lock() - def _query(self, command, arg=None): - if self._ws is not None: - 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 _e: - self._ws = None - raise IOError("Lost connection to CamillaDSP") - return self._handle_reply(command, rawrepl) - else: + 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, rawreply): + def _handle_reply(self, command: str, rawreply: str): try: reply = json.loads(rawreply) value = None @@ -143,18 +87,20 @@ def _handle_reply(self, command, rawreply): value = reply[command]["value"] if state == "Error" and value is not None: raise CamillaError(value) - elif state == "Error" and value is None: + if state == "Error" and value is None: raise CamillaError("Command returned an error") - elif state == "Ok" and value is not None: + if state == "Ok" and value is not None: return value - return - else: - raise IOError("Invalid response received: {}".format(rawreply)) - except json.JSONDecodeError: - raise IOError("Invalid response received: {}".format(rawreply)) + return None + raise IOError(f"Invalid response received: {rawreply}") + except json.JSONDecodeError as err: + raise IOError(f"Invalid response received: {rawreply}") from err - def _update_version(self, resp): - self._version = tuple(resp.split(".", 3)) + 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): """ @@ -162,10 +108,8 @@ def connect(self): """ try: with self._lock: - self._ws = create_connection( - "ws://{}:{}".format(self._host, self._port) - ) - rawvers = self._query("GetVersion") + 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 @@ -179,266 +123,836 @@ def disconnect(self): try: with self._lock: self._ws.close() - except Exception as _e: + except Exception as _e: # pylint: disable=broad-exception-caught pass self._ws = None def is_connected(self): """ - Is websocket connected? Returns True or False. + Is websocket connected? + + Returns: + bool: True if connected, False otherwise. """ return self._ws is not None - def get_version(self): - """Read CamillaDSP version, returns a tuple of (major, minor, patch).""" - return self._version - def get_supported_device_types(self): +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: """ - 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. + Get current value for rate adjust, 1.0 means 1:1 resampling. + + Returns: + float: Rate adjust value. """ - (playback, capture) = self._query("GetSupportedDeviceTypes") - return (playback, capture) + adj = self.client.query("GetRateAdjust") + return float(adj) - def get_library_version(self): - """Read pycamilladsp version, returns a tuple of (major, minor, patch).""" - return VERSION + def buffer_level(self) -> int: + """ + Get current buffer level of the playback device. - def get_state(self): + Returns: + int: Buffer level in frames. """ - Get current processing state. + level = self.client.query("GetBufferLevel") + return int(level) + + def clipped_samples(self) -> int: """ - state = self._query("GetState") - return _state_from_string(state) + Get number of clipped samples since the config was loaded. - def get_stop_reason(self): + Returns: + int: Number of clipped samples. """ - Get current processing state. + clipped = self.client.query("GetClippedSamples") + return int(clipped) + + def processing_load(self) -> float: """ - reason = self._query("GetStopReason") - return _reason_from_reply(reason) + 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 get_signal_range(self): + def range(self) -> float: """ - Get current signal range. Maximum value is 2.0. + Get signal range for the last processed chunk. Full scale is 2.0. """ - sigrange = self._query("GetSignalRange") + sigrange = self.client.query("GetSignalRange") return float(sigrange) - def get_signal_range_dB(self): + def range_decibel(self) -> float: """ - Get current signal range in dB. Full scale is 0 dB. + Get current signal range in dB for the last processed chunk. + Full scale is 0 dB. """ - sigrange = self.get_signal_range() + sigrange = self.range() if sigrange > 0.0: - range_dB = 20.0 * math.log10(sigrange / 2.0) + range_decibel = 20.0 * math.log10(sigrange / 2.0) else: - range_dB = -1000 - return range_dB + range_decibel = -1000 + return range_decibel - def get_capture_signal_rms(self): + def capture_rms(self) -> List[float]: """ - Get capture signal level rms in dB. Full scale is 0 dB. Returns a list with one element per channel. + 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._query("GetCaptureSignalRms") - sigrms = [float(val) for val in sigrms] + sigrms = self.client.query("GetCaptureSignalRms") return sigrms - def get_playback_signal_rms(self): + def playback_rms(self) -> List[float]: """ - Get playback signal level rms in dB. Full scale is 0 dB. Returns a list with one element per channel. + 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._query("GetPlaybackSignalRms") - sigrms = [float(val) for val in sigrms] + sigrms = self.client.query("GetPlaybackSignalRms") return sigrms - def get_capture_signal_peak(self): + def capture_peak(self) -> List[float]: """ - Get capture signal level peak in dB. Full scale is 0 dB. Returns a list with one element per channel. + 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._query("GetCaptureSignalPeak") - sigpeak = [float(val) for val in sigpeak] + sigpeak = self.client.query("GetCaptureSignalPeak") return sigpeak - def get_playback_signal_peak(self): + def playback_peak(self) -> List[float]: """ - Get playback signal level peak in dB. Full scale is 0 dB. Returns a list with one element per channel. + 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._query("GetPlaybackSignalPeak") - sigpeak = [float(val) for val in sigpeak] + sigpeak = self.client.query("GetPlaybackSignalPeak") return sigpeak - def get_volume(self): - """ - Get current volume setting in dB. + def playback_peak_since(self, interval: float) -> List[float]: """ - vol = self._query("GetVolume") - return float(vol) + 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. - def set_volume(self, value): - """ - Set volume in dB. + Args: + interval (float): Length of interval in seconds. """ - self._query("SetVolume", arg=float(value)) + sigpeak = self.client.query("GetPlaybackSignalPeakSince", arg=float(interval)) + return sigpeak - def get_mute(self): - """ - Get current mute setting. + def playback_rms_since(self, interval: float) -> List[float]: """ - mute = self._query("GetMute") - return bool(mute) + 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. - def set_mute(self, value): + Args: + interval (float): Length of interval in seconds. """ - Set mute, true or false. + sigrms = self.client.query("GetPlaybackSignalRmsSince", arg=float(interval)) + return sigrms + + def capture_peak_since(self, interval: float) -> List[float]: """ - self._query("SetMute", arg=bool(value)) + 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 get_capture_rate_raw(self): + def capture_rms_since(self, interval: float) -> List[float]: """ - Get current capture rate, raw value. + 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. """ - rate = self._query("GetCaptureRate") - return int(rate) + sigrms = self.client.query("GetCaptureSignalRmsSince", arg=float(interval)) + return sigrms - def get_capture_rate(self): + def playback_peak_since_last(self) -> List[float]: """ - Get current capture rate. Returns the nearest common rate, as long as it's within +-4% of the measured value. + 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. """ - rate = self.get_capture_rate_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 + sigpeak = self.client.query("GetPlaybackSignalPeakSinceLast") + return sigpeak - def get_update_interval(self): + def playback_rms_since_last(self) -> List[float]: """ - Get current update interval in ms. + 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. """ - interval = self._query("GetUpdateInterval") - return int(interval) + sigrms = self.client.query("GetPlaybackSignalRmsSinceLast") + return sigrms - def set_update_interval(self, value): + def capture_peak_since_last(self) -> List[float]: """ - Set current update interval in ms. + 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. """ - self._query("SetUpdateInterval", arg=value) + sigpeak = self.client.query("GetCaptureSignalPeakSinceLast") + return sigpeak - def get_rate_adjust(self): + def capture_rms_since_last(self) -> List[float]: """ - Get current value for rate adjust, 1.0 means 1:1 resampling. + 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. """ - adj = self._query("GetRateAdjust") - return float(adj) + sigrms = self.client.query("GetCaptureSignalRmsSinceLast") + return sigrms - def get_buffer_level(self): + def levels(self) -> Dict[str, List[float]]: """ - Get current buffer level of the playback device. + 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. """ - level = self._query("GetBufferLevel") - return int(level) + siglevels = self.client.query("GetSignalLevels") + return siglevels - def get_clipped_samples(self): + def levels_since(self, interval: float) -> Dict[str, List[float]]: """ - Get number of clipped samples since the config was loaded. + 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. """ - clipped = self._query("GetClippedSamples") - return int(clipped) + siglevels = self.client.query("GetSignalLevelsSince", arg=float(interval)) + return siglevels - def stop(self): + def levels_since_last(self) -> Dict[str, List[float]]: """ - Stop processing and wait for new config if wait mode is active, else exit. + 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. """ - self._query("Stop") + siglevels = self.client.query("GetSignalLevelsSinceLast") + return siglevels - def exit(self): + def peaks_since_start(self) -> Dict[str, List[float]]: """ - Stop processing and exit. + Get the playback and capture peak level since processing started. + The values are returned as a json object with keys `playback` and `capture`. """ - self._query("Exit") + peaks = self.client.query("GetSignalPeaksSinceStart") + return peaks - def reload(self): + def reset_peaks_since_start(self): """ - Reload config from disk. + Reset the peak level values. """ - self._query("Reload") + self.client.query("ResetSignalPeaksSinceStart") + + +class Config(_CommandGroup): + """ + Collection of methods for configuration management + """ - def get_config_name(self): + def file_path(self) -> Optional[str]: """ Get path to current config file. + + Returns: + str | None: Path to config file, or None. """ - name = self._query("GetConfigName") + name = self.client.query("GetConfigFilePath") return name - def set_config_name(self, value): + def set_file_path(self, value: str): """ - Set path to config file. + Set path to config file, without loading it. + Call `reload()` to apply the new config file. + + Args: + value (str): Path to config file. """ - self._query("SetConfigName", arg=value) + self.client.query("SetConfigFilePath", arg=value) - def get_config_raw(self): + def active_raw(self) -> Optional[str]: """ - Get the active configuation in yaml format as a string. + 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._query("GetConfig") + config_string = self.client.query("GetConfig") return config_string - def set_config_raw(self, config_string): + def set_active_raw(self, config_string: str): """ - Upload a new configuation in yaml format as a string. + Upload and apply a new configuration in raw yaml format (as a string). + + Args: + config_string (str): Config as yaml string. """ - self._query("SetConfig", arg=config_string) + self.client.query("SetConfig", arg=config_string) - def get_config(self): + def active(self) -> Optional[Dict]: """ - Get the active configuation as a Python object. + Get the active configuration as a Python object. + + Returns: + Dict | None: Current config as a Python dict, or None. """ - config_string = self.get_config_raw() + config_string = self.active_raw() + if config_string is None: + return None config_object = yaml.safe_load(config_string) return config_object - def get_previous_config(self): + def previous(self) -> Optional[Dict]: """ - Get the previously active configuation as a Python object. + Get the previously active configuration as a Python object. + + Returns: + Dict | None: Previous config as a Python dict, or None. """ - config_string = self._query("GetPreviousConfig") + config_string = self.client.query("GetPreviousConfig") config_object = yaml.safe_load(config_string) return config_object - def read_config(self, config_string): + def parse_yaml(self, config_string: str) -> Dict: """ - Read a config from yaml string and return the contents + 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._query("ReadConfig", arg=config_string) + config_raw = self.client.query("ReadConfig", arg=config_string) config_object = yaml.safe_load(config_raw) return config_object - def read_config_file(self, filename): + def read_and_parse_file(self, filename: str) -> Dict: """ - Read a config file from disk and return the contents as a Python object. + 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._query("ReadConfigFile", arg=filename) + config_raw = self.client.query("ReadConfigFile", arg=filename) config = yaml.safe_load(config_raw) return config - def set_config(self, config_object): + def set_active(self, config_object: Dict): """ - Upload a new configuation from a Python object. + 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_config_raw(config_raw) + self.set_active_raw(config_raw) - def validate_config(self, config_object): + 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._query("ValidateConfig", arg=config_string) + 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 Volume(_CommandGroup): + """ + Collection of methods for volume and mute control + """ + + def main(self) -> float: + """ + Get current main volume setting in dB. + Equivalent to calling `get_fader_volume()` with `fader=0`. + + Returns: + float: Current volume setting. + """ + vol = self.client.query("GetVolume") + return float(vol) + + def set_main(self, value: float): + """ + Set main volume in dB. + Equivalent to calling `set_fader()` with `fader=0`. + + Args: + value (float): New volume in dB. + """ + self.client.query("SetVolume", arg=float(value)) + + def fader(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_fader(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_fader_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_fader(self, fader: int, value: float) -> float: + """ + Adjust volume for the given fader in dB. + Positive values increase the volume, negative decrease. + + 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. + + Returns: + float: New volume setting. + """ + _fader, new_vol = self.client.query( + "AdjustFaderVolume", arg=(int(fader), float(value)) + ) + return float(new_vol) + + +class Mute(_CommandGroup): + """ + Collection of methods for mute control + """ + + def main(self) -> bool: + """ + Get current main mute setting. + Equivalent to calling `get_fader()` with `fader=0`. + + Returns: + bool: True if muted, False otherwise. + """ + mute = self.client.query("GetMute") + return bool(mute) + + def set_main(self, value: bool): + """ + Set main mute, true or false. + Equivalent to calling `set_fader()` with `fader=0`. + + Args: + value (bool): New mute setting. + """ + self.client.query("SetMute", arg=bool(value)) + + def fader(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_fader(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_fader(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("SetFaderMute", 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]) + + +class CamillaClient(_CamillaWS): + """ + Class for communicating with CamillaDSP. + + Args: + host (str): Hostname where CamillaDSP runs. + port (int): Port number of the CamillaDSP websocket server. + """ + + # pylint: disable=too-many-instance-attributes + + def __init__(self, host: str, port: int): + """ + Create a new CamillaClient. + + Args: + host (str): Hostname where CamillaDSP runs. + port (int): Port number of the CamillaDSP websocket server. + """ + super().__init__(host, port) + + self._volume = Volume(self) + self._mute = Mute(self) + self._rate = RateMonitor(self) + self._levels = Levels(self) + self._config = Config(self) + self._status = Status(self) + self._settings = Settings(self) + self._general = General(self) + self._versions = Versions(self) + + @property + def volume(self) -> Volume: + """ + A `Volume` instance for volume controls. + """ + return self._volume + + @property + def mute(self) -> Mute: + """ + A `Mute` instance for mute controls. + """ + return self._mute + + @property + def rate(self) -> RateMonitor: + """ + A `RateMonitor` instance for rate monitoring commands. + """ + return self._rate + + @property + def levels(self) -> Levels: + """ + A `Levels` instance for signal level monitoring. + """ + return self._levels + + @property + def config(self) -> Config: + """ + A `Config` instance for config management commands. + """ + return self._config + + @property + def status(self) -> Status: + """ + A `Status` instance for status commands. + """ + return self._status + + @property + def settings(self) -> Settings: + """ + A `Settings` instance for reading and writing settings. + """ + return self._settings + + @property + def general(self) -> General: + """ + A `General` instance for basic commands. + """ + return self._general + + @property + def versions(self) -> Versions: + """ + A `Versions` instance for version info. + """ + return self._versions diff --git a/camilladsp/datastructures.py b/camilladsp/datastructures.py new file mode 100644 index 0000000..8f66aec --- /dev/null +++ b/camilladsp/datastructures.py @@ -0,0 +1,130 @@ +""" +Various data structures used for communicating with CamillaDSP. +""" + +from enum import Enum, auto +from typing import Optional + +_STANDARD_RATES = ( + 8000, + 11025, + 16000, + 22050, + 32000, + 44100, + 48000, + 88200, + 96000, + 176400, + 192000, + 352800, + 384000, + 705600, + 768000, +) + + +class ProcessingState(Enum): + """ + An enum representing the different processing states of CamillaDSP. + """ + + RUNNING = auto() + """Processing is running""" + PAUSED = auto() + """Processing is paused""" + INACTIVE = auto() + """CamillaDSP is inactive, and waiting for a new config to be supplied""" + STARTING = auto() + """The processing is being set up""" + STALLED = auto() + """The processing is stalled because the capture device isn't providing any data""" + + +def _state_from_string(value: str) -> Optional[ProcessingState]: + if value == "Running": + return ProcessingState.RUNNING + if value == "Paused": + return ProcessingState.PAUSED + if value == "Inactive": + return ProcessingState.INACTIVE + if value == "Starting": + return ProcessingState.STARTING + if value == "Stalled": + return ProcessingState.STALLED + return None + + +class StopReason(Enum): + """ + An enum representing the possible reasons why CamillaDSP stopped processing. + The StopReason enums carry additional data: + - CAPTUREERROR and PLAYBACKERROR: + Carries the error message as a string. + - CAPTUREFORMATCHANGE and PLAYBACKFORMATCHANGE: + Carries the estimated new sample rate as an integer. + A value of 0 means the new rate is unknown. + + The additional data can be accessed by reading the `data` property. + ```python + reason = cdsp.general.stop_reason() + if reason == StopReason.CAPTUREERROR: + error_msg = reason.data + print(f"Capture failed, error: {error_msg}") + ``` + """ + + NONE = auto() + """Processing is running and hasn't stopped yet.""" + DONE = auto() + """The capture device reached the end of the stream.""" + CAPTUREERROR = auto() + """The capture device encountered an error.""" + PLAYBACKERROR = auto() + """The playback device encountered an error.""" + CAPTUREFORMATCHANGE = auto() + """The sample format or rate of the capture device changed. """ + PLAYBACKFORMATCHANGE = auto() + """The sample format or rate of the playback device changed.""" + + def __new__(cls, value): + obj = object.__new__(cls) + obj._value_ = value + obj._data = None + return obj + + def set_data(self, value): + """ + Set the custom data property of the enum. + """ + self._data = value + + @property + def data(self): + """Getter for the data property""" + return self._data + + +def _reason_from_reply(value): + if isinstance(value, dict): + reason, data = next(iter(value.items())) + else: + reason = value + data = None + + if reason == "None": + reasonenum = StopReason.NONE + elif reason == "Done": + reasonenum = StopReason.DONE + elif reason == "CaptureError": + reasonenum = StopReason.CAPTUREERROR + elif reason == "PlaybackError": + reasonenum = StopReason.PLAYBACKERROR + elif reason == "CaptureFormatChange": + reasonenum = StopReason.CAPTUREFORMATCHANGE + elif reason == "PlaybackFormatChange": + reasonenum = StopReason.PLAYBACKFORMATCHANGE + else: + raise ValueError(f"Invalid value for StopReason: {value}") + reasonenum.set_data(data) + return reasonenum diff --git a/docs/client.md b/docs/client.md new file mode 100644 index 0000000..2eb0b92 --- /dev/null +++ b/docs/client.md @@ -0,0 +1,4 @@ +# CamillaDSP websocket client + +## CamillaClient: main client +::: camilladsp.CamillaClient diff --git a/docs/config.md b/docs/config.md new file mode 100644 index 0000000..a080c95 --- /dev/null +++ b/docs/config.md @@ -0,0 +1,7 @@ +# Config management +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 diff --git a/docs/enums.md b/docs/enums.md new file mode 100644 index 0000000..1d6cc17 --- /dev/null +++ b/docs/enums.md @@ -0,0 +1,7 @@ +## Enums + +### ProcessingState +::: camilladsp.datastructures.ProcessingState + +### StopReason +::: camilladsp.datastructures.StopReason diff --git a/docs/errors.md b/docs/errors.md new file mode 100644 index 0000000..91b2982 --- /dev/null +++ b/docs/errors.md @@ -0,0 +1,27 @@ + +# 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. + +Different exceptions are raised in different situations. Consider the following example: +```python +from camilladsp import CamillaConnection, CamillaError +cdsp = CamillaConnection("127.0.0.1", 1234) + +myconfig = # get a config from somewhere +try: + cdsp.connect() + cdsp.config.validate(myconfig) +except ConnectionRefusedError as e: + print("Can't connect to CamillaDSP, is it running? Error:", e) +except CamillaError as e: + print("CamillaDSP replied with error:", e) +except IOError as e: + print("Websocket is not connected:", e) +``` +- `ConnectionRefusedError` means that CamillaDSP isn't responding on the given host and port. +- `CamillaError` means that the command was sent and CamillaDSP replied with an error. +- `IOError` can mean a few things, but the most likely is that the websocket connection was lost. This happens if the CamillaDSP process exits or is restarted. + +## CamillaError +::: camilladsp.camilladsp.CamillaError \ No newline at end of file diff --git a/docs/general.md b/docs/general.md new file mode 100644 index 0000000..533a722 --- /dev/null +++ b/docs/general.md @@ -0,0 +1,7 @@ +# General commands +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 diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..14f6eb2 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,132 @@ +# Overview + +Python library for communicating with CamillaDSP. + +The main component is the [CamillaClient][camilladsp.CamillaClient] class. +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. +These methods are accessible via the `volume` property of the CamillaClient. +Reading the main volume is then done by calling `my_client.volume.main()`. + +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`. + +Example: +```py +client = CamillaClient("localhost", 1234) + +volume = client.volume.main() +mute = client.mute.main() +state = client.general.state() +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 controls | +| [Mute][camilladsp.camilladsp.Mute] | `mute` | 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 | + +## All commands + +### [General][camilladsp.camilladsp.General] +These commands are accessed via the [general][camilladsp.CamillaClient.general] +property of a [CamillaClient][camilladsp.CamillaClient] instance. +::: camilladsp.camilladsp.General + options: + show_bases: false + show_source: false + show_docstring_parameters: false + show_docstring_returns: false + +### [Status][camilladsp.camilladsp.Status] +These commands are accessed via the [status][camilladsp.CamillaClient.status] +property of a [CamillaClient][camilladsp.CamillaClient] instance. +::: camilladsp.camilladsp.Status + options: + show_bases: false + show_source: false + show_docstring_parameters: false + show_docstring_returns: false + +### [Config][camilladsp.camilladsp.Config] +These commands are accessed via the [config][camilladsp.CamillaClient.config] +property of a [CamillaClient][camilladsp.CamillaClient] instance. +::: camilladsp.camilladsp.Config + options: + show_bases: false + show_source: false + show_docstring_parameters: false + show_docstring_returns: false + +### [Volume][camilladsp.camilladsp.Volume] +These commands are accessed via the [volume][camilladsp.CamillaClient.volume] +property of a [CamillaClient][camilladsp.CamillaClient] instance. +::: camilladsp.camilladsp.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] +These commands are accessed via the [levels][camilladsp.CamillaClient.levels] +property of a [CamillaClient][camilladsp.CamillaClient] instance. +::: camilladsp.camilladsp.Levels + options: + show_bases: false + show_source: false + show_docstring_parameters: false + show_docstring_returns: false + +### [RateMonitor][camilladsp.camilladsp.RateMonitor] +These commands are accessed via the [rate][camilladsp.CamillaClient.rate] +property of a [CamillaClient][camilladsp.CamillaClient] instance. +::: camilladsp.camilladsp.RateMonitor + options: + show_bases: false + show_source: false + show_docstring_parameters: false + show_docstring_returns: false + +### [Settings][camilladsp.camilladsp.Settings] +These commands are accessed via the [settings][camilladsp.CamillaClient.settings] +property of a [CamillaClient][camilladsp.CamillaClient] instance. +::: camilladsp.camilladsp.Settings + options: + show_bases: false + show_source: false + show_docstring_parameters: false + show_docstring_returns: false + +### [Versions][camilladsp.camilladsp.Versions] +These commands are accessed via the [versions][camilladsp.CamillaClient.versions] +property of a [CamillaClient][camilladsp.CamillaClient] instance. +::: camilladsp.camilladsp.Versions + options: + show_bases: false + show_source: falses + how_docstring_parameters: false + show_docstring_returns: false \ No newline at end of file diff --git a/docs/install.md b/docs/install.md new file mode 100644 index 0000000..68b8686 --- /dev/null +++ b/docs/install.md @@ -0,0 +1,163 @@ +# Installation instructions +As the name suggests, pyCamillaDSP is a Python library. It is installed using the `pip` package manager. + + +## Dependencies + +pyCamillaDSP only requires the `websocket-client` library. +The package is named slightly differently in different distributions: + +| Distribution | Package name | +|--------------------|--------------------------| +| Fedora | python3-websocket-client | +| Debian/Raspbian | python3-websocket | +| Arch | python-websocket-client | +| pypi.org (pip) | websocket_client | +| conda | websocket_client | + + +## About Python environments + +In all cases it is recommended to create an isolated Python environment. +This ensures that the installation of the CamillaDSP libraries does not interfere with +the environment managed by the system package manager. +This is mainly an issue on Linux, where the default Python environment is managed by +the system package manager (for example `apt` on Debian and `dnf` on Fedora). +Letting pip install packages into this environment is not recommended +and may corrupt the system. +Recent versions of pip will refuse to do this unless started with the flag `--break-system-packages`. + +There are several tools to set up Python environments. +For the sake of simplicity, this readme only deals with [conda](#conda) and [venv](#venv). + + +### conda + +The `conda` package manager is used in the popular Anaconda Python distribution. +It's possible to use the full Anaconda package, but it includes much more than needed. +Instead it's recommended to use the Miniconda package. + +Download Miniconda from https://docs.conda.io/en/latest/miniconda.html + +Alternatively use Miniforge from https://github.com/conda-forge/miniforge + +Conda creates a default `base` environment, but don't install any packages there. +It's better to create a specific environment. + +To do that open a terminal and type: +```console +conda create --name camilladsp +``` + +Before an environment can be used, either for install packages into it, +or for running some application, it must be activated. + +The command for that is: + +```console +> conda activate camilladsp +``` + +Now install the websocket-client library: + +```console +> conda install websocket_client +``` + +Finally install pyCamillaDSP with pip, see [Installing](#installing)). + + +### venv + +The standard Python library includes the `venv` tool that is used to create virtual Python environments. +This allows installing packages using pip separately, and prevents any issues from conflicts with the system package manager. +By default, all packages installed in the system environment are also available in the virtual environment. +See https://docs.python.org/3/library/venv.html for more details. + +Create a new venv, located in `camilladsp/.venv` in the user home directory: + +```console +> python -m venv ~/camilladsp/.venv +``` + +Activate the new environment: + +- Linux & MacOS + ```console + > source ~/camilladsp/.venv/bin/activate + ``` + +- Windows + + cmd.exe: + ```console + > %USERPROFILE%\camilladsp\.venv\Scripts\activate.bat + ``` + + PowerShell: + ```console + > $env:userprofile\camilladsp\.venv\Scripts\Activate.ps1 + ``` + +Finally install pyCamillaDSP with pip, see [Installing](#installing)). + +Once the environment is ready, it's possible to use it without first activating. +This is done by simply using the python interpreter of the environment: + +```console +> ~/camilladsp/.venv/bin/python some_script.py +``` + + +## Recommendations for different operating systems + +The way to set up a Python environment and install pyCamillaDSP depends on what operating system is used. +Linux normally comes with Python preinstalled, while Windows does not. +MacOS is somewhere in between in that it comes with a limited Python installation. + +### Linux +Most linux distributions have the required Python 3.6 or newer installed by default. +Use the normal package manager to install python if required, +and then create a [virtual environment](#venv) for pyCamillaDSP. + +It is also possible to use [Conda](#conda). + +### Windows +Use Anaconda, miniconda, or miniforge. See [Conda](#conda). + +### macOS +On macOS use either [conda](#conda), or Homebrew optionally with a [virtual environment](#venv). + +For Homebrew, install Python with `brew install python`, after which you can install the needed packages with pip, `pip3 install websocket_client`. + +## Installing +Once a suitable environment has been set up, use `pip` to install pyCamillaDSP. + +The `pip` package manager is normally installed by default together with Python. +The command is usually `pip`, but on some systems it's instead `pip3`. + +### Directly from Github +The easiest way to install is to let pip fetch the files directly from Github. + +The command for that is: +```console +pip install git+https://github.com/HEnquist/pycamilladsp.git +``` +This installs the current version in the default branch `master`. + +To install from another branch, or a tagged version, add `@` and the branch or tag name at the end. + +To install the version tagged with `v2.0.0`, the command is: +```console +pip install git+https://github.com/HEnquist/pycamilladsp.git@v2.0.0 +``` + +### Install from downloaded files +Download the library, either by `git clone` or by downloading a zip file of the code. +Then unpack the files, go to the folder containing the `setup.py` file and run: +```console +pip install . +``` + + + diff --git a/docs/levels.md b/docs/levels.md new file mode 100644 index 0000000..d8e7365 --- /dev/null +++ b/docs/levels.md @@ -0,0 +1,7 @@ +# Level monitoring +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 diff --git a/docs/mute.md b/docs/mute.md new file mode 100644 index 0000000..298ac85 --- /dev/null +++ b/docs/mute.md @@ -0,0 +1,7 @@ +# Mute control +This class is accessed via the `mute` property on a `CamillaClient` instance. + +It provides methods for reading and setting the mute control. + +## class: `Mute` +::: camilladsp.camilladsp.Mute \ No newline at end of file diff --git a/docs/rate.md b/docs/rate.md new file mode 100644 index 0000000..250bc4b --- /dev/null +++ b/docs/rate.md @@ -0,0 +1,7 @@ +# Sample rate monitoring +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 diff --git a/docs/settings.md b/docs/settings.md new file mode 100644 index 0000000..fe4629e --- /dev/null +++ b/docs/settings.md @@ -0,0 +1,7 @@ +# Server settings +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 diff --git a/docs/status.md b/docs/status.md new file mode 100644 index 0000000..5b48234 --- /dev/null +++ b/docs/status.md @@ -0,0 +1,7 @@ +# Status commands +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 diff --git a/docs/versions.md b/docs/versions.md new file mode 100644 index 0000000..fe8c73f --- /dev/null +++ b/docs/versions.md @@ -0,0 +1,7 @@ +# Software versions +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 diff --git a/docs/volume.md b/docs/volume.md new file mode 100644 index 0000000..f1d7f92 --- /dev/null +++ b/docs/volume.md @@ -0,0 +1,7 @@ +# Volume control +This class is accessed via the `volume` property on a `CamillaClient` instance. + +It provides methods for reading and setting the volume control. + +## class: `Volume` +::: camilladsp.camilladsp.Volume \ No newline at end of file diff --git a/examples/get_config/get_config.py b/examples/get_config/get_config.py index 3574f66..45204c4 100644 --- a/examples/get_config/get_config.py +++ b/examples/get_config/get_config.py @@ -1,5 +1,5 @@ # get config -from camilladsp import CamillaConnection +from camilladsp import CamillaClient import sys import time @@ -12,10 +12,10 @@ print("> python get_config.py 4321") sys.exit() -cdsp = CamillaConnection("127.0.0.1", port) +cdsp = CamillaClient("127.0.0.1", port) cdsp.connect() -conf = cdsp.get_config() +conf = cdsp.config.active() # Get some single parameters print(f'Capture device type: {conf["devices"]["capture"]["type"]}') diff --git a/examples/playwav/analyze_wav.py b/examples/playwav/analyze_wav.py index 38a3349..6f1846e 100644 --- a/examples/playwav/analyze_wav.py +++ b/examples/playwav/analyze_wav.py @@ -1,49 +1,51 @@ import struct import logging -sampleformats = {1: "int", +sampleformats = { + 1: "int", 3: "float", - } +} + def analyze_chunk(type, start, length, file, wav_info): if type == "fmt ": data = file.read(length) - wav_info['SampleFormat'] = sampleformats[struct.unpack('= input_filesize: break file_in.close() return wav_info - + + if __name__ == "__main__": import sys + info = read_wav_header(sys.argv[1]) print("Wav properties:") for name, val in info.items(): diff --git a/examples/playwav/play_wav.py b/examples/playwav/play_wav.py index 9713a75..544cb83 100644 --- a/examples/playwav/play_wav.py +++ b/examples/playwav/play_wav.py @@ -1,5 +1,5 @@ # play wav -from camilladsp import CamillaConnection +from camilladsp import CamillaClient import sys import os import yaml @@ -37,13 +37,12 @@ cfg["devices"]["capture_samplerate"] = wav_info["SampleRate"] cfg["devices"]["enable_rate_adjust"] = False if cfg["devices"]["samplerate"] != cfg["devices"]["capture_samplerate"]: - cfg["devices"]["enable_resampling"] = True - cfg["devices"]["resampler_type"] = "Synchronous" + cfg["devices"]["resampler"] = {"type": "Synchronous"} else: - cfg["devices"]["enable_resampling"] = False + cfg["devices"]["resampler"] = None cfg["devices"]["capture"] = capt_device # Send the modded config -cdsp = CamillaConnection("127.0.0.1", port) +cdsp = CamillaClient("127.0.0.1", port) cdsp.connect() -cdsp.set_config(cfg) +cdsp.config.set_active(cfg) diff --git a/examples/read_rms/read_rms.py b/examples/read_rms/read_rms.py index 1b240c2..1962234 100644 --- a/examples/read_rms/read_rms.py +++ b/examples/read_rms/read_rms.py @@ -1,5 +1,5 @@ # read rms -from camilladsp import CamillaConnection +from camilladsp import CamillaClient import sys import time @@ -12,11 +12,10 @@ print("> python read_rms.py 4321") sys.exit() -cdsp = CamillaConnection("127.0.0.1", port) +cdsp = CamillaClient("127.0.0.1", port) cdsp.connect() print("Reading playback signal RMS, press Ctrl+c to stop") while True: - print(cdsp.get_playback_signal_rms()) + print(cdsp.levels.playback_rms()) time.sleep(1) - diff --git a/examples/set_volume/set_volume.py b/examples/set_volume/set_volume.py index d83c42e..c9fbc44 100644 --- a/examples/set_volume/set_volume.py +++ b/examples/set_volume/set_volume.py @@ -1,5 +1,5 @@ # set volume -from camilladsp import CamillaConnection +from camilladsp import CamillaClient import sys import time @@ -7,18 +7,19 @@ port = int(sys.argv[1]) new_vol = float(sys.argv[2]) except: - print("Usage: Make sure that your pipeline includes Volume filters for each channel.") + print( + "Usage: Make sure that your pipeline includes Volume filters for each channel." + ) print("Then start CamillaDSP with the websocket server enabled:") print("> camilladsp -p4321 yourconfig.yml") print("Then set the volume") print("> python read_rms.py 4321 -12.3") sys.exit() -cdsp = CamillaConnection("127.0.0.1", port) +cdsp = CamillaClient("127.0.0.1", port) cdsp.connect() -current_vol = cdsp.get_volume() +current_vol = cdsp.volume.main() print(f"Current volume: {current_vol} dB") print(f"Changing volume to: {new_vol} dB") -cdsp.set_volume(new_vol) - +cdsp.volume.set_main(new_vol) diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..ef00341 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,24 @@ +site_name: pyCamillaDSP +repo_url: https://github.com/HEnquist/pycamilladsp + +theme: + name: "material" + +plugins: + - mkdocstrings + +nav: + - index.md + - client.md + - general.md + - status.md + - config.md + - volume.md + - mute.md + - levels.md + - rate.md + - settings.md + - versions.md + - enums.md + - errors.md + - install.md \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..89ed589 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,29 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["camilladsp"] + +[project] +name = "camilladsp" +authors = [ + {name = "Henrik Enquist", email = "henrik.enquist@gmail.com"}, +] +description = "Python library for communicating with CamillaDSP" +readme = "README.md" +requires-python = ">=3.8" +dependencies = [ + "PyYAML >= 6.0", + "websocket_client >= 1.6" +] +dynamic = ["version"] +license = {file = "LICENSE.txt"} + +[tool.setuptools.dynamic] +version = {attr = "camilladsp.camilladsp.VERSION"} + +[project.optional-dependencies] +dev = ["black >= 23.0.0", "pylint >= 2.17", "mypy >= 1.0", "pytest >= 7.0"] +docs = ["mkdocs", "mkdocs-material", "mkdocstrings"] + diff --git a/setup.py b/setup.py deleted file mode 100644 index 595a319..0000000 --- a/setup.py +++ /dev/null @@ -1,18 +0,0 @@ -import setuptools - -with open("README.md", "r") as fh: - long_description = fh.read() - -setuptools.setup( - name="camilladsp", - version="1.0.0", - author="Henrik Enquist", - author_email="henrik.enquist@gmail.com", - description="A library for communicating with CamillaDSP", - long_description=long_description, - long_description_content_type="text/markdown", - url="https://github.com/HEnquist/pycamilladsp", - packages=setuptools.find_packages(), - python_requires=">=3", - install_requires=["PyYAML", "websocket_client"], -) diff --git a/tests/test_camillaws.py b/tests/test_camillaws.py index 0dd5279..0b08dbb 100644 --- a/tests/test_camillaws.py +++ b/tests/test_camillaws.py @@ -4,6 +4,7 @@ import camilladsp import json + class DummyWS: def __init__(self): self.query = None @@ -13,16 +14,45 @@ def __init__(self): responses = { '"GetState"': json.dumps({"GetState": {"result": "Ok", "value": "Inactive"}}), '"GetVersion"': json.dumps({"GetVersion": {"result": "Ok", "value": "0.3.2"}}), - '"GetSupportedDeviceTypes"': json.dumps({"GetSupportedDeviceTypes": {"result": "Ok", "value": [["a", "b"], ["c", "d"]]}}), - '"GetSignalRange"': json.dumps({"GetSignalRange": {"result": "Ok", "value": "0.2"}}), - '"GetCaptureSignalRms"': json.dumps({"GetCaptureSignalRms": {"result": "Ok", "value": [0.1, 0.2]}}), - '"GetCaptureRate"': json.dumps({"GetCaptureRate": {"result": "Ok", "value": "88250"}}), - '"GetErrorValue"': json.dumps({"GetErrorValue": {"result": "Error", "value": "badstuff"}}), + '"GetSupportedDeviceTypes"': json.dumps( + { + "GetSupportedDeviceTypes": { + "result": "Ok", + "value": [["a", "b"], ["c", "d"]], + } + } + ), + '"GetSignalRange"': json.dumps( + {"GetSignalRange": {"result": "Ok", "value": "0.2"}} + ), + '"GetCaptureSignalRms"': json.dumps( + {"GetCaptureSignalRms": {"result": "Ok", "value": [0.1, 0.2]}} + ), + '"GetCaptureRate"': json.dumps( + {"GetCaptureRate": {"result": "Ok", "value": "88250"}} + ), + '{"GetFaderVolume": 1}': json.dumps( + {"GetFaderVolume": {"result": "Ok", "value": [1, -1.23]}} + ), + '"GetErrorValue"': json.dumps( + {"GetErrorValue": {"result": "Error", "value": "badstuff"}} + ), '"GetError"': json.dumps({"GetError": {"result": "Error"}}), '"Invalid"': json.dumps({"Invalid": {"result": "Error", "value": "badstuff"}}), - '"GetStopReason"': json.dumps({"GetStopReason": {"result": "Ok", "value": "Done"}}), - '"GetStopReason2"': json.dumps({"GetStopReason": {"result": "Ok", "value": {'CaptureFormatChange': 44098}}}), - '"GetStopReason3"': json.dumps({"GetStopReason": {"result": "Ok", "value": {'CaptureError': 'error error'}}}), + '"GetStopReason"': json.dumps( + {"GetStopReason": {"result": "Ok", "value": "Done"}} + ), + '"GetStopReason2"': json.dumps( + {"GetStopReason": {"result": "Ok", "value": {"CaptureFormatChange": 44098}}} + ), + '"GetStopReason3"': json.dumps( + { + "GetStopReason": { + "result": "Ok", + "value": {"CaptureError": "error error"}, + } + } + ), '"NotACommand"': json.dumps({"Invalid": {"result": "Error"}}), '{"SetSomeValue": 123}': json.dumps({"SetSomeValue": {"result": "Ok"}}), '"nonsense"': "abcdefgh", @@ -33,7 +63,7 @@ def send(self, query): if query == '"fail"': raise IOError("not connected") self.query = query - #if ":" in query: + # if ":" in query: # query, val = query.split(":",1) # self.response = "OK:{}".format(query.upper()) # self.value = val @@ -41,8 +71,9 @@ def send(self, query): if query in self.responses: self.response = self.responses[query] else: - self.response = json.dumps({"Invalid": {"result": "Error", "value": "badstuff"}}) - + self.response = json.dumps( + {"Invalid": {"result": "Error", "value": "badstuff"}} + ) def recv(self): print(self.response) @@ -56,171 +87,201 @@ 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): - cdsp = camilladsp.camilladsp.CamillaConnection("localhost", 1234) + with patch("camilladsp.camilladsp.create_connection", create_connection): + cdsp = camilladsp.camilladsp.CamillaClient("localhost", 1234) cdsp.dummyws = ws_dummy cdsp.mockconnection = connection yield cdsp + @pytest.fixture def camilla(): - cdsp = camilladsp.camilladsp.CamillaConnection("localhost", 12345) + cdsp = camilladsp.camilladsp.CamillaClient("localhost", 12345) yield cdsp + @pytest.fixture def camilla_mockquery(): query_dummy = MagicMock() - with patch('camilladsp.camilladsp.CamillaConnection._query', query_dummy): - cdsp = camilladsp.camilladsp.CamillaConnection("localhost", 1234) + with patch("camilladsp.camilladsp.CamillaClient.query", query_dummy): + cdsp = camilladsp.camilladsp.CamillaClient("localhost", 1234) yield cdsp + @pytest.fixture def camilla_mockquery_yaml(): query_dummy = MagicMock(return_value="some: value\n") - with patch('camilladsp.camilladsp.CamillaConnection._query', query_dummy): - cdsp = camilladsp.camilladsp.CamillaConnection("localhost", 1234) + with patch("camilladsp.camilladsp.CamillaClient.query", query_dummy): + cdsp = camilladsp.camilladsp.CamillaClient("localhost", 1234) yield cdsp + def test_connect(camilla_mockws): with pytest.raises(IOError): - camilla_mockws.get_state() + camilla_mockws.general.state() camilla_mockws.connect() assert camilla_mockws.is_connected() - assert camilla_mockws.get_state() == camilladsp.ProcessingState.INACTIVE - assert camilla_mockws.get_version() == ('0', '3', '2') - assert camilla_mockws.get_library_version() == camilladsp.camilladsp.VERSION + 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(".") + ) camilla_mockws.disconnect() assert not camilla_mockws.is_connected() + def test_connect_fail(camilla): with pytest.raises(IOError): camilla.connect() + def test_device_types(camilla_mockws): camilla_mockws.connect() - assert camilla_mockws.get_supported_device_types() == (["a", "b"], ["c", "d"]) + assert camilla_mockws.general.supported_device_types() == (["a", "b"], ["c", "d"]) + def test_signal_range(camilla_mockws): camilla_mockws.connect() - assert camilla_mockws.get_signal_range() == 0.2 + assert camilla_mockws.levels.range() == 0.2 + def test_signal_rms(camilla_mockws): camilla_mockws.connect() - assert camilla_mockws.get_capture_signal_rms() == [0.1, 0.2] + assert camilla_mockws.levels.capture_rms() == [0.1, 0.2] -def test_signal_range_dB(camilla_mockws): + +def test_signal_range_decibel(camilla_mockws): camilla_mockws.connect() - assert camilla_mockws.get_signal_range_dB() == -20 - camilla_mockws.dummyws.responses['"GetSignalRange"'] = json.dumps({"GetSignalRange": {"result": "Ok", "value": "0.0"}}) - assert camilla_mockws.get_signal_range_dB() == -1000 + assert camilla_mockws.levels.range_decibel() == -20 + camilla_mockws.dummyws.responses['"GetSignalRange"'] = json.dumps( + {"GetSignalRange": {"result": "Ok", "value": "0.0"}} + ) + assert camilla_mockws.levels.range_decibel() == -1000 + def test_disconnect_fail(camilla_mockws): camilla_mockws.connect() + def raise_error(): raise IOError("disconnected") + camilla_mockws.mockconnection.close = MagicMock(side_effect=raise_error) camilla_mockws.disconnect() assert not camilla_mockws.is_connected() + def test_capture_rate(camilla_mockws): camilla_mockws.connect() - assert camilla_mockws.get_capture_rate() == 88200 - assert camilla_mockws.get_capture_rate_raw() == 88250 + assert camilla_mockws.rate.capture() == 88200 + assert camilla_mockws.rate.capture_raw() == 88250 + def test_stop_reason(camilla_mockws): camilla_mockws.connect() - assert camilla_mockws.get_stop_reason() == StopReason.DONE - assert camilla_mockws.get_stop_reason().data == None + assert camilla_mockws.general.stop_reason() == StopReason.DONE + assert camilla_mockws.general.stop_reason().data == None print(camilla_mockws.dummyws.responses) - camilla_mockws.dummyws.responses['"GetStopReason"'] = camilla_mockws.dummyws.responses['"GetStopReason2"'] - assert camilla_mockws.get_stop_reason() == StopReason.CAPTUREFORMATCHANGE - assert camilla_mockws.get_stop_reason().data == 44098 - camilla_mockws.dummyws.responses['"GetStopReason"'] = camilla_mockws.dummyws.responses['"GetStopReason3"'] - assert camilla_mockws.get_stop_reason() == StopReason.CAPTUREERROR - assert camilla_mockws.get_stop_reason().data == "error error" + camilla_mockws.dummyws.responses[ + '"GetStopReason"' + ] = camilla_mockws.dummyws.responses['"GetStopReason2"'] + assert camilla_mockws.general.stop_reason() == StopReason.CAPTUREFORMATCHANGE + assert camilla_mockws.general.stop_reason().data == 44098 + camilla_mockws.dummyws.responses[ + '"GetStopReason"' + ] = camilla_mockws.dummyws.responses['"GetStopReason3"'] + assert camilla_mockws.general.stop_reason() == StopReason.CAPTUREERROR + assert camilla_mockws.general.stop_reason().data == "error error" + def test_query(camilla_mockws): camilla_mockws.connect() with pytest.raises(camilladsp.CamillaError): - camilla_mockws._query("GetError") + camilla_mockws.query("GetError") with pytest.raises(camilladsp.CamillaError): - camilla_mockws._query("GetErrorValue") + camilla_mockws.query("GetErrorValue") with pytest.raises(camilladsp.CamillaError): - camilla_mockws._query("Invalid") + camilla_mockws.query("Invalid") with pytest.raises(IOError): - camilla_mockws._query("bug_in_ws") + camilla_mockws.query("bug_in_ws") with pytest.raises(IOError): - camilla_mockws._query("NotACommand") + camilla_mockws.query("NotACommand") with pytest.raises(IOError): - camilla_mockws._query("fail") + camilla_mockws.query("fail") + def test_query_mockedws(camilla_mockws): camilla_mockws.connect() - assert camilla_mockws._query("SetSomeValue", arg=123) is None + assert camilla_mockws.query("SetSomeValue", arg=123) is None assert camilla_mockws.dummyws.query == json.dumps({"SetSomeValue": 123}) - assert camilla_mockws.get_supported_device_types() == (["a", "b"], ["c", "d"]) + assert camilla_mockws.general.supported_device_types() == (["a", "b"], ["c", "d"]) + assert camilla_mockws.volume.fader(1) == -1.23 + def test_queries(camilla_mockquery): - camilla_mockquery.get_capture_rate() - camilla_mockquery._query.assert_called_with('GetCaptureRate') - camilla_mockquery.get_capture_rate_raw() - camilla_mockquery._query.assert_called_with('GetCaptureRate') - camilla_mockquery.get_signal_range() - camilla_mockquery._query.assert_called_with('GetSignalRange') - camilla_mockquery.get_signal_range_dB() - camilla_mockquery._query.assert_called_with('GetSignalRange') - camilla_mockquery.set_update_interval(1234) - camilla_mockquery._query.assert_called_with('SetUpdateInterval', arg=1234) - camilla_mockquery.get_update_interval() - camilla_mockquery._query.assert_called_with('GetUpdateInterval') - camilla_mockquery.stop() - camilla_mockquery._query.assert_called_with('Stop') - camilla_mockquery.exit() - camilla_mockquery._query.assert_called_with('Exit') - camilla_mockquery.reload() - camilla_mockquery._query.assert_called_with('Reload') - camilla_mockquery.get_config_name() - camilla_mockquery._query.assert_called_with('GetConfigName') - camilla_mockquery.set_config_name("some/path") - camilla_mockquery._query.assert_called_with('SetConfigName', arg="some/path") - camilla_mockquery.get_config_raw() - camilla_mockquery._query.assert_called_with('GetConfig') - camilla_mockquery.set_config_raw("some:yaml") - camilla_mockquery._query.assert_called_with('SetConfig', arg="some:yaml") - camilla_mockquery.set_config({"some":"yaml"}) - camilla_mockquery._query.assert_called_with('SetConfig', arg='some: yaml\n') - camilla_mockquery.get_rate_adjust() - camilla_mockquery._query.assert_called_with('GetRateAdjust') - camilla_mockquery.get_buffer_level() - camilla_mockquery._query.assert_called_with('GetBufferLevel') - camilla_mockquery.get_clipped_samples() - camilla_mockquery._query.assert_called_with('GetClippedSamples') - camilla_mockquery.get_volume() - camilla_mockquery._query.assert_called_with('GetVolume') - camilla_mockquery.set_volume(-25.0) - camilla_mockquery._query.assert_called_with('SetVolume', arg=-25.0) - camilla_mockquery.get_mute() - camilla_mockquery._query.assert_called_with('GetMute') - camilla_mockquery.set_mute(False) - camilla_mockquery._query.assert_called_with('SetMute', arg=False) - camilla_mockquery.get_capture_signal_rms() - camilla_mockquery._query.assert_called_with('GetCaptureSignalRms') - camilla_mockquery.get_capture_signal_peak() - camilla_mockquery._query.assert_called_with('GetCaptureSignalPeak') - camilla_mockquery.get_playback_signal_rms() - camilla_mockquery._query.assert_called_with('GetPlaybackSignalRms') - camilla_mockquery.get_playback_signal_peak() - camilla_mockquery._query.assert_called_with('GetPlaybackSignalPeak') + camilla_mockquery.rate.capture() + camilla_mockquery.query.assert_called_with("GetCaptureRate") + camilla_mockquery.rate.capture_raw() + camilla_mockquery.query.assert_called_with("GetCaptureRate") + camilla_mockquery.levels.range() + camilla_mockquery.query.assert_called_with("GetSignalRange") + camilla_mockquery.levels.range_decibel() + camilla_mockquery.query.assert_called_with("GetSignalRange") + camilla_mockquery.settings.set_update_interval(1234) + camilla_mockquery.query.assert_called_with("SetUpdateInterval", arg=1234) + camilla_mockquery.settings.update_interval() + camilla_mockquery.query.assert_called_with("GetUpdateInterval") + camilla_mockquery.general.stop() + camilla_mockquery.query.assert_called_with("Stop") + camilla_mockquery.general.exit() + camilla_mockquery.query.assert_called_with("Exit") + camilla_mockquery.general.reload() + camilla_mockquery.query.assert_called_with("Reload") + camilla_mockquery.config.file_path() + camilla_mockquery.query.assert_called_with("GetConfigFilePath") + camilla_mockquery.config.set_file_path("some/path") + camilla_mockquery.query.assert_called_with("SetConfigFilePath", arg="some/path") + camilla_mockquery.config.active_raw() + camilla_mockquery.query.assert_called_with("GetConfig") + camilla_mockquery.config.set_active_raw("some:yaml") + camilla_mockquery.query.assert_called_with("SetConfig", arg="some:yaml") + camilla_mockquery.config.set_active({"some": "yaml"}) + camilla_mockquery.query.assert_called_with("SetConfig", arg="some: yaml\n") + camilla_mockquery.status.rate_adjust() + camilla_mockquery.query.assert_called_with("GetRateAdjust") + camilla_mockquery.status.buffer_level() + camilla_mockquery.query.assert_called_with("GetBufferLevel") + camilla_mockquery.status.clipped_samples() + camilla_mockquery.query.assert_called_with("GetClippedSamples") + camilla_mockquery.volume.main() + camilla_mockquery.query.assert_called_with("GetVolume") + camilla_mockquery.volume.set_main(-25.0) + camilla_mockquery.query.assert_called_with("SetVolume", arg=-25.0) + camilla_mockquery.mute.main() + camilla_mockquery.query.assert_called_with("GetMute") + camilla_mockquery.mute.set_main(False) + camilla_mockquery.query.assert_called_with("SetMute", arg=False) + camilla_mockquery.levels.capture_rms() + camilla_mockquery.query.assert_called_with("GetCaptureSignalRms") + camilla_mockquery.levels.capture_peak() + camilla_mockquery.query.assert_called_with("GetCaptureSignalPeak") + camilla_mockquery.levels.playback_rms() + camilla_mockquery.query.assert_called_with("GetPlaybackSignalRms") + camilla_mockquery.levels.playback_peak() + camilla_mockquery.query.assert_called_with("GetPlaybackSignalPeak") + camilla_mockquery.volume.set_fader(1, -1.23) + camilla_mockquery.query.assert_called_with("SetFaderVolume", arg=(1, -1.23)) def test_queries_adv(camilla_mockquery_yaml): - camilla_mockquery_yaml.read_config_file("some/path") - camilla_mockquery_yaml._query.assert_called_with('ReadConfigFile', arg="some/path") - camilla_mockquery_yaml.read_config("rawyaml") - camilla_mockquery_yaml._query.assert_called_with('ReadConfig', arg="rawyaml") - camilla_mockquery_yaml.validate_config({"some":"yaml"}) - camilla_mockquery_yaml._query.assert_called_with('ValidateConfig', arg='some: yaml\n') - camilla_mockquery_yaml.get_config() - camilla_mockquery_yaml._query.assert_called_with('GetConfig') - camilla_mockquery_yaml.get_previous_config() - camilla_mockquery_yaml._query.assert_called_with('GetPreviousConfig') + camilla_mockquery_yaml.config.read_and_parse_file("some/path") + camilla_mockquery_yaml.query.assert_called_with("ReadConfigFile", arg="some/path") + camilla_mockquery_yaml.config.parse_yaml("rawyaml") + camilla_mockquery_yaml.query.assert_called_with("ReadConfig", arg="rawyaml") + camilla_mockquery_yaml.config.validate({"some": "yaml"}) + camilla_mockquery_yaml.query.assert_called_with( + "ValidateConfig", arg="some: yaml\n" + ) + camilla_mockquery_yaml.config.active() + camilla_mockquery_yaml.query.assert_called_with("GetConfig") + camilla_mockquery_yaml.config.previous() + camilla_mockquery_yaml.query.assert_called_with("GetPreviousConfig")