CamillaDSP - Cross-platform IIR and FIR engine for crossovers, room correction etc.

Hello Henrik,

I need to tell You I am realy happy with Youre DSP Software and did an realy nice Test this day and was realy happy with that result.

I was able to connect 2x Motu UL mk5 to one RPI 5 on USB.

And config the Alsa Device with the Help of that Link:
https://medium.com/home-wireless/how-to-run-camilladsp-with-multiple-dacs-9672a4639cf3

To one big 36 Output Channel Device. with an fixed Delay of 0,462us between this two Motu Devices without Drift!

Look here Please:
https://www.audiosciencereview.com/...-a-whole-lot-easier-and-cheaper.48233/page-17

Now i am much closer to my Full aktiv 7.1 DSP Setup, for that i need minimum of 16 Analogue Outs.

My Plan is to use the frist Motu UL for my Frontspeaker (2x2Way + 2x SUB) Center (1x2way) and Sub (2 Channel Out)
Second Motu for Surround L+R (2x 2Way) and Back surround L+R (2x2Way)

Robert
 
Last edited:
  • Like
Reactions: 1 user
I use Camilla DSP with Moode Audio on a Raspberry Pi4. I noticed that when using Camilla DSP, the output volume is lower in general. There is no gain attenuation anywhere in the pipeline so curious why this is the case. I only have a flat dither and a volume filter in the pipeline configuration. Even compared to the input volume, the output volume seems a few DBs lower (screen shot). I usually have to have my volume knob on my integrated amp at 4'o clock when using Moode + CamillaDSP whereas if i use the MPD (with Camilla DSP off) i can listen at 1'o clock setting. @HenrikEnquist thoughts on why this might be the case.
 

Attachments

  • Screenshot 2024-03-30 at 12.22.00 PM.png
    Screenshot 2024-03-30 at 12.22.00 PM.png
    7.4 KB · Views: 22
Yes, That reflects the software volume setting on MoOde. I noticed it immediately on posting. I then raised the setting to 100% on MoOde and the pipeline page showed -0.0 db as expected. The difference (apparent attenuation) between using the CDSP configuration and without remains the same. The volume heard goes up and down chamginge the MoOde setting, but it's always quieter when the V2-ProtoDAC.yml config is invoked.
 
Here's the yaml file.

YAML:
description: ProtoDAC TDA1387 X8 Non-oversampling DAC. Invert +/- signal polarity on both channels and apply Flat dither to
  16 bit samples.
devices:
  adjust_period: null
  capture:
    channels: 2
    extra_samples: null
    filename: /dev/stdin
    format: S24LE
    read_bytes: null
    skip_bytes: null
    type: File
  capture_samplerate: null
  chunksize: 4096
  enable_rate_adjust: null
  playback:
    channels: 2
    device: hw:0,0
    format: S24LE
    type: Alsa
  queuelimit: 1
  rate_measure_interval: null
  samplerate: 44100
  silence_threshold: null
  silence_timeout: null
  stop_on_rate_change: null
  target_level: null
  volume_ramp_time: 150
filters:
  Dither:
    description: null
    parameters:
      amplitude: 2
      bits: 16
      type: Flat
    type: Dither
  Master gain:
    description: null
    parameters:
      gain: 0
      inverted: false
      mute: false
      scale: dB
    type: Gain
mixers:
  Stereo:
    channels:
      in: 2
      out: 2
    description: null
    mapping:
    - dest: 0
      mute: false
      sources:
      - channel: 0
        gain: 0
        inverted: true
        mute: false
        scale: dB
    - dest: 1
      mute: false
      sources:
      - channel: 1
        gain: 0
        inverted: true
        mute: false
        scale: dB
pipeline:
- bypassed: null
  channel: 0
  description: null
  names:
  - Master gain
  - Dither
  type: Filter
- bypassed: null
  channel: 1
  description: null
  names:
  - Master gain
  - Dither
  type: Filter
processors: null
title: ProtoDAC TDA1387 X8
 
The ProtoDAC is a "passive mode" DAC i.e. it has no control interface (I2C), no on-board clocks, it just accepts an I2S data stream. Here are the pieces in the audio and volume chains.

Here's the audio chain up to the DAC:

Code:
---------------------------- Raspberry Pi -----------------------------     ----- Device ------
MPD -> alsa_cdsp -> CamillaDSP -> ALSA -> i2s-dac/pcm1794 driver -> I2S ==> ProtoDAC TDA1387 X8

The volume chain can be configured one of two ways.

1. MPD software volume
In this configuration CamillaDSP volume is set to 0dB in the moode routine that sets MPD volume type to "software".

Code:
MPD -> software volume -> alsa_cdsp -> CamillaDSP volume (0dB) -> ...

2. CamillaDSP volume
In this configuration MPD volume is set to "null" (fake volume) which causes MPD to output 0dB but still emit volume change events that are then picked up by a daemon (mpd2cdspvolume) that proxies the MPD volume level N (0 -100) to CamillaDSP volume in dB.

Code:
MPD -> fake volume (0dB)-> alsa_cdsp -> CamillaDSP  volume (-120dB - 0dB) -> ...
                     |                                         |
                     +------> N% -> mpd2cdspvolume -> NdB -----+

Here's the mpd2cdspvolume daemon

Code:
pi@moode900:~ $ cat /etc/mpd2cdspvolume.config
# Configuration file of mpd2cdspvolume service
[default]
dynamic_range = 60
volume_offset = 0

Code:
pi@moode900:~ $ cat /lib/systemd/system/mpd2cdspvolume.service
[Unit]
Description=Synchronize MPD volume to CamillaDSP
After=network-online.target

[Service]
Type=simple
User=mpd
ExecStart=/usr/local/bin/mpd2cdspvolume --pid_file /var/run/mpd2cdspvol/mpd2cdspvol.pid --volume_state_file /var/lib/cdsp/statefile.yml --config /etc/mpd2cdspvolume.config

[Install]
WantedBy=multi-user.target


Python:
#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# Script for updating the CamillaDSP volume on MPD volume changes.
#
#
# The MIT License
#
# Copyright (c) 2023 bitkeeper @ github
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.


import os
from typing import Callable, Optional
import argparse
import time
import signal
import logging
import time
import yaml
import configparser
from pathlib import Path
from math import log10, exp, log
from mpd import MPDClient, ConnectionError

import camilladsp

VERSION = "1.0.0"

def lin_vol_curve(perc: int, dynamic_range: float= 60.0) -> float:
    '''
    Generates from a percentage a dBA, based on a curve with a dynamic_range.
    Curve calculations coming from: https://www.dr-lex.be/info-stuff/volumecontrols.html

    @perc (int) : linair value between 0-100
    @dynamic_range (float) : dynamic range of the curve
    return (float): Value in dBA
    '''
    x = perc/100.0
    y = pow(10, dynamic_range/20)
    a = 1/y
    b = log(y)
    y=a*exp(b*(x))
    if x < .1:
        y = x*10*a*exp(0.1*b)
    if y == 0:
        y = 0.000001
    return 20* log10(y)
class MPDMixerMonitor:
    """ Monitors MPD for mixer changes and callback when so
        callback receives as argument the volume in dbs.
    """
    def __init__(self, host: str= "127.0.0.1", port: int = 6600, callback: Callable= None, dynamic_range: Optional[int] = None, volume_offset : Optional[float]= None):
        self._host = host
        self._port = port
        self._callback = callback

        self._client = MPDClient()               # create client object
        self._client.timeout = 10                # network timeout in seconds (floats allowed), default: None
        self._client.idletimeout = None          # timeout for fetching the result of the idle command is handled seperately, default: None


        self._kill_now = False

        self._volume = None                     # last synced volume
        """ Indicates if signal to close app is received"""

        self._dynamic_range: int = dynamic_range if dynamic_range else 30
        self._volume_offset: float = volume_offset if volume_offset else 0

        logging.info('dynamic_range = %d dB', self._dynamic_range)
        logging.info('volume_offset = -%d dB', abs(self._volume_offset))

    def exit_gracefully(self, signum, frame):
        logging.info('close the shop')
        global kill_now
        self._kill_now = True
        self._client.close()

    def _handle_mpd_status(self, status: dict):
        """

        @return False when the volume was the same as the previous one, else True
        """

        if 'volume' in status:
            volume = float(status['volume'])
            if volume != self._volume:
                volume_db = lin_vol_curve(volume, self._dynamic_range) - abs(self._volume_offset)
                logging.info('vol update = %d : %.2f dB', volume, volume_db)

                if self._callback:
                    if self._callback(volume_db) == False and (self._volume == 0 or volume == 0):
                        # when unmute fails, give cdsp a little more time to start
                        time.sleep(0.4)
                        self._callback(volume_db)
                self._volume = volume
            else:
                return False

        return True

    def run_monitor(self):
        while self._kill_now is False:
            try:
                changed = self._client.idle('mixer')
                if 'mixer' in changed:
                    status= self._client.status()
                    # make sure that it is in sync with the latest state of the volume, by repeating untill we get the same volume
                    while self._handle_mpd_status(status):
                        status= self._client.status()

            except (ConnectionError, ConnectionRefusedError, ConnectionResetError):
                while self._kill_now is False:
                    try:
                        self._client.connect(self._host, self._port)
                        self._handle_mpd_status(self._client.status())
                        self._volume = None
                        break
                    except ConnectionRefusedError:
                        logging.info('couldn\'t connect to MPD, retrying')
                        time.sleep(1)

        self._client.disconnect()

class CamillaDSPVolumeUpdater:
    """Updates CamillaDSP volume
       When cdsp isn't running and a volume state file for alsa_cdsp is provided that one is updated
    """

    CDSP_STATE_TEMPLATE = {
            'config_path': '/usr/share/camilladsp/working_config.yml',
            'mute': [ False,False, False,False,False],
            'volume': [ -6.0, -6.0, -6.0, -6.0, -6.0]
    }
    """ Used as default statefile value, when not present or invalid"""

    def __init__(self, volume_state_file: Optional[Path] = None, host: str='127.0.0.1', port:int=1234):
        self._volume_state_file: Optional[Path]= volume_state_file
        self._cdsp = camilladsp.CamillaClient(host, port)
        if volume_state_file:
            logging.info('volume state file: "%s"', volume_state_file )

    def check_cdsp_statefile(self) -> bool:
        """ check if it exists and is valid. If not create a valid one"""
        try:
            if self._volume_state_file and self._volume_state_file.is_file() is False:
                logging.info('Create statefile %s',self._volume_state_file)
                cdsp.update_cdsp_statefile(0, False)
            elif self._volume_state_file.is_file() is True:
                cdsp_state = yaml.load(self._volume_state_file.read_text(), Loader=yaml.Loader)
                if isinstance(cdsp_state, dict) is False:
                    logging.info('Statefile %s content not valid recreate it',self._volume_state_file)
                    cdsp.update_cdsp_statefile(0, False)
        except FileNotFoundError as e:
            logging.error('Couldn\'t create state file "%s", prob basedir doesn\'t exists.', self._volume_state_file)
            return False
        except PermissionError as e:
            logging.error('Couldn\'t write state to "%s", prob incorrect owner rights of dir.', self._volume_state_file)
            return False

        return True

    def update_cdsp_volume(self, volume_db: float):
        try:
            if self._cdsp.is_connected() is False:
                self._cdsp.connect()

            self._cdsp.volume.set_main(volume_db)
            time.sleep(0.2)
            cdsp_actual_volume = self._cdsp.volume.main()
            logging.info('volume set to %.2f [readback = %.2f] dB', volume_db, cdsp_actual_volume)

            # correct issue when volume is not the required one (issue with cdsp 2.0)
            if abs(cdsp_actual_volume-volume_db) > .2:
                # logging.info('volume incorrect !')
                self._cdsp.volume.set_main(volume_db)
            return True
        except (ConnectionRefusedError, IOError) as e:
            logging.info('no cdsp')
            self.update_cdsp_statefile(volume_db)
            return False

    def update_cdsp_statefile(self, main_volume: float=-6.0, main_mute:bool = False):
        """ Update statefile from camilladsp. Used for CamillaDSP 2.x and higher."""
        logging.info('update volume state file : %.2f dB, mute: %d', main_volume ,main_mute)
        cdsp_state = dict(CamillaDSPVolumeUpdater.CDSP_STATE_TEMPLATE)
        if self._volume_state_file:
            try:
                if self._volume_state_file.exists():
                    data = yaml.load(self._volume_state_file.read_text(), Loader=yaml.Loader)
                    if isinstance(data, dict):
                        cdsp_state = data
                    else:
                        logging.warning('no valid state file content, overwrite it')
                else:
                    logging.info('no state file present, create one')

                cdsp_state['volume'][0] = main_volume
                cdsp_state['mute'][0] = main_mute

                self._volume_state_file.write_text(yaml.dump(cdsp_state, indent=8, explicit_start=True))
            except FileNotFoundError as e:
                logging.error('Couldn\'t create state file "%s", prob basedir doesn\'t exists.', self._volume_state_file)
            except PermissionError as e:
                logging.error('Couldn\'t write state to "%s", prob incorrect owner rights of dir.', self._volume_state_file)


def get_cmdline_arguments():
    parser = argparse.ArgumentParser(description = 'Synchronize MPD volume to CamillaDSP')

    parser.add_argument('-V', '--version', action='version', version='%(prog)s {}'.format(VERSION))
    parser.add_argument('-v', '--verbose', action='store_true',
                        help = 'Show debug output.')
    parser.add_argument('--mpd_host', default = '127.0.0.1',
                   help = 'Host running MPD. (default: 127.0.0.1)')
    parser.add_argument('--mpd_port', default = 6600, type=int,
                   help = 'Port user by MPD. (default: 6600)')
    parser.add_argument('--cdsp_host', default = '127.0.0.1',
                   help = 'Host running CamillaDSP. (default: 127.0.0.1)')
    parser.add_argument('--cdsp_port', default = 1234, type=int,
                   help = 'Port used by CamillaDSP. (default: 1234)')

    parser.add_argument('-s', '--volume_state_file', type=Path, default = None,
                   help = 'File where to store the volume state. (default: None)')
    parser.add_argument('-p', '--pid_file', type=Path, default = None,
                   help = 'Write PID of process to this file. (default: None)')
    parser.add_argument('-c', '--config', type=Path,
                   help = 'Load config from a file (default: None)')


    args = parser.parse_args()
    return args

def get_config(config_file: Path):
    dynamic_range = None
    volume_offset = None

    if config_file and config_file.is_file():
        config = configparser.ConfigParser()
        config.read(config_file)


        if 'default' in config and 'dynamic_range' in config['default']:
            dynamic_range = int(config['default']['dynamic_range'])
        if 'default' in config and 'volume_offset' in config['default']:
            volume_offset = float(config['default']['volume_offset'])
    return dynamic_range, volume_offset

if __name__ == "__main__":
    args = get_cmdline_arguments()
    if args.verbose:
        logging.basicConfig(level=logging.INFO)

    if not hasattr(camilladsp, 'CamillaClient'):
            logging.error('No or wrong version of Python package camilladsp installed, requires version 2 or higher.')
            exit(1)

    logging.info('start-up mpd2cdspvolume')

    dynamic_range : Optional[int]= None
    volume_offset : Optional[float]= None

    config_file = args.config
    if config_file and config_file.is_file() is False:
        logging.error('Supplied config file "%s" can\'t be read.', config_file)
        exit(1)
    elif config_file:
        logging.info('config file: "%s"', config_file )
        dynamic_range, volume_offset = get_config(config_file)


    pid_file=args.pid_file
    if pid_file:
        logging.info('pid file: "%s"', pid_file )
        try:
            pid_file.write_text('{}'.format(os.getpid()))
        except FileNotFoundError as e:
            logging.error('Couldn\'t write PID file "%s", prob basedir doesn\'t exists.', pid_file)
            exit(1)
        except PermissionError as e:
            logging.error('Couldn\'t write PID file to "%s", prob incorrect owner rights of dir.', pid_file)
            exit(1)


    state_file = args.volume_state_file
    cdsp = CamillaDSPVolumeUpdater(state_file, host = args.cdsp_host, port = args.cdsp_port)
    if cdsp.check_cdsp_statefile() is False:
        exit(1)
    monitor = MPDMixerMonitor(host = args.mpd_host, port = args.mpd_port, callback = cdsp.update_cdsp_volume, dynamic_range=dynamic_range, volume_offset=volume_offset)

    signal.signal(signal.SIGINT, monitor.exit_gracefully)
    signal.signal(signal.SIGTERM, monitor.exit_gracefully)

    monitor.run_monitor()

    if pid_file and pid_file.exists():
        pid_file.unlink()
 
  • Like
Reactions: 1 user

Attachments

  • Screenshot 2024-04-06 at 9.22.50 AM.png
    Screenshot 2024-04-06 at 9.22.50 AM.png
    21.8 KB · Views: 24
  • Screenshot 2024-04-06 at 9.22.06 AM.png
    Screenshot 2024-04-06 at 9.22.06 AM.png
    17 KB · Views: 24
  • Screenshot 2024-04-06 at 9.21.48 AM.png
    Screenshot 2024-04-06 at 9.21.48 AM.png
    12.4 KB · Views: 22
  • Screenshot 2024-04-06 at 9.21.32 AM.png
    Screenshot 2024-04-06 at 9.21.32 AM.png
    13.4 KB · Views: 26
  • Screenshot 2024-04-06 at 9.21.16 AM.png
    Screenshot 2024-04-06 at 9.21.16 AM.png
    50.4 KB · Views: 25
Hi folks,

I’ve been running Camilla DSP on a raspberry pi for a few years now and although its been great I have an issue which I wonder if anyone has some insight on.

I have a SPIDF input on my PI from my TV which then feeds into Camilla and then from there feeds out my DAC (OKTO). When the TV is off Camilla goes into a paused state and then I have to turn both the DAC off and turn the TV on and off again to get Camilla to work.

Is there a work around for this?
 
I have a SPIDF input on my PI from my TV which then feeds into Camilla and then from there feeds out my DAC (OKTO). When the TV is off Camilla goes into a paused state and then I have to turn both the DAC off and turn the TV on and off again to get Camilla to work.
Not sure if this is related given CDSP with your TV off goes to pause mode as i should when input is missing. Anyway I had a similar problem when DAC where powered of CDSP had to manually be restarted. Back then Henrid sorted me out by recommending not to run CDSP with the -w (wait) argument. Then it will recover from error state automatically when DAC is powered on again.
 
@TNT, @HenrikEnquist here are my settings in Moode/Camilla DSP
Ok got it :) You have an extra Volume filter in the pipeline, reacting to fader Aux1.
In v2.0 there is always a volume control, no need to add one as a filter. The Aux1 fader will be set to -6 dB by the mpd2cdspvolume daemon:
CDSP_STATE_TEMPLATE = { 'config_path': '/usr/share/camilladsp/working_config.yml', 'mute': [ False,False, False,False,False], 'volume': [ -6.0, -6.0, -6.0, -6.0, -6.0] }
The Aux faders are not (yet) exposed in the UI, only the Main one is. Which means this extra volume control is stuck at -6 dB.

The solution is simply to get rid of the Volume filter :)
 
  • Like
Reactions: 1 user
Not sure if this is related given CDSP with your TV off goes to pause mode as i should when input is missing. Anyway I had a similar problem when DAC where powered of CDSP had to manually be restarted. Back then Henrid sorted me out by recommending not to run CDSP with the -w (wait) argument. Then it will recover from error state automatically when DAC is powered on again.
Ok thanks for the advice. It’s been so long since I’ve looked at CDSP, where should I look for the -w (Wait)? Is it in a config somewhere?