diff --git a/.github/workflows/ci_test.yml b/.github/workflows/ci_test.yml index 202c024c..76729098 100644 --- a/.github/workflows/ci_test.yml +++ b/.github/workflows/ci_test.yml @@ -9,7 +9,7 @@ jobs: #container: ubuntu:20.04 steps: - name: Checkout sources - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Update package list run: sudo apt-get update @@ -44,7 +44,7 @@ jobs: run: cargo test --features bluez-backend,cpal-backend,jack-backend,pulse-backend, - name: Run cargo test with all optional features - run: cargo test --features 32bit,debug,fftw,secure-websocket + run: cargo test --features 32bit,debug,secure-websocket - name: Run cargo fmt run: cargo fmt --all -- --check @@ -57,7 +57,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout sources - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install stable toolchain uses: dtolnay/rust-toolchain@stable @@ -81,7 +81,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout sources - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install stable toolchain uses: dtolnay/rust-toolchain@stable @@ -105,7 +105,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout sources - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install stable toolchain uses: dtolnay/rust-toolchain@stable @@ -131,7 +131,7 @@ jobs: runs-on: windows-latest steps: - name: Checkout sources - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install stable toolchain uses: dtolnay/rust-toolchain@stable @@ -142,12 +142,28 @@ jobs: - name: Run cargo test run: cargo test --no-default-features + check_test_windows7: + name: Check and test Windows7 (rustc 1.75) + runs-on: windows-latest + steps: + - name: Checkout sources + uses: actions/checkout@v4 + + - name: Install stable toolchain + uses: dtolnay/rust-toolchain@1.75.0 + + - name: Run cargo check + run: cargo check --no-default-features + + - name: Run cargo test + run: cargo test --no-default-features + check_test_macos: - name: Check and test macOS - runs-on: macos-latest + name: Check and test macOS Intel + runs-on: macos-13 steps: - name: Checkout sources - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install stable toolchain uses: dtolnay/rust-toolchain@stable @@ -159,19 +175,20 @@ jobs: run: cargo test --no-default-features check_macos_arm: - name: Check macOS aarch64 + name: Check and test macOS Arm runs-on: macos-latest steps: - name: Checkout sources - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install stable toolchain uses: dtolnay/rust-toolchain@stable - with: - targets: aarch64-apple-darwin - - name: Run cargo check for arm - run: cargo check --target aarch64-apple-darwin + - name: Run cargo check + run: cargo check --no-default-features + + - name: Run cargo test + run: cargo test --no-default-features diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e8fa04ad..4d95c897 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,7 +12,7 @@ jobs: #container: ubuntu:20.04 steps: - name: Checkout sources - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Update package list run: sudo apt-get update @@ -51,7 +51,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout sources - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install stable toolchain uses: dtolnay/rust-toolchain@stable @@ -85,7 +85,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout sources - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install stable toolchain uses: dtolnay/rust-toolchain@stable @@ -117,7 +117,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout sources - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install stable toolchain uses: dtolnay/rust-toolchain@stable @@ -149,7 +149,7 @@ jobs: runs-on: windows-latest steps: - name: Checkout sources - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install stable toolchain uses: dtolnay/rust-toolchain@stable @@ -168,12 +168,35 @@ jobs: asset_name: camilladsp-windows-amd64.zip tag: ${{ github.ref }} + windows7: + name: Windows7 + runs-on: windows-latest + steps: + - name: Checkout sources + uses: actions/checkout@v4 + + - name: Install stable toolchain + uses: dtolnay/rust-toolchain@1.75.0 + + - name: Build + run: cargo build --release + + - name: Compress + run: powershell Compress-Archive target/release/camilladsp.exe camilladsp.zip + + - name: Upload binaries to release + uses: svenstaro/upload-release-action@v2 + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + file: camilladsp.zip + asset_name: camilladsp-windows7-amd64.zip + tag: ${{ github.ref }} macos: - name: macOS - runs-on: macos-latest + name: macOS Intel + runs-on: macos-13 steps: - name: Checkout sources - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install stable toolchain uses: dtolnay/rust-toolchain@stable @@ -194,22 +217,20 @@ jobs: macos_arm: - name: macOS aarch64 + name: macOS Arm runs-on: macos-latest steps: - name: Checkout sources - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install stable toolchain uses: dtolnay/rust-toolchain@stable - with: - targets: aarch64-apple-darwin - name: Build - run: cargo build --release --target aarch64-apple-darwin + run: cargo build --release - name: Compress - run: tar -zcvf camilladsp.tar.gz -C target/aarch64-apple-darwin/release camilladsp + run: tar -zcvf camilladsp.tar.gz -C target/release camilladsp - name: Upload binaries to release uses: svenstaro/upload-release-action@v2 diff --git a/.gitignore b/.gitignore index 00c5468b..05ebb9e2 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,7 @@ default_config.yml active_config.yml camilladsp.log configs/ -coeffs/ \ No newline at end of file +coeffs/ +.venv +*.csv +*.exe \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 2aa08452..76b474c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,29 @@ +## v3.0.0 +New features: +- Optional multithreaded filter processing. +- Request higher proprity of audio threads for improved stability. +- Add a signal generator capture device. +- Optionally write wav header when outputting to file or stdout. +- Add `WavFile` capture device type for reading wav files. +- Optional limit for volume controls. +- Add websocket command for reading all faders with a single call. +- Linux: Subscribe to capture device control events for volume, sample rate and format changes. +- Linux: Optionally select Alsa sample format automatically. +- Improved controller for rate adjustment. +- Command line options for setting aux volume and mute. +- Optional user-defined volume limits for volume adjust commands. +- Add noise gate. +- Add optional channel labels for capture devices and mixers. +- Optional log file rotation. +Changes: +- Remove the optional use of FFTW instead of RustFFT. +- Rename `File` capture device to `RawFile`. +- Filter pipeline steps take a list of channels to filter instead of a single one. +Bugfixes: +- Windows: Fix compatibility issues for some WASAPI devices. +- MacOS: Support devices appearing as separate capture and playback devices. +- Linux: Improved Alsa error handling. + ## v2.0.3 Bugfixes: - MacOS: Fix using Aggregate devices for playback. diff --git a/Cargo.toml b/Cargo.toml index 1fa06e96..02d26ef7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,10 @@ [package] -name = "camilladsp" -version = "2.0.3" +name = "CamillaDSP" +version = "3.0.0" authors = ["Henrik Enquist "] edition = "2021" description = "A flexible tool for processing audio" -rust-version = "1.61" +rust-version = "1.74" [features] default = ["websocket"] @@ -15,7 +15,6 @@ bluez-backend = ["zbus"] 32bit = [] websocket = ["tungstenite"] secure-websocket = ["websocket", "native-tls", "tungstenite/native-tls"] -FFTW = ["fftw"] debug = [] avoid-rustc-issue-116359 = [] @@ -28,9 +27,9 @@ name = "camilladsp" path = "src/bin.rs" [target.'cfg(target_os="linux")'.dependencies] -alsa = "0.8.1" +alsa = "0.9.0" alsa-sys = "0.3.1" -nix = "0.23" +nix = { version = "0.28", features = ["poll", "signal"] } zbus = { version = "3.0.0", optional = true } [target.'cfg(target_os="macos")'.dependencies] @@ -42,8 +41,8 @@ dispatch = "0.2.0" [target.'cfg(target_os="windows")'.dependencies] #wasapi = { path = "../../rust/wasapi" } #wasapi = { git = "https://github.com/HEnquist/wasapi-rs", branch = "win041" } -wasapi = "0.13.0" -windows = {version = "0.48.0", features = ["Win32_System_Threading", "Win32_Foundation"] } +wasapi = "0.15.0" +windows = {version = "0.57.0", features = ["Win32_System_Threading", "Win32_Foundation"] } [dependencies] serde = { version = "1.0", features = ["derive"] } @@ -52,13 +51,12 @@ serde_json = "1.0" serde_with = "1.11" realfft = "3.0.0" #realfft = { git = "https://github.com/HEnquist/realfft", branch = "better_errors" } -fftw = { version = "0.7.0", optional = true } num-complex = "0.4" num-traits = "0.2" signal-hook = "0.3.8" -rand = { version = "0.8.3", default_features = false, features = ["small_rng", "std"] } +rand = { version = "0.8.3", default-features = false, features = ["small_rng", "std"] } rand_distr = "0.4.3" -clap = "2.33.0" +clap = { version = "4.5.4", features = ["cargo"] } lazy_static = "1.4.0" log = "0.4.14" flexi_logger = { version = "0.27.2", features = ["async", "colors"] } @@ -67,8 +65,8 @@ tungstenite = { version = "0.21.0", optional = true } native-tls = { version = "0.2.7", optional = true } libpulse-binding = { version = "2.0", optional = true } libpulse-simple-binding = { version = "2.0", optional = true } -rubato = "0.14.1" -#rubato = { git = "https://github.com/HEnquist/rubato", branch = "next-0.13" } +rubato = "0.15.0" +#rubato = { git = "https://github.com/HEnquist/rubato", branch = "optional_fft" } cpal = { version = "0.13.3", optional = true } #rawsample = { path = "../../rust/rawsample" } #rawsample = { git = "https://github.com/HEnquist/rawsample", branch = "main" } @@ -76,6 +74,8 @@ rawsample = "0.2.0" circular-queue = "0.2.6" parking_lot = { version = "0.12.1", features = ["hardware-lock-elision"] } crossbeam-channel = "0.5" +rayon = "1.10.0" +audio_thread_priority = { version = "0.32.0", default-features = false } [build-dependencies] version_check = "0.9" diff --git a/FAQ.md b/FAQ.md index 7cdfe719..b89ecaba 100644 --- a/FAQ.md +++ b/FAQ.md @@ -47,6 +47,12 @@ Note the extra padding bytes (`0x00`) in S24LE. This scheme means that the samples get an "easier" alignment in memory, while wasting some space. In practice, this format isn't used much. +- Why don't I get any sound on MacOS? + + Apps need to be granted access to the microphone in order to record sound from any source. + Without microphone access, things appear to be running well but only silence is recorded. + See [Microphone access](./backend_coreaudio.md#microphone-access) + ## Filtering - I only have filters with negative gain, why do I get clipping anyway? diff --git a/README.md b/README.md index 16073c57..948a599f 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# CamillaDSP v2.0 +# CamillaDSP v3.0 ![CI test and lint](https://github.com/HEnquist/camilladsp/workflows/CI%20test%20and%20lint/badge.svg) A tool to create audio processing pipelines for applications such as active crossovers or room correction. -It is written in Rust to benefit from the safety and elegant handling of threading that this language provides. +It is written in Rust to benefit from the safety and elegant handling of threading that this language provides. Supported platforms: Linux, macOS, Windows. @@ -20,7 +20,7 @@ The full configuration is given in a YAML file. CamillaDSP is distributed under the [GNU GENERAL PUBLIC LICENSE Version 3](LICENSE.txt). -This includes the following disclaimer: +This includes the following disclaimer: > 15. Disclaimer of Warranty. > > THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY @@ -100,12 +100,13 @@ It does not matter if the damage is caused by incorrect usage or a bug in the so - **[Difference equation](#difference-equation)** - **[Processors](#processors)** - **[Compressor](#compressor)** + - **[NoiseGate](#noise-gate)** - **[Pipeline](#pipeline)** - **[Filter step](#filter-step)** - **[Mixer and Processor step](#mixer-and-processor-step)** - **[Tokens in names](#tokens-in-names)** - **[Bypassing steps](#bypassing-steps)** -- **[Export filters from REW](#export-filters-from-rew)** +- **[Using filters from REW](#using-filters-from-rew)** - **[Visualizing the config](#visualizing-the-config)** **[Related projects](#related-projects)** @@ -121,12 +122,12 @@ It does not matter if the damage is caused by incorrect usage or a bug in the so ## Background The purpose of CamillaDSP is to enable audio processing with combinations of FIR and IIR filters. This functionality is available in EqualizerAPO, but for Windows only. -For Linux the best known FIR filter engine is probably BruteFIR, which works very well but doesn't support IIR filters. -The goal of CamillaDSP is to provide both FIR and IIR filtering for Linux, Windows and macOS, to be stable, fast and flexible, and be easy to use and configure. +For Linux the best known FIR filter engine is probably BruteFIR, which works very well but doesn't support IIR filters. +The goal of CamillaDSP is to provide both FIR and IIR filtering for Linux, Windows and macOS, to be stable, fast and flexible, and be easy to use and configure. * BruteFIR: https://torger.se/anders/brutefir.html * EqualizerAPO: https://sourceforge.net/projects/equalizerapo/ -* The IIR filtering is heavily inspired by biquad-rs: https://github.com/korken89/biquad-rs +* The IIR filtering is heavily inspired by biquad-rs: https://github.com/korken89/biquad-rs ## How it works The audio pipeline in CamillaDSP runs in three separate threads. @@ -176,14 +177,14 @@ In general, a 64-bit CPU and OS will perform better. A few examples, done with CamillaDSP v0.5.0: -- A Raspberry Pi 4 doing FIR filtering of 8 channels, with 262k taps per channel, at 192 kHz. +- A Raspberry Pi 4 doing FIR filtering of 8 channels, with 262k taps per channel, at 192 kHz. CPU usage about 55%. -- An AMD Ryzen 7 2700u (laptop) doing FIR filtering of 96 channels, with 262k taps per channel, at 192 kHz. +- An AMD Ryzen 7 2700u (laptop) doing FIR filtering of 96 channels, with 262k taps per channel, at 192 kHz. CPU usage just under 100%. ### Linux requirements -Both 64 and 32 bit architectures are supported. All platforms supported by the Rustc compiler should work. +Both 64 and 32 bit architectures are supported. All platforms supported by the Rustc compiler should work. Pre-built binaries are provided for: - x86_64 (almost all PCs) @@ -214,7 +215,7 @@ These are the key dependencies for CamillaDSP. * https://crates.io/crates/alsa - Alsa audio backend * https://crates.io/crates/clap - Command line argument parsing * https://crates.io/crates/cpal - Jack audio backend -* https://crates.io/crates/libpulse-simple-binding - PulseAudio audio backend +* https://crates.io/crates/libpulse-simple-binding - PulseAudio audio backend * https://crates.io/crates/realfft - Wrapper for RustFFT that speeds up FFTs of real-valued data * https://crates.io/crates/rustfft - FFT used for FIR filters * https://crates.io/crates/rubato - Sample rate conversion @@ -239,7 +240,7 @@ Binaries for each release are available for the most common systems. See the ["Releases"](https://github.com/HEnquist/camilladsp/releases) page. To see the files click "Assets". -These are compressed files containing a single executable file that is ready to run. +These are compressed files containing a single executable file that is ready to run. The following configurations are provided: @@ -255,9 +256,9 @@ The following configurations are provided: All builds include the Websocket server. -The `.tar.gz`-files can be uncompressed with the `tar` command: +The `.tar.gz`-files can be uncompressed with the `tar` command: -```sh +```sh tar -xvf camilladsp-linux-amd64.tar.gz ``` @@ -300,12 +301,6 @@ CamillaDSP includes a Websocket server that can be used to pass commands to the This feature is enabled by default, but can be left out. The feature name is "websocket". For usage see the section "Controlling via websocket". -The default FFT library is RustFFT, but it's also possible to use FFTW. -This is enabled by the feature "FFTW". When the chunksize is a power of two, like 1024 or 4096, then FFTW and RustFFT are very similar in speed. -But if the chunksize is a "strange" number like a large prime, then FFTW can be faster. -FFTW is a much larger and more complicated library, -so using FFTW is only recommended if you for some reason can't use an "easy" chunksize and this makes RustFFT too slow. - ## Building in Linux with standard features These instructions assume that the linux distribution used is one of Fedora, Debian, Ubunty or Arch. They should also work also work on distributions closely related to one of these, such as Manjaro (Arch), @@ -333,7 +328,7 @@ If possible, it's recommended to use a pre-built binary on these systems. - - see below for other options - The binary is now available at ./target/release/camilladsp - Optionally install with `cargo install --path .` -- - Note: the `install` command takes the same options for features as the `build` command. +- - Note: the `install` command takes the same options for features as the `build` command. ## Customized build All the available options, or "features" are: @@ -343,9 +338,8 @@ All the available options, or "features" are: - `bluez-backend`: Bluetooth support via BlueALSA (Linux only). - `websocket`: Websocket server for control. - `secure-websocket`: Enable secure websocket, also enables the `websocket` feature. -- `FFTW`: Use FFTW instead of RustFFT. - `32bit`: Perform all calculations with 32-bit floats (instead of 64). -- `debug`: Enable extra logging, useful for debugging. +- `debug`: Enable extra logging, useful for debugging. - `avoid-rustc-issue-116359`: Enable a workaround for [rust issue #116359](https://github.com/rust-lang/rust/issues/116359). Used to check if a performance issue is caused by this compiler bug. @@ -356,18 +350,18 @@ Cargo doesn't allow disabling a single default feature, but you can disable the whole group with the `--no-default-features` flag. Then you have to manually add all the ones you want. -Example 1: You want `websocket`, `pulse-backend` and `FFTW`. The first one is included by default so you only need to add `FFTW` and `pulse-backend`: +Example 1: You want `websocket` and `pulse-backend`. The first one is included by default so you only need to add `pulse-backend`: ``` -cargo build --release --features FFTW --features pulse-backend +cargo build --release --features pulse-backend (or) -cargo install --path . --features FFTW --features pulse-backend +cargo install --path . --features pulse-backend ``` -Example 2: You want `32bit` and `FFTW`. Since you don't want `websocket` you have to disable the defaults: +Example 2: You want only `32bit`. Since you don't want `websocket` you have to disable the defaults: ``` -cargo build --release --no-default-features --features FFTW --features 32bit +cargo build --release --no-default-features --features 32bit (or) -cargo install --path . --no-default-features --features FFTW --features 32bit +cargo install --path . --no-default-features --features 32bit ``` ### Additional dependencies @@ -386,7 +380,7 @@ The `jack-backend` feature requires jack and its development files. To install: By default Cargo builds for a generic system, meaning the resulting binary might not run as fast as possible on your system. This means for example that it will not use AVX on an x86-64 CPU, or NEON on a Raspberry Pi. -To make an optimized build for your system, you can specify this in your Cargo configuration file. +To make an optimized build for your system, you can specify this in your Cargo configuration file. Or, just set the RUSTFLAGS environment variable by adding RUSTFLAGS='...' in from of the "cargo build" or "cargo install" command. Make an optimized build on x86-64: @@ -396,7 +390,7 @@ RUSTFLAGS='-C target-cpu=native' cargo build --release On a Raspberry Pi also state that NEON should be enabled: ``` -RUSTFLAGS='-C target-feature=+neon -C target-cpu=native' cargo build --release +RUSTFLAGS='-C target-feature=+neon -C target-cpu=native' cargo build --release ``` ## Building on Windows and macOS @@ -409,7 +403,7 @@ RUSTFLAGS='-C target-cpu=native' cargo build --release Windows (cmd.exe command prompt): ``` -set RUSTFLAGS=-C target-cpu=native +set RUSTFLAGS=-C target-cpu=native cargo build --release ``` @@ -419,15 +413,13 @@ $env:RUSTFLAGS="-C target-cpu=native" cargo build --release ``` -On macOS both the PulseAudio and FFTW features can be used. The necessary dependencies can be installed with brew: +The PulseAudio backend can be used on macOS. +The necessary dependencies can be installed with brew: ``` -brew install fftw brew install pkg-config brew install pulseaudio ``` -The FFTW feature can also be used on Windows. There is no need to install anything extra. - # How to run @@ -444,44 +436,49 @@ See more about the configuration file below. ## Command line options Starting with the --help flag prints a short help message: ``` -> camilladsp.exe --help -CamillaDSP 2.0.0 +> camilladsp --help +CamillaDSP v3.0.0 Henrik Enquist A flexible tool for processing audio Built with features: websocket Supported device types: -Capture: File, Stdin, Wasapi -Playback: File, Stdout, Wasapi - -USAGE: - camilladsp.exe [FLAGS] [OPTIONS] - -FLAGS: - -m, --mute Start with the volume control muted - -c, --check Check config file and exit - -h, --help Prints help information - -V, --version Prints version information - -v Increase message verbosity - -w, --wait Wait for config from websocket - -OPTIONS: - -s, --statefile Use the given file to persist the state - -o, --logfile Write logs to file - -l, --loglevel Set log level [possible values: trace, debug, info, warn, error, off] - -a, --address
IP address to bind websocket server to - -g, --gain Set initial gain in dB for the volume control - -p, --port Port for websocket server - -n, --channels Override number of channels of capture device in config - -e, --extra_samples Override number of extra samples in config - -r, --samplerate Override samplerate in config - -f, --format Override sample format of capture device in config [possible values: S16LE, - S24LE, S24LE3, S32LE, FLOAT32LE, FLOAT64LE] - -ARGS: - The configuration file to use - +Capture: RawFile, WavFile, Stdin, SignalGenerator, CoreAudio +Playback: File, Stdout, CoreAudio + +Usage: camilladsp [OPTIONS] [CONFIGFILE] + +Arguments: + [CONFIGFILE] The configuration file to use + +Options: + -c, --check Check config file and exit + -s, --statefile Use the given file to persist the state + -v... Increase message verbosity + -l, --loglevel Set log level [possible values: trace, debug, info, warn, error, off] + -o, --logfile Write logs to the given file path + --log_rotate_size Rotate log file when the size in bytes exceeds this value + --log_keep_nbr Number of previous log files to keep + -a, --address
IP address to bind websocket server to + -p, --port Port for websocket server + -w, --wait Wait for config from websocket + -g, --gain Initial gain in dB for main volume control + --gain1 Initial gain in dB for Aux1 fader + --gain2 Initial gain in dB for Aux2 fader + --gain3 Initial gain in dB for Aux3 fader + --gain4 Initial gain in dB for Aux4 fader + -m, --mute Start with main volume control muted + --mute1 Start with Aux1 fader muted + --mute2 Start with Aux2 fader muted + --mute3 Start with Aux3 fader muted + --mute4 Start with Aux4 fader muted + -e, --extra_samples Override number of extra samples in config + -n, --channels Override number of channels of capture device in config + -r, --samplerate Override samplerate in config + -f, --format Override sample format of capture device in config [possible values: S16LE, S24LE, S24LE3, S32LE, FLOAT32LE, FLOAT64LE] + -h, --help Print help + -V, --version Print version ``` Most flags and options have a long and a short form. For example `--port 1234` and `-p1234` are equivalent. @@ -498,8 +495,19 @@ Alternatively, the log level can be changed with the verbosity flag. By passing the verbosity flag once, `-v`, `debug` messages are enabled. If it's given twice, `-vv`, it also prints `trace` messages. -The log messages are normally written to the terminal via stderr, but they can instead be written to a file by giving the `--logfile` option. -The argument should be the path to the logfile. If this file is not writable, CamillaDSP will panic and exit. +The log messages are normally written to the terminal via stderr, +but they can instead be written to a file by giving the `--logfile` option. +The argument should be the path to the logfile. +If this file is not writable, CamillaDSP will panic and exit. + +Log rotation can be enabled by the `--log_rotate_size` option. +This creates a new log file whenever the log fize size exceeds the given size in bytes. +When rotation is enabled the current log file gets an added infix of `_rCURRENT`, +so for example `logfile.log` becomes `logfile_rCURRENT.log`. +When the file is rotated, the old logs are kept with a timestamp as infix, +for example `logfile_r2023-01-29_12-59-00.log`. +The default is to keep all previous log files, +but this can be limited by setting the `--log_keep_nbr` option to the desired number. ### Persistent storage of state @@ -509,11 +517,12 @@ The values in the file will then be kept updated whenever they change. If the file doesn't exist, it will be created on the first write. If the `configfile` argument is given, then this will be used instead of the value from the statefile. -Similarly, the `--gain` and `--mute` options also override the values in the statefile. +Similarly, the `--gain` and `--mute` options also override the values in the statefile for the main fader +while the `--gain1` to `--gain4` and `--mute1` to `--mute4` do the same for the Aux faders. **Use this feature with caution! The volume setting given in the statefile will be applied immediately when CamillaDSP starts processing.** In systems that have a gain structure such that a too high volume setting can damage equipment or ears, -it is recommended to always use the `--gain` option to set the volume to start at a safe value. +it is recommended to always use the `--gain` and `--gain1..4` options to set the volume to start at a safe value. #### Example statefile The statefile is a small YAML file that holds the path to the active config file, @@ -537,7 +546,7 @@ volume: ### Websocket -To enable the websocket server, provide a port number with the `--port` option. Leave it out, or give 0 to disable. +To enable the websocket server, provide a port number with the `--port` option. Leave it out, or give 0 to disable. By default the websocket server binds to the address 127.0.0.1 which means it's only accessible locally (to clients running on the same machine). If it should be also available to remote machines, give the IP address of the interface where it should be available with the `--address` option. @@ -560,30 +569,34 @@ If then `enable_rate_adjust` is false and `capture_samplerate`=`samplerate`, the When overriding the samplerate, two other parameters are scaled as well. Firstly, the `chunksize` is multiplied or divided by integer factors to try to keep the pipeline running at a constant number of chunks per second. Secondly, the value of `extra_samples` is scaled to give the extra samples the same duration at the new samplerate. -But if the `extra_samples` override is used, the given value is used without scaling it. +But if the `extra_samples` override is used, the given value is used without scaling it. ### Initial volume -The `--gain` option can accept negative values, but this requires a little care since the minus sign can be misinterpreted as another option. +The `--gain` and `--gain1..4` options can accept negative values, +but this requires a little care since the minus sign can be misinterpreted as another option. It works as long as there is no space in front of the minus sign. -These work (for a gain of +/- 12.3 dB): +These all work for positive values (with 12.3 dB used as an example): ``` -g12.3 +--gain=12.3 -g 12.3 --gain 12.3 ---gain=12.3 +``` +These work for negative values (note that there is no space before the minus sign): +``` -g-12.3 --gain=-12.3 ``` -These will __NOT__ work: +These have a space before the minus sign and do __NOT__ work: ``` -g -12.3 --gain -12.3 -``` +``` ## Exit codes @@ -609,22 +622,26 @@ See the [separate readme for the websocket server](./websocket.md) # Processing audio The goal is to insert CamillaDSP between applications and the sound card. The details of how this is achieved depends on which operating system and which audio API is being used. -It is also possible to use pipes for apps that support reading or writing audio data from/to stdout. +It is also possible to use pipes for apps that support reading or writing audio data from/to stdout. ## Cross-platform These backends are supported on all platforms. ### File or pipe -Audio can be read from a file or a pipe using the `File` device type. +Audio can be read from a file or a pipe using the `RawFile` device type. This can read raw interleaved samples in most common formats. To instead read from stdin, use the `Stdin` type. -This makes it possible to pipe raw samples from some applications directly to CamillaDSP, without going via a virtual soundcard. +This makes it possible to pipe raw samples from some applications directly to CamillaDSP, +without going via a virtual soundcard. + +Wav files can be read using the `WavFile` device type. +See [the capture device section](#file-rawfile-wavfile-stdin-stdout) for more details. ### Jack Jack is most commonly used with Linux, but can also be used with both Windows and MacOS. The Jack support of CamillaDSP version should be considered experimental. -It is implemented using the CPAL library, which currently only supports Jack on Linux. +It is implemented using the CPAL library, which currently only supports Jack on Linux. #### Using Jack The Jack server must be running. @@ -632,7 +649,7 @@ The Jack server must be running. Set `device` to "default" for both capture and playback. The sample format is fixed at 32-bit float (FLOAT32LE). -The samplerate must match the samplerate configured for the Jack server. +The samplerate must match the samplerate configured for the Jack server. CamillaDSP will show up in Jack as "cpal_client_in" and "cpal_client_out". @@ -645,7 +662,8 @@ See the [separate readme for CoreAudio](./backend_coreaudio.md). ## Linux Linux offers several audio APIs that CamillaDSP can use. -### Alsa + +### Alsa See the [separate readme for ALSA](./backend_alsa.md). ### PulseAudio @@ -688,8 +706,8 @@ This device can be set as the default output in the Gnome sound settings, meanin The audio sent to this device can then be captured from the monitor output named "MySink.monitor" using the PulseAudio backend. Pipewire can also be configured to output to an ALSA Loopback. -This is done by adding an ALSA sink in the Pipewire configuration. -This sink then becomes available as an output device in the Gnome sound settings. +This is done by adding an ALSA sink in the Pipewire configuration. +This sink then becomes available as an output device in the Gnome sound settings. See the "camilladsp-config" repository under [Related projects](#related-projects) for an example Pipewire configuration. TODO test with Jack. @@ -697,14 +715,14 @@ TODO test with Jack. ### BlueALSA BlueALSA ([bluez-alsa](https://github.com/Arkq/bluez-alsa)) is a project to receive or send audio through Bluetooth A2DP. The `Bluez` source will connect to BlueALSA via D-Bus to get a file descriptor. -It will then read the audio directly from there, avoiding the need to go via ALSA. +It will then read the audio directly from there, avoiding the need to go via ALSA. Currently only capture (a2dp-sink) is supported. BlueALSA is supported on Linux only, and requires building CamillaDSP with the `bluez-backend` Cargo feature. #### Prerequisites Start by installing `bluez-alsa`. Both Pipewire and PulseAudio will interfere with BlueALSA and must be disabled. -The source device should be paired after disabling Pipewire or PulseAudio and enabling BlueALSA. +The source device should be paired after disabling Pipewire or PulseAudio and enabling BlueALSA. #### Configuration @@ -736,13 +754,13 @@ This should produce output similar to this: ({objectpath '/org/bluealsa/hci0/dev_A0_B1_C2_D3_E4_F5/a2dpsnk/source': {'org.bluealsa.PCM1': {'Device': , 'Sequence': , 'Transport': <'A2DP-sink'>, 'Mode': <'source'>, 'Format': , 'Channels': , 'Sampling': , 'Codec': <'AAC'>, 'CodecConfiguration': <[byte 0x80, 0x01, 0x04, 0x03, 0x5b, 0x60]>, 'Delay': , 'SoftVolume': , 'Volume': }}},) ``` The wanted path is the string after `objectpath`. -If the output is looking like `(@a{oa{sa{sv}}} {},)`, then no A2DP source is connected or detected. +If the output is looking like `(@a{oa{sa{sv}}} {},)`, then no A2DP source is connected or detected. Connect an A2DP device and try again. If a device is already connected, try removing and pairing the device again. The `service` property can be left out to get the default. This only needs changing if there is more than one instance of BlueALSA running. You have to specify correct capture sample rate, number of channel and sample format. -These parameters can be found with `bluealsa-aplay`: +These parameters can be found with `bluealsa-aplay`: ``` > bluealsa-aplay -L @@ -751,7 +769,7 @@ bluealsa:DEV=A0:B1:C2:D3:E4:F5,PROFILE=a2dp,SRV=org.bluealsa A2DP (AAC): S16_LE 2 channels 44100 Hz ``` -Note that Bluetooth transfers data in chunks, and the time between chunks can vary. +Note that Bluetooth transfers data in chunks, and the time between chunks can vary. To avoid underruns, use a large chunksize and a large target_level. The values in the example above are a good starting point. Rate adjust should also be enabled. @@ -785,7 +803,7 @@ filters: type: Raw < example_fir_b: parameters: <-- "parameters" can be placed before or after "type". - type: Wav + type: Wav filename: path/to/filter.wav type: Conv @@ -842,19 +860,22 @@ title: "Example" description: "Example description of a configuration" ``` -Both these properties are optional and can be set to `null` or left out. +Both these properties are optional and can be set to `null` or left out. The `title` property is intended for a short title, while `description` can be longer and more descriptive. ## Volume control There is a volume control that is enabled regardless of what configuration file is loaded. -CamillaDSP defines a total of five control "channels" for volume. -The default volume control reacts to the `Main` control channel. +CamillaDSP defines a total of five control "channels" for volume called "faders". +The default volume control reacts to the `Main` fader. When the volume or mute setting is changed, the gain is smoothly ramped to the new setting. The duration of this ramp can be customized via the `volume_ramp_time` parameter in the `devices` section. The value must not be negative. If left out or set to `null`, it defaults to 400 ms. +The range of the volume control can be limited. +Set the `volume_limit` to the desired maximum volume value. +This setting is optional. If left out or set to `null`, it defaults to +50 dB. In addition to this, there are four additional control channels, named `Aux1` to `Aux4`. These can be used to implement for example a separate volume control for a headphone output, @@ -877,11 +898,15 @@ devices: stop_on_rate_change: false (*) rate_measure_interval: 1.0 (*) volume_ramp_time: 400.0 (*) + volume_limit: -12.0 (*) + multithreaded: false (*) + worker_threads: 4 (*) capture: type: Pulse channels: 2 device: "MySink.monitor" format: S16LE + labels: ["L", "R"] (*) playback: type: Alsa channels: 2 @@ -892,24 +917,63 @@ A parameter marked (*) in any example is optional. If they are left out from the * `samplerate` - The `samplerate` setting decides the sample rate that everything will run at. + The `samplerate` setting decides the sample rate that everything will run at. This rate must be supported by both the capture and playback device. * `chunksize` - All processing is done in chunks of data. The `chunksize` is the number of samples each chunk will have per channel. - It's good if the number is an "easy" number like a power of two, since this speeds up the FFT in the Convolution filter. + All processing is done in chunks of data. + The `chunksize` is the number of samples each chunk will have per channel. + Suggested starting points for different sample rates: - 44.1 or 48 kHz: 1024 - 88.2 or 96 kHz: 2048 - 176.4 or 192 kHz: 4096 - The duration in seconds of a chunk is `chunksize/samplerate`, so the suggested values corresponds to about 22 ms per chunk. - This is a reasonable value, and making it shorter can increase the cpu usage and make buffer underruns more likely. + The duration in seconds of a chunk is `chunksize/samplerate`, + so the suggested values corresponds to about 22 ms per chunk. + This is a reasonable value. + + A larger chunk size generally reduces CPU usage, + but since the entire chunk must be captured before processing, + it can cause unacceptably long delays. + Conversely, using a smaller chunk size can reduce latency + but will increase CPU usage. + Additionally, the shorter duration of each chunk makes CamillaDSP + more vulnerable to disruptions from other system activities, + potentially causing buffer underruns. + + __Choosing chunk size for best performance__ + + FIR filters are automatically padded as needed, + so there is no need match chunk size and filter length. + + CamillaDSP uses FFT for convolution, with an FFT length of `2 * chunksize`. + Therefore, the chunk size should be chosen for optimal FFT performance. + + Using a power of two for the chunk size is ideal for best performance. + The FFT also works well with numbers that can be expressed as products + of small primes, like `2^4 * 3^3 = 1296`. + + Avoid using prime numbers, such as 1297, + or numbers with large prime factors, like `29 * 43 = 1247`. + + __Long FIR filters__ + + When a FIR filter is longer than the chunk size, the convolver uses segmented convolution. + The number of segments is calculated as `filter_length / chunk size`, + and rounded up to the nearest integer. + + Using a smaller chunk size (i.e. more segments) reduces latency + but makes the convoultion process less efficient and thus needs more processing power. + Although a smaller chunk size leads to increased CPU usage for all filters, + the difference is larger for FIR filters than the other types. + + If you have long FIR filters, try different chunk sizes + to find the best balance between latency and processing power. + + When increasing the chunk size, try doubling it, like going from 1024 to 2048 or 4096 to 8192. - If you have long FIR filters you can reduce CPU usage by making the chunksize larger. - When increasing, try increasing in factors of two, like 1024 -> 2048 or 4096 -> 8192. - * `queuelimit` (optional, defaults to 4) @@ -930,7 +994,7 @@ A parameter marked (*) in any example is optional. If they are left out from the Setting the rate can be done in two ways. * Some capture devices provide a way to adjust the speed of their virtual sample clock (also called pitch adjust). This is available with the Alsa Loopback and USB Audio gadget devices on Linux, - as well as the latest (currently unreleased) version or BlackHole on macOS. + as well as BlackHole version 0.5.0 and later on macOS. When capturing from any of these devices, the adjustment can be done by tuning the virtual sample clock of the device. This avoids the need for asynchronous resampling. * If asynchronous resampling is enabled, the adjustment can be done by tuning the resampling ratio. @@ -938,18 +1002,18 @@ A parameter marked (*) in any example is optional. If they are left out from the With Alsa capture devices, the first option is used whenever it's available. If not, and when not using an Alsa capture device, then the second option is used. - + * `target_level` (optional, defaults to the `chunksize` value) The value is the number of samples that should be left in the buffer of the playback device when the next chunk arrives. Only applies when `enable_rate_adjust` is set to `true`. - It will take some experimentation to find the right number. - If it's too small there will be buffer underruns from time to time, - and making it too large might lead to a longer input-output delay than what is acceptable. - Suitable values are in the range 1/2 to 1 times the `chunksize`. - + It will take some experimentation to find the right number. + If it's too small there will be buffer underruns from time to time, + and making it too large might lead to a longer input-output delay than what is acceptable. + Suitable values are in the range 1/2 to 1 times the `chunksize`. + * `adjust_period` (optional, defaults to 10) - + The `adjust_period` parameter is used to set the interval between corrections, in seconds. The default is 10 seconds. Only applies when `enable_rate_adjust` is set to `true`. A smaller value will make for a faster reaction time, which may be useful if there are occasional @@ -957,7 +1021,7 @@ A parameter marked (*) in any example is optional. If they are left out from the * `silence_threshold` & `silence_timeout` (optional) The fields `silence_threshold` and `silence_timeout` are optional - and used to pause processing if the input is silent. + and used to pause processing if the input is silent. The threshold is the threshold level in dB, and the level is calculated as the difference between the minimum and maximum sample values for all channels in the capture buffer. 0 dB is full level. Some experimentation might be needed to find the right threshold. @@ -981,40 +1045,72 @@ A parameter marked (*) in any example is optional. If they are left out from the * `stop_on_rate_change` and `rate_measure_interval` (both optional) - Setting `stop_on_rate_change` to `true` makes CamillaDSP stop the processing if the measured capture sample rate changes. Default is `false`. + Setting `stop_on_rate_change` to `true` makes CamillaDSP stop the processing + if the measured capture sample rate changes. + Default is `false`. The `rate_measure_interval` setting is used for adjusting the measurement period. A longer period gives a more accurate measurement of the rate, at the cost of slower response when the rate changes. - The default is 1.0 seconds. Processing will stop after 3 measurements in a row are more than 4% off from the configured rate. + The default is 1.0 seconds. + Processing will stop after 3 measurements in a row are more than 4% off from the configured rate. The value of 4% is chosen to allow some variation, while still catching changes between for example 44.1 to 48 kHz. * `volume_ramp_time` (optional, defaults to 400 ms) This setting controls the duration of this ramp when changing volume of the default volume control. The value must not be negative. If left out or set to `null`, it defaults to 400 ms. - + +* `multithreaded` and `worker_threads` (optional, defaults to `false` and automatic) + Setting `multithreaded` to `true` enables multithreaded processing. + When this is enabled, CamillaDSP creates several filtering tasks by grouping the filters for each channel. + These tasks are then sent to a thread pool, where multiple threads are ready to pick up the work. + On a machine with multiple CPU cores, this allows filters to be processed in parallel, + potentially boosting performance. + Once all tasks are completed, the results are returned to the main processing thread. + + However, Mixers and Processors, which work on all channels in the pipeline, + cannot be parallelized and are processed in the main thread. + Therefore, only the filters between mixers and/or processors can be parallelized. + + Multithreaded processing is beneficial for configurations that require significant processing power, + such as using very long FIR filters, high sample rates, or a large number of channels. + It should only be enabled if necessary, as it typically should remain disabled. + Synchronizing with worker threads adds some overhead, increasing overall CPU usage. + It also makes CamillaDSP more susceptible to other processes using the CPU, + which may cause buffer underruns. + + An exception to this recommendation is when both the input and output are files on disk, + allowing processing to run faster than real time. + In this scenario, multithreading is likely to improve throughput and should usually be enabled. + + The number of worker threads can be set manually using the `worker_threads` setting. + If left out or set to zero, it defaults to one worker thread per hardware thread of the machine. + * `capture` and `playback` - Input and output devices are defined in the same way. + Input and output devices are defined in the same way. A device needs: - * `type`: + * `type`: The available types depend on which features that were included when compiling. All possible types are: - * `File` + * `RawFile` (capture only) + * `WavFile` (capture only) + * `File` (playback only) + * `SignalGenerator` (capture only) * `Stdin` (capture only) * `Stdout` (playback only) * `Bluez` (capture only) * `Jack` * `Wasapi` * `CoreAudio` - * `Alsa` + * `Alsa` * `Pulse` * `channels`: number of channels - * `device`: device name (for Alsa, Pulse, Wasapi, CoreAudio). For CoreAudio and Wasapi, "default" will give the default device. - * `filename` path to the file (for File) - * `format`: sample format (for all except Jack). + * `device`: device name (for `Alsa`, `Pulse`, `Wasapi`, `CoreAudio`). For `CoreAudio` and `Wasapi`, `null` will give the default device. + * `filename` path to the file (for `File`, `RawFile` and `WavFile`) + * `format`: sample format (for all except `Jack`). Currently supported sample formats are signed little-endian integers of 16, 24 and 32 bits as well as floats of 32 and 64 bits: * S16LE - Signed 16-bit int, stored as two bytes * S24LE - Signed 24-bit int, stored as four bytes (three bytes of data, one padding byte) * S24LE3 - Signed 24-bit int, stored as three bytes (with no padding) - * S32LE - Signed 32-bit int, stored as four bytes + * S32LE - Signed 32-bit int, stored as four bytes * FLOAT32LE - 32-bit float, stored as four bytes * FLOAT64LE - 64-bit float, stored as eight bytes @@ -1030,8 +1126,8 @@ A parameter marked (*) in any example is optional. If they are left out from the | S32LE | Yes | Yes | Yes | Yes | No | Yes | | FLOAT32LE | Yes | Yes | Yes | Yes | Yes | Yes | | FLOAT64LE | Yes | No | No | No | No | Yes | - - + + ### Equivalent formats This table shows which formats in the different APIs are equivalent. @@ -1044,28 +1140,42 @@ A parameter marked (*) in any example is optional. If they are left out from the | S32LE | S32_LE | S32LE | | FLOAT32LE | FLOAT_LE | FLOAT32LE | | FLOAT64LE | FLOAT64_LE | - | - - ### File, Stdin, Stdout - The `File` device type reads or writes to a file, while `Stdin` reads from stdin and `Stdout` writes to stdout. + + ### File, RawFile, WavFile, Stdin, Stdout + The `RawFile` device type reads from a file, while `Stdin` reads from stdin. + `File` and `Stdout` writes to a file and stdout, respectively. The format is raw interleaved samples, in the selected sample format. - If the capture device reaches the end of a file, the program will exit once all chunks have been played. - That delayed sound that would end up in a later chunk will be cut off. To avoid this, set the optional parameter `extra_samples` for the File capture device. - This causes the capture device to yield the given number of samples (per channel) after reaching end of file, allowing any delayed sound to be played back. + + If the capture device reaches the end of a file, the program will exit once all chunks have been played. + That delayed sound that would end up in a later chunk will be cut off. + To avoid this, set the optional parameter `extra_samples` for the File capture device. + This causes the capture device to yield the given number of samples (per channel) after reaching end of file, + allowing any delayed sound to be played back. + The `Stdin` capture device and `Stdout` playback device use stdin and stdout, so it's possible to easily pipe audio between applications: ``` > camilladsp stdio_capt.yml > rawfile.dat > cat rawfile.dat | camilladsp stdio_pb.yml ``` - Note: On Unix-like systems it's also possible to use the File device and set the filename to `/dev/stdin` for capture, or `/dev/stdout` for playback. - - Please note the `File` capture device isn't able to read wav-files directly. - If you want to let CamillaDSP play wav-files, please see the [separate guide for converting wav to raw files](coefficients_from_wav.md). - - Example config for File: - ``` + Note: On Unix-like systems it's also possible to use the File device + and set the filename to `/dev/stdin` for capture, or `/dev/stdout` for playback. + + The `File` and `Stdout` playback devices can write a wav-header to the output. + Enable this by setting `wav_header` to `true`. + Setting it to `false`, `null`, or leaving it out disables the wav header. + This is a _streaming_ header, meaning it contains a dummy value for the file length. + Most applications ignore this and calculate the correct length from the file size. + + To read from a wav file, use the `WavFile` capture device. + The samplerate and numnber of channels of the file is used to override the values in the config, + similar to how these values can be [overriden from the command line](#overriding-config-values). + Note that `WavFile` only supports reading from files. Reading from a pipe is not supported. + + Example config for raw files: + ```yaml capture: - type: File + type: RawFile channels: 2 filename: "/path/to/inputfile.raw" format: S16LE @@ -1078,9 +1188,9 @@ A parameter marked (*) in any example is optional. If they are left out from the filename: "/path/to/outputfile.raw" format: S32LE ``` - + Example config for Stdin/Stdout: - ``` + ```yaml capture: type: Stdin channels: 2 @@ -1094,19 +1204,68 @@ A parameter marked (*) in any example is optional. If they are left out from the format: S32LE ``` - The `File` and `Stdin` capture devices support two additional optional parameters, for advanced handling of raw files and testing: + Example config for wav input and output: + ```yaml + capture: + type: WavFile + filename: "/path/to/inputfile.wav" + playback: + type: File + channels: 2 + format: S32LE + wav_header: true + filename: "/path/to/outputfile.wav" + ``` + + + The `RawFile` and `Stdin` capture devices support two additional optional parameters, + for advanced handling of raw files and testing: * `skip_bytes`: Number of bytes to skip at the beginning of the file or stream. - This can be used to skip over the header of some formats like .wav (which typically has a fixed size 44-byte header). - Leaving it out or setting to zero means no bytes are skipped. + This can be used to skip over the header of some formats like .wav + (which often has a 44-byte header). + Leaving it out or setting to zero means no bytes are skipped. * `read_bytes`: Read only up until the specified number of bytes. Leave it out or set it to zero to read until the end of the file or stream. - * Example, this will skip the first 50 bytes of the file (index 0-49) and then read the following 200 bytes (index 50-249). + * Example, this will skip the first 50 bytes of the file (index 0-49) + and then read the following 200 bytes (index 50-249). ``` skip_bytes: 50 read_bytes: 200 ``` + The `SignalGenerator` capture device is intended for testing. + It accepts the number of channels as `channels`. + It also requires a block defining the signal properties, called `signal`. + + The signal shape is give by `type`, which accepts `Sine`, `Square` and `WhiteNoise`. + All types require the signal level, which is given in dB in the `level` parameter. + `Sine` and `Square` also require a frequency, defined by the `freq` parameter. + + When using the `SignalGenerator`, the resampler config and capture samplerate are ignored. + The same signal is generated on every channel. + + Example config for sine wave at 440 Hz and -20 dB: + ``` + capture: + type: SignalGenerator + channels: 2 + signal: + type: Sine + freq: 440 + level: -20.0 + ``` + + Example config for white noise ad -10 dB: + ``` + capture: + type: SignalGenerator + channels: 2 + signal: + type: WhiteNoise + level: -10.0 + ``` + ### Wasapi See the [separate readme for Wasapi](./backend_wasapi.md#configuration-of-devices). @@ -1150,6 +1309,12 @@ A parameter marked (*) in any example is optional. If they are left out from the device: "default" ``` + ### Channel labels + All capture device types have an optional `labels` property. + This accepts a list of strings, and is meant to be used by a GUI + to display meaningful channel names. + CamillaDSP itself does not use these labels. + ## Resampling Resampling is provided by the [Rubato library.](https://github.com/HEnquist/rubato) @@ -1184,13 +1349,13 @@ and then use polynomial interpolation to get values for arbitrary times between The AsyncSinc resampler takes an additional parameter `profile`. This is used to select a pre-defined profile. The `Balanced` profile is the best choice in most cases. -It provides good resampling quality with a noise threshold in the range +It provides good resampling quality with a noise threshold in the range of -170 dB along with reasonable CPU usage. -As -170 dB is way beyond the resolution limit of even the best commercial DACs, +As -170 dB is way beyond the resolution limit of even the best commercial DACs, this preset is thus sufficient for all audio use. -The `Fast` and `VeryFast` profiles are faster but have a little more high-frequency roll-off +The `Fast` and `VeryFast` profiles are faster but have a little more high-frequency roll-off and give a bit higher resampling artefacts. -The `Accurate` profile provides the highest quality result, +The `Accurate` profile provides the highest quality result, with all resampling artefacts below -200dB, at the expense of higher CPU usage. Example: @@ -1202,7 +1367,7 @@ Example: It is also possible to specify all parameters of the resampler instead of using the pre-defined profiles. -Example: +Example: ``` resampler: type: AsyncSinc @@ -1216,7 +1381,7 @@ Note that these two ways of defining the resampler cannot be mixed. When using `profile` the other parameters must not be present and vice versa. The `f_cutoff` parameter is the relative cutoff frequency of the anti-aliasing filter. A value of 1.0 means the Nyquist limit. Useful values are in the range 0.9 - 0.99. -It can also be calculated automatically by setting `f_cutoff` to `null`. +It can also be calculated automatically by setting `f_cutoff` to `null`. Available interpolation types: @@ -1234,14 +1399,14 @@ For reference, the profiles are defined according to this table: | | VeryFast | Fast | Balanced | Accurate | |--------------------|:------------:|:----------------:|:------------------:|:------------------:| -|sinc_len | 64 | 128 | 192 | 256 | +|sinc_len | 64 | 128 | 192 | 256 | |oversampling_factor | 1024 | 1024 | 512 | 256 | |interpolation | Linear | Linear | Quadratic | Cubic | |window | Hann2 | Blackman2 | BlackmanHarris2 | BlackmanHarris2 | |f_cutoff | 0.91 (#) | 0.92 (#) | 0.93 (#) | 0.95 (#) | (#) These cutoff values are approximate. The actual values used are calculated automatically -at runtime for the combination of sinc length and window. +at runtime for the combination of sinc length and window. ### `AsyncPoly`: Asynchronous resampling without anti-aliasing @@ -1276,11 +1441,11 @@ Use the `AsyncPoly` type to save processing power, with little or no perceived q ### `Synchronous`: Synchronous resampling with anti-aliasing -For performing fixed ratio resampling, like resampling +For performing fixed ratio resampling, like resampling from 44.1kHz to 96kHz (which corresponds to a precise ratio of 147/320) choose the `Synchronous` type. -This works by transforming the waveform with FFT, modifying the spectrum, and then +This works by transforming the waveform with FFT, modifying the spectrum, and then getting the resampled waveform by inverse FFT. This is considerably faster than the asynchronous variants, but does not support rate adjust. @@ -1294,12 +1459,12 @@ The `Synchronous` type takes no additional parameters: ``` ### Rate adjust via resampling -When using the rate adjust feature to match capture and playback devices, -one of the "Async" types must be used. +When using the rate adjust feature to match capture and playback devices, +one of the "Async" types must be used. These asynchronous resamplers do not rely on a fixed resampling ratio. -When rate adjust is enabled the resampling ratio is dynamically adjusted in order to compensate -for drifts and mismatches between the input and output sample clocks. -Using the "Synchronous" type with rate adjust enabled will print warnings, +When rate adjust is enabled the resampling ratio is dynamically adjusted in order to compensate +for drifts and mismatches between the input and output sample clocks. +Using the "Synchronous" type with rate adjust enabled will print warnings, and any rate adjust request will be ignored. @@ -1310,6 +1475,7 @@ Example for a mixer that copies two channels into four: mixers: ExampleMixer: description: "Example mixer to convert two channels to four" (*) + labels: ["L_LF", "R_LF", "L_HF", "R_HF"] (*) channels: in: 2 out: 4 @@ -1349,8 +1515,13 @@ Each source has a `channel` number, a `gain` value, a `scale` for the gain (`dB` A channel that has no sources will be filled with silence. The `mute` option determines if an output channel of the mixer should be muted. The `mute`, `gain`, `scale` and `inverted` parameters are optional, and defaults to not muted, a gain of 0 in dB, and not inverted. + The optional `description` property is intended for the user and is not used by CamillaDSP itself. +Similar to [capture devices](#channel-labels), the mixer also has a `labels` property. +This is meant to define labels for the output channels from the mixer. +The labels are intended to be used by GUIs and are not used by CamillaDSP. + Another example, a simple stereo to mono mixer: ``` mixers: @@ -1377,7 +1548,7 @@ Then, setting the number of capture channels to 4 will enable both inputs. In this case we are only interested in the SPDIF input. This is then done by adding a mixer that reduces the number of channels to 2. In this mixer, input channels 0 and 1 are not mapped to anything. -This is then detected, and no format conversion, resampling or processing will be done on these two channels. +This is then detected, and no format conversion, resampling or processing will be done on these two channels. ## Filters The filters section defines the filter configurations to use in the pipeline. @@ -1397,13 +1568,15 @@ The `gain` value is given in either dB or as a linear factor, depending on the ` This can be set to `dB` or `linear`. If set to `null` or left out it defaults to dB. -When the dB scale is used (`scale: dB`), a positive gain value means the signal will be amplified while a negative values attenuates. +When the dB scale is used (`scale: dB`), a positive gain value means the signal will be amplified +while a negative values attenuates. The gain value must be in the range -150 to +150 dB. If linear gain is used (`scale: linear`), the gain value is treated as a simple multiplication factor. A factor 0.5 attenuates by a factor two (equivalent to a gain of -6.02 dB). A negative value inverts the signal. -Note that the `invert` setting also inverts, so a gain of -0.5 with invert set to true becomes inverted twice and the result is non-inverted. +Note that the `invert` setting also inverts, so a gain of -0.5 with invert set to true +becomes inverted twice and the result is non-inverted. The linear gain is limited to a range of -10.0 to +10.0. The `mute` parameter determines if the the signal should be muted. @@ -1436,7 +1609,7 @@ and it's not possible to select this fader for Volume filters. Volume filters may use the four additional faders, named `Aux1`, `Aux2`,`Aux3` and `Aux4`. -A Volume filter is configured to react to one of these faders. +A Volume filter is configured to react to one of these faders. The volume can then be changed via the websocket, by changing the corresponding fader. A request to set the volume will be applied to all Volume filters listening to the affected `fader`. @@ -1445,6 +1618,10 @@ The duration of this ramp is set by the `ramp_time` parameter (unit milliseconds The value must not be negative. If left out or set to `null`, it defaults to 400 ms. The value will be rounded to the nearest number of chunks. +The range of the volume control can be limited via the optional `limit` parameter. +This sets a limit for the maximum value of the volume. +If left out or set to `null`, it defaults to +50 dB. + Example Volume filter: ``` filters: @@ -1452,6 +1629,7 @@ filters: type: Volume parameters: ramp_time: 200 (*) + limit: 10.0 (*) fader: Aux1 ``` @@ -1496,7 +1674,7 @@ filters: type: Loudness parameters: fader: Main (*) - reference_level: -25.0 + reference_level: -25.0 high_boost: 7.0 (*) low_boost: 7.0 (*) attenuate_mid: false (*) @@ -1507,16 +1685,16 @@ Allowed ranges: - low_boost: 0 to 20 ### Delay -The delay filter provides a delay in milliseconds, millimetres or samples. +The delay filter provides a delay in milliseconds, millimetres or samples. The `unit` can be `ms`, `mm` or `samples`, and if left out it defaults to `ms`. When giving the delay in millimetres, the speed of sound of is assumed to be 343 m/s (dry air at 20 degrees Celsius). If the `subsample` parameter is set to `true`, then it will use use an IIR filter to achieve subsample delay precision. If set to `false`, the value will instead be rounded to the nearest number of full samples. This is a little faster and should be used if subsample precision is not required. - -The delay value must be positive or zero. + +The delay value must be positive or zero. Example Delay filter: ``` @@ -1541,7 +1719,7 @@ filters: example_fir_a: type: Conv parameters: - type: Raw + type: Raw filename: path/to/filter.txt format: TEXT skip_bytes_lines: 0 (*) @@ -1549,7 +1727,7 @@ filters: example_fir_b: type: Conv parameters: - type: Wav + type: Wav filename: path/to/filter.wav channel: 0 (*) ``` @@ -1564,8 +1742,22 @@ If it's not found there, the path is assumed to be relative to the current worki Note that this only applies when the config is loaded from a file. When a config is supplied via the websocket server only the current working dir of the CamillaDSP process will be searched. -If the filename includes the tokens `$samplerate$` or `$channels$`, these will be replaced by the corresponding values from the config. -For example, if samplerate is 44100, the filename `/path/to/filter_$samplerate$.raw` will be updated to `/path/to/filter_44100.raw`. +If the filename includes the tokens `$samplerate$` or `$channels$`, +these will be replaced by the corresponding values from the config. +For example, if samplerate is 44100, +the filename `/path/to/filter_$samplerate$.raw` will be updated to `/path/to/filter_44100.raw`. + +#### Generating FIR coefficients +There are many ways to generate impulse responses for FIR filters. +Typically they are generated by some dedicated application. +See also [Measurement and filter generation tools](#measurement-and-filter-generation-tools). + +[rePhase](#rephase) is a popular choice that is free to use. +It allows building fully linear-phase active crossovers with arbitrary slopes. +It also supports compensating the phase shifts of loudspeakers and existing crossovers. +In the Impulse Settings box configure the rate to the same as used in CamillaDSP +and the format to 64 bits IEEE-754 (.dbl). +This corresponds to raw samples in FLOAT64LE format in CamillaDSP. #### Values directly in config file @@ -1608,15 +1800,17 @@ The sample rate of the file is ignored. To load coefficients from a raw file, use the `Raw` type. This is also used to load coefficients from text files. Raw files are often saved with a `.dbl`, `.raw`, or `.pcm` ending. The lack of a header means that the files doesn't contain any information about data format etc. -CamillaDSP supports loading coefficients from such files that contain a single channel only (stereo files are not supported), in all the most common sample formats. +CamillaDSP supports loading coefficients from such files that contain a single channel only +(stereo files are not supported), in all the most common sample formats. The `Raw` type supports two additional optional parameters, for advanced handling of raw files and text files with headers: * `skip_bytes_lines`: Number of bytes (for raw files) or lines (for text) to skip at the beginning of the file. - This can be used to skip over a header. Leaving it out or setting to zero means no bytes or lines are skipped. + This can be used to skip over a header. Leaving it out or setting to zero means no bytes or lines are skipped. * `read_bytes_lines`: Read only up until the specified number of bytes (for raw files) or lines (for text). Leave it out or set it to zero to read until the end of the file. The filter coefficients can be provided either as text, or as raw samples. Each file can only hold one channel. -The "format" parameter can be omitted, in which case it's assumed that the format is TEXT. This format is a simple text file with one value per row: +The "format" parameter can be omitted, in which case it's assumed that the format is TEXT. +This format is a simple text file with one value per row: ``` -0.000021 -0.000020 @@ -1635,7 +1829,8 @@ The other possible formats are raw data: ### IIR IIR filters are implemented as Biquad filters. -CamillaDSP can calculate the coefficients for a number of standard filters, or you can provide the coefficients directly. +CamillaDSP can calculate the coefficients for a number of standard filters, +or you can provide the coefficients directly. Examples: ``` @@ -1699,27 +1894,28 @@ Single Biquads are defined using the type "Biquad". The available filter types a * Highpass & Lowpass Second order high/lowpass filters (12dB/oct) - + Defined by cutoff frequency `freq` and Q-value `q`. * HighpassFO & LowpassFO First order high/lowpass filters (6dB/oct) - + Defined by cutoff frequency `freq`. * Highshelf & Lowshelf - + High / Low uniformly affects the high / low frequencies respectively while leaving the low / high part unaffected. In between there is a slope of variable steepness. Parameters: * `freq` is the center frequency of the sloping section. * `gain` gives the gain of the filter * `slope` is the steepness in dB/octave. Values up to around +-12 are usable. - * `q` is the Q-value and can be used instead of `slope` to define the steepness of the filter. Only one of `q` and `slope` can be given. + * `q` is the Q-value and can be used instead of `slope` to define the steepness of the filter. + Only one of `q` and `slope` can be given. * HighshelfFO & LowshelfFO - + First order (6dB/oct) versions of the shelving functions. Parameters: @@ -1727,22 +1923,26 @@ Single Biquads are defined using the type "Biquad". The available filter types a * `gain` gives the gain of the filter * Peaking - - A parametric peaking filter with selectable gain `gain` at a given frequency `freq` with a bandwidth given either by the Q-value `q` or bandwidth in octaves `bandwidth`. + + A parametric peaking filter with selectable gain `gain` at a given frequency `freq` + with a bandwidth given either by the Q-value `q` or bandwidth in octaves `bandwidth`. Note that bandwidth and Q-value are inversely related, a small bandwidth corresponds to a large Q-value etc. Use positive gain values to boost, and negative values to attenuate. * Notch - - A notch filter to attenuate a given frequency `freq` with a bandwidth given either by the Q-value `q` or bandwidth in octaves `bandwidth`. + + A notch filter to attenuate a given frequency `freq` with a bandwidth + given either by the Q-value `q` or bandwidth in octaves `bandwidth`. The notch filter is similar to a Peaking filter configured with a large negative gain. * GeneralNotch The general notch is a notch where the pole and zero can be placed at different frequencies. - It is defined by its zero frequency `freq_z`, pole frequency `freq_p`, pole Q `q_p`, and an optional parameter `normalize_at_dc`. + It is defined by its zero frequency `freq_z`, pole frequency `freq_p`, + pole Q `q_p`, and an optional parameter `normalize_at_dc`. - When pole and zero frequencies are different, the low-frequency gain is changed and the shape (peakiness) at the `freq_p` side of the notch can be controlled by `q_p`. + When pole and zero frequencies are different, the low-frequency gain is changed + and the shape (peakiness) at the `freq_p` side of the notch can be controlled by `q_p`. The response is similar to an adjustable Q 2nd order shelf between `freq_p` and `freq_z` plus a notch at `freq_z`. The highpass-notch form is obtained when `freq_p` > `freq_z`. @@ -1758,27 +1958,29 @@ Single Biquads are defined using the type "Biquad". The available filter types a Note that when the pole and zero frequencies are set to the same value the common (symmetrical) notch is obtained. * Bandpass - + A second order bandpass filter for a given frequency `freq` with a bandwidth given either by the Q-value `q` or bandwidth in octaves `bandwidth`. * Allpass - A second order allpass filter for a given frequency `freq` with a steepness given either by the Q-value `q` or bandwidth in octaves `bandwidth` + A second order allpass filter for a given frequency `freq` with a steepness given + either by the Q-value `q` or bandwidth in octaves `bandwidth` * AllpassFO A first order allpass filter for a given frequency `freq`. * LinkwitzTransform - + A normal sealed-box speaker has a second order high-pass frequency response given by a resonance frequency and a Q-value. - A [Linkwitz transform](https://linkwitzlab.com/filters.htm#9) can be used to apply a tailored filter that modifies the actual frequency response to a new target response. + A [Linkwitz transform](https://linkwitzlab.com/filters.htm#9) can be used to apply a tailored filter + that modifies the actual frequency response to a new target response. The target is also a second order high-pass function, given by the target resonance frequency and Q-value. Parameters: * `freq_act`: actual resonance frequency of the speaker. * `q_act`: actual Q-value of the speaker. - * `freq_target`: target resonance frequency. + * `freq_target`: target resonance frequency. * `q_target`: target Q-value. To build more complex filters, use the type "BiquadCombo". @@ -1809,11 +2011,12 @@ The available types are: The `gain` value is limited to +- 100 dB. * FivePointPeq - - This filter combo is mainly meant to be created by guis. Is defines a 5-point (or band) parametric equalizer by combining a Lowshelf, a Highshelf and three Peaking filters. + + This filter combo is mainly meant to be created by guis. + It defines a 5-point (or band) parametric equalizer by combining a Lowshelf, a Highshelf and three Peaking filters. Each individual filter is defined by frequency, gain and q. The parameter names are: - * Lowshelf: `fls`, `gls`, `qls` + * Lowshelf: `fls`, `gls`, `qls` * Peaking 1: `fp1`, `gp1`, `qp1` * Peaking 2: `fp2`, `gp2`, `qp2` * Peaking 3: `fp3`, `gp3`, `qp3` @@ -1822,7 +2025,8 @@ The available types are: All 15 parameters must be included in the config. -Other types such as Bessel filters can be built by combining several Biquads. [See the separate readme for more filter functions.](./filterfunctions.md) +Other types such as Bessel filters can be built by combining several Biquads. +[See the separate readme for more filter functions.](./filterfunctions.md) * GraphicEqualizer @@ -1834,11 +2038,12 @@ Other types such as Bessel filters can be built by combining several Biquads. [S The number of bands, and the gain for each band is given by the `gains` parameter. This accepts a list of gains in dB. The number of values determines the number of bands. - The gains are limited to +- 40 dB. + The gains are limited to +- 40 dB. The band frequencies are distributed evenly on the logarithmic frequency scale, and each band has the same relative bandwidth. - For example a 31-band equalizer on the default range gets a 1/3 octave bandwith, with the first three bands centered at 22.4, 27.9, 34.9 Hz, and the last two at 14.3 and 17.9 kHz. + For example a 31-band equalizer on the default range gets a 1/3 octave bandwith, + with the first three bands centered at 22.4, 27.9, 34.9 Hz, and the last two at 14.3 and 17.9 kHz. Example: ``` @@ -1857,7 +2062,7 @@ Other types such as Bessel filters can be built by combining several Biquads. [S ### Dither The "Dither" filter should only be added at the very end of the pipeline for each channel, and adds noise shaped dither to the output. -This is intended for 16-bit output, but can be used also for higher bit depth if desired. There are several subtypes: +This is intended for 16-bit output, but can be used also for higher bit depth if desired. There are several subtypes: | Subtype | kHz | Noise shaping | Comments | | ------------------- | ---- | ------------- | -------------------------------------------------------------- | @@ -1891,19 +2096,25 @@ Highpass is an exception, which is about as fast as Flat. The parameter "bits" sets the target bit depth. For most oversampling delta-sigma DACs, this should match the bit depth of the playback device for best results. -For true non-oversampling DACs, this should match the number of bits over which the DAC is linear (or the playback bit depth, whichever is lower). +For true non-oversampling DACs, this should match the number of bits over which the DAC is linear +(or the playback bit depth, whichever is lower). Setting it to a higher value is not useful since then the applied dither will be lost. For the "Flat" subtype, the parameter "amplitude" sets the number of LSB to be dithered. To linearize the samples, this should be 2. Lower amplitudes produce less noise but also linearize less; higher numbers produce more noise but do not linearize more. -Some comparisons between the noise shapers are available from [SoX](http://sox.sourceforge.net/SoX/NoiseShaping), [SSRC](https://shibatch.sourceforge.net/ssrc/) and [ReSampler](https://github.com/jniemann66/ReSampler/blob/master/ditherProfiles.md). -To test the different types, set the target bit depth to something very small like 5 or 6 bits (the minimum allowed value is 2) and try them all. -Note that on "None" this may well mean there is no or unintelligible audio -- this is to experiment with and show what the other ditherers actually do. +Some comparisons between the noise shapers are available from [SoX](http://sox.sourceforge.net/SoX/NoiseShaping), +[SSRC](https://shibatch.sourceforge.net/ssrc/) +and [ReSampler](https://github.com/jniemann66/ReSampler/blob/master/ditherProfiles.md). +To test the different types, set the target bit depth to something very small +like 5 or 6 bits (the minimum allowed value is 2) and try them all. +Note that on "None" this may well mean there is no or unintelligible audio -- this is to experiment with +and show what the other ditherers actually do. For sample rates above 48 kHz there is no need for anything more advanced than the "Highpass" subtype. -For the low sample rates there is no spare bandwidth and the dither noise must use the audible range, with shaping to makes it less audible. +For the low sample rates there is no spare bandwidth and the dither noise must use the audible range, +with shaping to makes it less audible. But at 96 or 192 kHz there is all the bandwidth from 20 kHz up to 48 or 96 kHz where the noise can be placed without issues. The Highpass ditherer will place almost all of it there. Of course, the high-resolution Shibata filters provide some icing on the cake. @@ -1911,7 +2122,8 @@ Of course, the high-resolution Shibata filters provide some icing on the cake. Selecting a noise shaping ditherer for a different sample rate than it was designed for, will cause the frequency response curve of the noise shaper to be fitted to the playback rate. This means that the curve no longer matches its design points to be minimally audible. -You may experiment which shaper still sounds good, or use the Flat or Highpass subtypes which work well at any sample rate. +You may experiment which shaper still sounds good, +or use the Flat or Highpass subtypes which work well at any sample rate. Example: ``` @@ -1923,7 +2135,7 @@ Example: ``` ### Limiter -The "Limiter" filter is used to limit the signal to a given level. It can use hard or soft clipping. +The "Limiter" filter is used to limit the signal to a given level. It can use hard or soft clipping. Note that soft clipping introduces some harmonic distortion to the signal. Example: @@ -1957,14 +2169,13 @@ Both a and b are optional. If left out, they default to [1.0]. ## Processors The `processors` section contains the definitions for the Processors. These are special "filters" that work on several channels at the same time. -At present only one type of processor, "Compressor", has been implemented. Processors take an optional `description` property. This is intended for the user and is not used by CamillaDSP itself. ### Compressor The "Compressor" processor implements a standard dynamic range compressor. -It is configured using the most common parameters. +It is configured using the most common parameters. Example: ``` @@ -1986,7 +2197,7 @@ processors: pipeline: - type: Processor name: democompressor -``` +``` Parameters: * `channels`: number of channels, must match the number of channels of the pipeline where the compressor is inserted. @@ -2001,8 +2212,43 @@ pipeline: Note that soft clipping introduces some harmonic distortion to the signal. This setting is ignored if `enable_clip = false`. Optional, defaults to `false`. * `monitor_channels`: a list of channels used when estimating the loudness. Optional, defaults to all channels. - * `process_channels`: a list of channels that should be compressed. Optional, defaults to all channels. + * `process_channels`: a list of channels to be compressed. Optional, defaults to all channels. + +### Noise Gate +The "NoiseGate" processor implements a simple noise gate. +It monitors the given channels to estimate the current loudness, +using the same algorithm as the compressor. +When the loudness is above the threshold, +the gate "opens" and the sound is passed through unaltered. +When it is below, the gate "closes" and attenuates the selected channels by the given amount. + +Example: +``` +processors: + demogate: + type: NoiseGate + parameters: + channels: 2 + attack: 0.025 + release: 1.0 + threshold: -25 + attenuation: 50.0 + monitor_channels: [0, 1] (*) + process_channels: [0, 1] (*) + +pipeline: + - type: Processor + name: demogate +``` + Parameters: + * `channels`: number of channels, must match the number of channels of the pipeline where the compressor is inserted. + * `attack`: time constant in seconds for attack, how fast the gate reacts to an increase of the loudness. + * `release`: time constant in seconds for release, how fast the gate reacts when the loudness decreases. + * `threshold`: the loudness threshold in dB where gate "opens". + * `attenuation`: the amount of attenuation in dB to apply when the gate is "closed". + * `monitor_channels`: a list of channels used when estimating the loudness. Optional, defaults to all channels. + * `process_channels`: a list of channels to be gated. Optional, defaults to all channels. ## Pipeline @@ -2014,7 +2260,7 @@ A step can be a filter, a mixer or a processor. The filters, mixers and processors must be defined in the corresponding section of the configuration, and the pipeline refers to them by their name. During processing, the steps are applied in the listed order. For each mixer and for the output device the number of channels from the previous step must match the number of input channels. -For filter steps, the channel number must exist at that point of the pipeline. +For filter steps, the channel numbers must exist at that point of the pipeline. Channels are numbered starting from zero. Apart from this, there are no rules for ordering of the steps or how many are added. @@ -2028,28 +2274,15 @@ pipeline: name: to4channels bypassed: false (*) - type: Filter - description: "Left channel woofer" - channel: 0 - bypassed: false (*) - names: - - lowpass_fir - - peak1 - - type: Filter - description: "Right channel woofer" - channel: 1 + description: "Left and right woofer channels" + channels: [0, 1] (*) bypassed: false (*) names: - lowpass_fir - peak1 - type: Filter - description: "Left channel tweeter" - channel: 2 - bypassed: false (*) - names: - - highpass_fir - - type: Filter - description: "Right channel tweeter" - channel: 3 + description: "Left and right tweeter channels" + channels: [2, 3] bypassed: false (*) names: - highpass_fir @@ -2061,15 +2294,25 @@ pipeline: In this config first a mixer is used to copy a stereo input to four channels. Before the mixer, only channels 0 and 1 exist. After the mixer, four channels are available, with numbers 0 to 3. -The mixer is followed by a filter step for each channel. +The mixer is followed by a filter step for each pair of channels. Finally a compressor is added as the last step. ### Filter step A filter step, `type: Filter`, can contain one or several filters. +The chosen filters are given in the `names` property, which is an list of filter names. The filters must be defined in the `Filters` section. -In the example above, channel 0 and 1 get filtered by `lowpass_fir` and `peak1`, while 2 and 3 get filtered by just `highpass_fir`. -If several filters are to be applied to a channel, it is recommended to put then in a single filter step. -This makes the config easier to overview and gives a minor performance benefit, compared to adding each filter in a separate step, + +The chosen filters will be applied to the channels listed in the `channels` property. +This property is optional. If it is left out or set to `null`, +the filters are applied to all the channels at that point in the pipeline. +An empty list means the filters will not be applied to any channel. + +In the example above, channels 0 and 1 get filtered by `lowpass_fir` and `peak1`, +while 2 and 3 get filtered by just `highpass_fir`. + +If several filters are to be applied to a channel, it is recommended to put them in a single filter step. +This makes the config easier to overview and gives a minor performance benefit, +compared to adding each filter in a separate step. ### Mixer and Processor step Mixer steps, `type: Mixer`, and processor steps, `type: Processor`, are defined in a similar way. @@ -2086,8 +2329,8 @@ Take care when bypassing mixers. If a mixer is used to change the number of chan then bypassing it will make the pipeline output the wrong number of channels. In this case, the bypass may be used to switch between mixers with different settings. -## Export filters from REW -REW can automatically generate a set of filters for correcting the frequency response of a system. +## Using filters from REW +[REW](#rew) can automatically generate a set of filters for correcting the frequency response of a system. REW V5.20.14 and later is able to export the filters in the CamillaDSP YAML format. - Go to the "EQ Filters" screen. Expand the "Equalizer" section in the list on the right side. @@ -2098,56 +2341,44 @@ REW V5.20.14 and later is able to export the filters in the CamillaDSP YAML form Note that the generated YAML file is not a complete CamillaDSP configuration. It contains only filter definitions and pipeline steps, that can be pasted into a CamillaDSP config file. - -## Visualizing the config -__Please note that the `show_config.py` script mentioned here is deprecated, and has been replaced by the `plotcamillaconf` tool from the pycamilladsp-plot library.__ -The new tool provides the same functionality as well as many improvements. -The `show_config.py` does not support any of the newer config options, and the script will be removed in a future version. - -A Python script is included to view the configuration. -This plots the transfer functions of all included filters, as well as plots a flowchart of the entire processing pipeline. Run it with: -``` -python show_config.py /path/to/config.yml -``` - -Example flowchart: - -![Example](pipeline.png) - -Note that the script assumes a valid configuration file and will not give any helpful error messages if it's not, -so it's a good idea to first use CamillaDSP to validate the file. -The script requires the following: -* Python 3 -* Numpy -* Matplotlib -* PyYAML - +If using [CamillaGUI](#gui), it is also possible to import the filters into an existing configuration. # Related projects -Other projects using CamillaDSP: +## Other projects using CamillaDSP * https://github.com/scripple/alsa_cdsp - ALSA CamillaDSP "I/O" plugin, automatic config updates at changes of samplerate, sample format or number of channels. * https://github.com/raptorlightning/I2S-Hat - An SPDIF Hat for the Raspberry Pi 2-X for SPDIF Communication, see also [this thread at diyAudio.com](https://www.diyaudio.com/forums/pc-based/375834-i2s-hat-raspberry-pi-hat-spdif-i2s-communication-dsp.html). * https://github.com/daverz/camilla-remote-control - Interface for remote control of CamillaDSP using a FLIRC USB infrared receiver or remote keyboard. * https://github.com/Wang-Yue/CamillaDSP-Monitor - A script that provides a DSP pipeline and a spectral analyzer similar to those of the RME ADI-2 DAC/Pro. -Music players: +## Music players * https://moodeaudio.org/ - moOde audio player, audiophile-quality music playback for Raspberry Pi. -* https://github.com/thoelf/Linux-Stream-Player - Play local files or streamed music with room EQ on Linux. +* https://github.com/thoelf/Linux-Stream-Player - Play local files or streamed music with room EQ on Linux. * https://github.com/Lykkedk/SuperPlayer-v8.0.0---SamplerateChanger-v1.0.0 - Automatic filter switching at sample rate change for squeezelite, see also [this thread at diyAudio.com](https://www.diyaudio.com/forums/pc-based/361429-superplayer-dsp_engine-camilladsp-samplerate-switching-esp32-remote-control.html). * https://github.com/JWahle/piCoreCDSP - Installs CamillaDSP and GUI on piCorePlayer * [FusionDsp](https://docs.google.com/document/d/e/2PACX-1vRhU4i830YaaUlB6-FiDAdvl69T3Iej_9oSbNTeSpiW0DlsyuTLSv5IsVSYMmkwbFvNbdAT0Tj6Yjjh/pub) a plugin based on CamillaDsp for [Volumio](https://volumio.com), the music player, with graphic equalizer, parametric equalizer, FIR filters, Loudness, AutoEq profile for headphone and more! -Guides and example configs: +## Guides and example configs * https://github.com/ynot123/CamillaDSP-Cfgs-FIR-Examples - Example Filter Configuration and Convolver Coefficients. * https://github.com/hughpyle/raspot - Hugh's raspotify config * https://github.com/Wang-Yue/camilladsp-crossfeed - Bauer stereophonic-to-binaural crossfeed for headphones * https://github.com/jensgk/akg_k702_camilladsp_eq - Headphone EQ and Crossfeed for the AKG K702 headphones -* https://github.com/phelluy/room_eq_mac_m1 - Room Equalization HowTo with REW and Apple Silicon +* https://github.com/phelluy/room_eq_mac_m1 - Room Equalization HowTo with REW and Apple Silicon -Projects of general nature which can be useful together with CamillaDSP: +## Projects of general nature which can be useful together with CamillaDSP * https://github.com/scripple/alsa_hook_hwparams - Alsa hooks for reacting to sample rate and format changes. -* https://github.com/HEnquist/cpal-listdevices - List audio devices with names and supported formats under Windows and macOS. - +* https://github.com/HEnquist/cpal-listdevices - List audio devices with names and supported formats under Windows and macOS. + +## Measurement and filter generation tools +### rePhase +https://rephase.org/ - rePhase is a free FIR generation tool for building +fully linear-phase active crossovers with arbitrary slopes. +### REW +https://www.roomeqwizard.com/ - REW is free software for room acoustic measurement, +loudspeaker measurement and audio device measurement. +### DRC +https://drc-fir.sourceforge.net/ - DRC is a program used to generate correction filters +for acoustic compensation of HiFi and audio systems in general, +including listening room compensation. # Getting help diff --git a/backend_alsa.md b/backend_alsa.md index 3cf1fffb..ded43ecc 100644 --- a/backend_alsa.md +++ b/backend_alsa.md @@ -2,17 +2,27 @@ ## Introduction -ALSA is the low level audio API that is used in the Linux kernel. The ALSA project also maintains various user-space tools and utilities that are installed by default in most Linux distributions. +ALSA is the low level audio API that is used in the Linux kernel. +The ALSA project also maintains various user-space tools and utilities +that are installed by default in most Linux distributions. -This readme only covers some basics of ALSA. For more details, see for example the [ALSA Documentation](#alsa-documentation) and [A close look at ALSA](#a-close-look-at-alsa) +This readme only covers some basics of ALSA. For more details, +see for example the [ALSA Documentation](#alsa-documentation) and [A close look at ALSA](#a-close-look-at-alsa) ### Hardware devices -In the ALSA scheme, a soundcard or dac corresponds to a "card". A card can have one or several inputs and/or outputs, denoted "devices". Finally each device can support one or several streams, called "subdevices". It depends on the driver implementation how the different physical ports of a card is exposed in terms of devices. For example a 4-channel unit may present a single 4-channel device, or two separate 2-channel devices. +In the ALSA scheme, a soundcard or dac corresponds to a "card". +A card can have one or several inputs and/or outputs, denoted "devices". +Finally each device can support one or several streams, called "subdevices". +It depends on the driver implementation how the different physical ports of a card is exposed in terms of devices. +For example a 4-channel unit may present a single 4-channel device, or two separate 2-channel devices. ### PCM devices -An alsa PCM device can be many different things, like a simple alias for a hardware device, or any of the many plugins supported by ALSA. PCM devices are normally defined in the ALSA configuration file see the [ALSA Plugin Documentation](#alsa-plugin-documentation) for a list of the available plugins. +An alsa PCM device can be many different things, like a simple alias for a hardware device, +or any of the many plugins supported by ALSA. +PCM devices are normally defined in the ALSA configuration file. +See the [ALSA Plugin Documentation](#alsa-plugin-documentation) for a list of the available plugins. ### Find name of device To list all hardware playback devices use the `aplay` command with the `-l` option: @@ -33,7 +43,9 @@ hdmi:CARD=Generic,DEV=0 ``` Capture devices can be found in the same way with `arecord -l` and `arecord -L`. -A hardware device is accessed via the "hw" plugin. The device name is then prefixed by `hw:`. To use the ALC236 hardware device from above, put either `hw:Generic` (to use the name, recommended) or `hw:0` (to use the index) in the CamillaDSP config. +A hardware device is accessed via the "hw" plugin. The device name is then prefixed by `hw:`. +To use the ALC236 hardware device from above, +put either `hw:Generic` (to use the name, recommended) or `hw:0` (to use the index) in the CamillaDSP config. To instead use the "hdmi" PCM device, it's enough to give the name `hdmi`. @@ -66,12 +78,23 @@ Available formats: - S16_LE - S32_LE ``` -Ignore the error message at the end. The interesting fields are FORMAT, RATE and CHANNELS. In this example the sample formats this device can use are S16_LE and S32_LE (corresponding to S16LE and S32LE in CamillaDSP, see the [table of equivalent formats in the main README](./README.md#equivalent-formats) for the complete list). The sample rate can be either 44.1 or 48 kHz. And it supports only stereo playback (2 channels). +Ignore the error message at the end. The interesting fields are FORMAT, RATE and CHANNELS. +In this example the sample formats this device can use are S16_LE and S32_LE (corresponding to S16LE and S32LE in CamillaDSP, +see the [table of equivalent formats in the main README](./README.md#equivalent-formats) for the complete list). +The sample rate can be either 44.1 or 48 kHz. And it supports only stereo playback (2 channels). ### Combinations of parameter values -Note that all possible combinations of the shown parameters may not be supported by the device. For example many USB DACS only support 24-bit samples up to 96 kHz, so that only 16-bit samples are supported at 192 kHz. For other devices, the number of channels depends on the sample rate. This is common on studio interfaces that support [ADAT](#adat). +Note that all possible combinations of the shown parameters may not be supported by the device. +For example many USB DACS only support 24-bit samples up to 96 kHz, +so that only 16-bit samples are supported at 192 kHz. +For other devices, the number of channels depends on the sample rate. +This is common on studio interfaces that support [ADAT](#adat). -CamillaDSP sets first the number of channels. Then it sets sample rate, and finally sample format. Setting a value for a parameter may restrict the allowed values for the ones that have not yet been set. For the USB DAC just mentioned, setting the sample rate to 192 kHz means that only the S16LE sample format is allowed. If the CamillaDSP configuration is set to 192 kHz and S24LE3, then there will be an error when setting the format. +CamillaDSP sets first the number of channels. +Then it sets sample rate, and finally sample format. +Setting a value for a parameter may restrict the allowed values for the ones that have not yet been set. +For the USB DAC just mentioned, setting the sample rate to 192 kHz means that only the S16LE sample format is allowed. +If the CamillaDSP configuration is set to 192 kHz and S24LE3, then there will be an error when setting the format. Capture parameters are determined in the same way with `arecord`: @@ -82,10 +105,13 @@ This outputs the same table as for the aplay example above, but for a capture de ## Routing all audio through CamillaDSP -To route all audio through CamillaDSP using ALSA, the audio output from any application must be redirected. This can be acheived either by using an [ALSA Loopback device](#alsa-loopback), or the [ALSA CamillaDSP "I/O" plugin](#alsa-camilladsp-"io"-plugin). +To route all audio through CamillaDSP using ALSA, the audio output from any application must be redirected. +This can be acheived either by using an [ALSA Loopback device](#alsa-loopback), +or the [ALSA CamillaDSP "I/O" plugin](#alsa-camilladsp-"io"-plugin). ### ALSA Loopback -An ALSA Loopback card can be used. This behaves like a sound card that presents two devices. The sound being send to the playback side on one device can then be captured from the capture side on the other device. +An ALSA Loopback card can be used. This behaves like a sound card that presents two devices. +The sound being send to the playback side on one device can then be captured from the capture side on the other device. To load the kernel module type: ``` sudo modprobe snd-aloop @@ -103,7 +129,8 @@ The audio can then be captured from card "Loopback", device 0, subdevice 0, by r ``` arecord -D hw:Loopback,0,0 sometrack_copy.wav ``` -The first application that opens either side of a Loopback decides the sample rate and format. If `aplay` is started first in this example, this means that `arecord` must use the same sample rate and format. +The first application that opens either side of a Loopback decides the sample rate and format. +If `aplay` is started first in this example, this means that `arecord` must use the same sample rate and format. To change format or rate, both sides of the loopback must first be closed. When using the ALSA Loopback approach, see the separate repository [camilladsp-config](#camilladsp-config). @@ -111,9 +138,13 @@ This contains example configuration files for setting up the entire system, and ### ALSA CamillaDSP "I/O" plugin -ALSA can be extended by plugins in user-space. One such plugin that is intended specifically for CamillaDSP is the [ALSA CamillaDSP "I/O" plugin](#alsa-camilladsp-plugin) by scripple. +ALSA can be extended by plugins in user-space. +One such plugin that is intended specifically for CamillaDSP +is the [ALSA CamillaDSP "I/O" plugin](#alsa-camilladsp-plugin) by scripple. -The plugin starts CamillaDSP whenever an application opens the CamillaDSP plugin PCM device. This makes it possible to support automatic switching of the sample rate. See the plugin readme for how to install and configure it. +The plugin starts CamillaDSP whenever an application opens the CamillaDSP plugin PCM device. +This makes it possible to support automatic switching of the sample rate. +See the plugin readme for how to install and configure it. ## Configuration of devices @@ -123,19 +154,90 @@ This example configuration will be used to explain the various options specific type: Alsa channels: 2 device: "hw:0,1" - format: S16LE + format: S16LE (*) + stop_on_inactive: false (*) + follow_volume_control: "PCM Playback Volume" (*) playback: type: Alsa channels: 2 device: "hw:Generic_1" - format: S32LE + format: S32LE (*) ``` ### Device names See [Find name of device](#find-name-of-device) for what to write in the `device` field. ### Sample rate and format -Please see [Find valid playback and capture parameters](#find-valid-playback-and-capture-parameters). +The sample format is optional. If set to `null` or left out, +the highest quality available format is chosen automatically. + +When the format is set automatically, 32-bit integer (`S32LE`) is considered the best, +followed by 24-bit (`S24LE3` and `S24LE`) and 16-bit integer (`S16LE`). +The 32-bit (`FLOAT32LE`) and 64-bit (`FLOAT64LE`) float formats are high quality, +but are supported by very few devices. Therefore these are checked last. + +Please also see [Find valid playback and capture parameters](#find-valid-playback-and-capture-parameters). + +### Linking volume control to device volume +It is possible to let CamillaDSP link its volume and mute controls to controls on the capture device. +This is mostly useful when capturing from the USB Audio Gadget, +which provides a volume control named `PCM Capture Volume` +and a mute control called `PCM Capture Switch` that are controlled by the USB host. + +This volume control does not alter the signal, +and can be used to forward the volume setting from a player to CamillaDSP. +To enable this, set the `link_volume_control` setting to the name of the volume control. +The corresponding setting for the mute control is `link_mute_control`. +Any change of the volume or mute then gets applied to the CamillaDSP main volume control. +The link works in both directions, so that volume and mute changes requested +over the websocket interface also get sent to the USB host. + +The available controls for a device can be listed with `amixer`. +List controls for card 1: +```sh +amixer -c 1 controls +``` + +List controls with values and more details: +```sh +amixer -c 1 contents +``` + +The chosen volume control should be one that does not affect the signal volume, +otherwise the volume gets applied twice. +It must also have a scale in decibel, and take a single value (`values=1`). + +Example: +``` +numid=15,iface=MIXER,name='Master Playback Volume' + ; type=INTEGER,access=rw---R--,values=1,min=0,max=87,step=0 + : values=52 + | dBscale-min=-65.25dB,step=0.75dB,mute=0 +``` + +The mute control shoule be a _switch_, meaning that is has states `on` and `off`, +where `on` is not muted and `off` is muted. +It must also take a single value (`values=1`). + +Example: +``` +numid=6,iface=MIXER,name='PCM Capture Switch' + ; type=BOOLEAN,access=rw------,values=1 + : values=on +``` + +### Subscribe to Alsa control events +The Alsa capture device subscribes to control events from the USB Gadget and Loopback devices. +For the loopback, it subscribes to events from the `PCM Slave Active` control, +and for the gadget it subscribes to events from `Capture Rate`. +Both of these can indicate when playback has stopped. +If CamillaDSP should stop when that happens, set `stop_on_inactive` to `true`. +For the loopback, this means that CamillaDSP releases the capture side, +making it possible for a player application to re-open at another sample rate. + +For the gadget, the control can also indicate that the sample rate changed. +When this happens, the capture can no longer continue and CamillaDSP will stop. +The new sample rate can then be read by the `GetStopReason` websocket command. ## Links ### ALSA Documentation @@ -151,4 +253,5 @@ https://github.com/scripple/alsa_cdsp/ ## Notes ### ADAT -ADAT achieves higher sampling rates by multiplexing two or four 44.1/48kHz audio streams into a single one. A device implementing 8 channels over ADAT at 48kHz will therefore provide 4 channels over ADAT at 96kHz and 2 channels over ADAT at 192kHz. \ No newline at end of file +ADAT achieves higher sampling rates by multiplexing two or four 44.1/48kHz audio streams into a single one. +A device implementing 8 channels over ADAT at 48kHz will therefore provide 4 channels over ADAT at 96kHz and 2 channels over ADAT at 192kHz. \ No newline at end of file diff --git a/backend_coreaudio.md b/backend_coreaudio.md index e916071c..2c2c8ce9 100644 --- a/backend_coreaudio.md +++ b/backend_coreaudio.md @@ -1,33 +1,65 @@ # CoreAudio (macOS) ## Introduction -CoreAudio is the standard audio API of macOS. -The CoreAudio support of CamillaDSP is provided by [an updated and extended fork](https://github.com/HEnquist/coreaudio-rs) of the [coreaudio-rs library](https://github.com/RustAudio/coreaudio-rs). +CoreAudio is the standard audio API of macOS. +The CoreAudio support of CamillaDSP is provided via the +[coreaudio-rs library](https://github.com/RustAudio/coreaudio-rs). + +CoreAudio is a large API that offers several ways to accomplish most common tasks. +CamillaDSP uses the low-level AudioUnits for playback and capture. +An AudioUnit that represents a hardware device has two stream formats. +One format is used for communicating with the application. +This is typically 32-bit float, the same format that CoreAudio uses internally. +The other format (called the physical format) is the one used to send or receive data to/from the sound card driver. + +## Microphone access +In order to capture audio on macOS, an application needs the be given access. +First time CamillaDSP is launched, the system should show a popup asking if the Terminal app +should be allowed to use the microphone. +This is somewhat misleading, as the microphone access covers all recording of sound, +not only from the microphone. + +Without this access, there is no error message and CamillaDSP appears to be running ok, +but only records silence. +If this happens, open System Settings, select "Privacy & Security", and click "Microphone". +Verify that "Terminal" is listed and enabled. + +There is no way to manually add approved apps to the list. +If "Terminal" is not listed, try executing `tccutil reset Microphone` in the terminal. +This resets the microphone access for all apps, +and should make the popup appear next time CamillaDSP is started. -CoreAudio is a large API that offers several ways to accomplish most common tasks. CamillaDSP uses the low-level AudioUnits for playback and capture. An AudioUnit that represents a hardware device has two stream formats. One format is used for communicating with the application. This is typically 32-bit float, the same format that CoreAudio uses internally. The other format (called the physical format) is the one used to send or receive data to/from the sound card driver. ## Capturing audio from other applications To capture audio from applications a virtual sound card is needed. It is recommended to use [BlackHole](https://github.com/ExistentialAudio/BlackHole). This works on both Intel and Apple Silicon macs. -The latest (currently unreleased) version of BlachHole supports adjusting the rate of the virtual clock. +Since version 0.5.0 Blackhole supports adjusting the rate of the virtual clock. This makes it possible to sync the virtual device with a real device, and avoid the need for asynchronous resampling. CamillaDSP supports and will use this functionality when it is available. An alternative is [Soundflower](https://github.com/mattingalls/Soundflower), which is older and only supports Intel macs. -Some player applications can use hog mode to get exclusive access to the playback device. Using this with a virtual soundcard like BlackHole causes problems, and is therefore not recommended. +Some player applications can use hog mode to get exclusive access to the playback device. +Using this with a virtual soundcard like BlackHole causes problems, and is therefore not recommended. ### Sending all audio to the virtual card -Set the virtual sound card as the default playback device in the Sound preferences. This will work for all applications that respect this setting, which in practice is nearly all. The exceptions are the ones that provide their own way of selecting playback device. +Set the virtual sound card as the default playback device in the Sound preferences. +This will work for all applications that respect this setting, which in practice is nearly all. +The exceptions are the ones that provide their own way of selecting playback device. ### Capturing the audio When applications output their audio to the playback side of the virtual soundcard, then this audio can be captured from the capture side. This is done by giving the virtual soundcard as the capture device in the CamillaDSP configuration. ### Sample rate change notifications -CamillaDSP will listen for notifications from CoreAudio. If the sample rate of the capture device changes, then CoreAudio will stop providing new samples to any client currently capturing from it. To continue from this state, the capture device needs to be closed and reopened. For CamillaDSP this means that the configuration must be reloaded. If the capture device sample rate changes, then CamillaDSP will stop. Reading the "StopReason" via the websocket server tells that this was due to a sample rate change, and give the value for the new sample rate. +CamillaDSP will listen for notifications from CoreAudio. +If the sample rate of the capture device changes, then CoreAudio will stop providing new samples to any client currently capturing from it. +To continue from this state, the capture device needs to be closed and reopened. +For CamillaDSP this means that the configuration must be reloaded. +If the capture device sample rate changes, then CamillaDSP will stop. +Reading the "StopReason" via the websocket server tells that this was due to a sample rate change, and give the value for the new sample rate. ## Configuration of devices @@ -36,38 +68,40 @@ This example configuration will be used to explain the various options specific capture: type: CoreAudio channels: 2 - device: "Soundflower (2ch)" + device: "Soundflower (2ch)" (*) format: S32LE (*) playback: type: CoreAudio channels: 2 - device: "Built-in Output" + device: "Built-in Output" (*) format: S24LE (*) exclusive: false (*) ``` The parameters marked (*) are optional. ### Device names -The device names that are used for `device:` for both playback and capture are entered as shown in the "Audio MIDI Setup" that can be found under "Other" in Launchpad. +The device names that are used for `device` for both playback and capture are entered as shown in the "Audio MIDI Setup" that can be found under "Other" in Launchpad. The name for the 2-channel interface of Soundflower is "Soundflower (2ch)", and the built in audio in a MacBook Pro is called "Built-in Output". -Specifying "default" will give the default capture or playback device. +Specifying `null` or leaving out `device` will give the default capture or playback device. To help with finding the name of playback and capture devices, use the macOS version of "cpal-listdevices" program from here: https://github.com/HEnquist/cpal-listdevices/releases Just download the binary and run it in a terminal. It will list all devices with the names. ### Sample format -CamillaDSP always uses 32-bit float uses when transferring data to and from CoreAudio. The conversion from 32-bit float to the sample format used by the actual DAC (the physical format) is performed by CoreAudio. +CamillaDSP always uses 32-bit float uses when transferring data to and from CoreAudio. +The conversion from 32-bit float to the sample format used by the actual DAC (the physical format) is performed by CoreAudio. The physical format can be set using the "Audio MIDI Setup" app. -The optional `format` parameter determines whether CamillaDSP should change the physical format or not. If a value is given, then CamillaDSP will change the setting to match the selected `format`. -To do this, it fetches a list of the supported stream formats for the device. +The optional `format` parameter determines whether CamillaDSP should change the physical format or not. +If a value is given, then CamillaDSP will change the setting to match the selected `format`. +To do this, it fetches a list of the supported stream formats for the device. It then searches the list until it finds a suitable one. -The criteria is that it must have the right sample rate, the right number of bits, -and the right number type (float or integer). -There exact representation of the given format isn't used. -This means that S24LE and S24LE3 are equivalent, and the "LE" ending that means +The criteria is that it must have the right sample rate, the right number of bits, +and the right number type (float or integer). +There exact representation of the given format isn't used. +This means that S24LE and S24LE3 are equivalent, and the "LE" ending that means little-endian for other backends is ignored. This table shows the mapping between the format setting in "Audio MIDI Setup" and the CamillaDSP `format`: diff --git a/backend_wasapi.md b/backend_wasapi.md index c5c088a1..b3836c9f 100644 --- a/backend_wasapi.md +++ b/backend_wasapi.md @@ -8,7 +8,10 @@ It offers two modes, "shared" and "exclusive", that offer different features and ### Shared mode This is the mode that most applications use. As the name suggests, this mode allows an audio device to be shared by several applications. -In shared mode the audio device then operates at a fixed sample rate and sample format. Every stream sent to it (or recorded from it) is resampled to/from the shared rate and format. The sample rate and output sample format of the device are called the "Default format" of the device and can be set in the Sound control panel. Internally, the Windows audio stack uses 32-bit float as the sample format. +In shared mode the audio device then operates at a fixed sample rate and sample format. +Every stream sent to it (or recorded from it) is resampled to/from the shared rate and format. +The sample rate and output sample format of the device are called the "Default format" of the device and can be set in the Sound control panel. +Internally, the Windows audio stack uses 32-bit float as the sample format. The audio passes through the Windows mixer and volume control. In shared mode, these points apply for the CamillaDSP configuration: @@ -23,7 +26,10 @@ In shared mode, these points apply for the CamillaDSP configuration: ### Exclusive mode This mode is often used for high quality music playback. -In this mode one application takes full control over an audio device. Only one application at a time can use the device. The sample rate and sample format can be changed, and the audio does not pass through the Windows mixer and volume control. This allows bit-perfect playback at any sample rate and sample format the hardware supports. While an application holds the device in exclusive mode, other apps will not be able to play for example notification sounds. +In this mode one application takes full control over an audio device. Only one application at a time can use the device. +The sample rate and sample format can be changed, and the audio does not pass through the Windows mixer and volume control. +This allows bit-perfect playback at any sample rate and sample format the hardware supports. +While an application holds the device in exclusive mode, other apps will not be able to play for example notification sounds. In exclusive mode, these points apply for the CamillaDSP configuration: - CamillaDSP is able to control the sample rate of the devices. @@ -41,22 +47,31 @@ CamillaDSP must capture audio from a capture device. This can either be a virtua ### Virtual sound card -When using a virtual sound card (sometimes called loopback device), all applications output their audio to the playback side of this virtual sound card. Then this audio signal can be captured from the capture side of the virtual card. [VB-CABLE from VB-AUDIO](https://www.vb-audio.com/Cable/) works well. +When using a virtual sound card (sometimes called loopback device), all applications output their audio to the playback side of this virtual sound card. +Then this audio signal can be captured from the capture side of the virtual card. [VB-CABLE from VB-AUDIO](https://www.vb-audio.com/Cable/) works well. #### Sending all audio to the virtual card -Set VB-CABLE as the default playback device in the Windows sound control panel. Open "Sound" in the Control Panel, then in the "Playback" tab select "CABLE Input" and click the "Set Default" button. This will work for all applications that respect this setting, which in practice is nearly all. The exceptions are the ones that provide their own way of selecting playback device. +Set VB-CABLE as the default playback device in the Windows sound control panel. +Open "Sound" in the Control Panel, then in the "Playback" tab select "CABLE Input" and click the "Set Default" button. +This will work for all applications that respect this setting, which in practice is nearly all. +The exceptions are the ones that provide their own way of selecting playback device. #### Capturing the audio The next step is to figure out the device name to enter in the CamillaDSP configuration. -Again open "Sound" in the Control Panel, and switch to the Recording tab. There should be a device listed as "CABLE Output". Unless the default names have been changed, the device name to enter in the CamillaDSP config is "CABLE Output (VB-Audio Virtual Cable)". +Again open "Sound" in the Control Panel, and switch to the Recording tab. +There should be a device listed as "CABLE Output". +Unless the default names have been changed, the device name to enter in the CamillaDSP config is "CABLE Output (VB-Audio Virtual Cable)". See also [Device names](#device-names) for more details on how to build the device names. ### Loopback capture -In loopback mode the audio is captured from a Playback device. This allows capturing the sound that a card is playing. In this mode, a spare unused sound card is used (note that this card can be either real or virtual). -The built in audio of the computer should work. The quality of the card doesn't matter, +In loopback mode the audio is captured from a Playback device. +This allows capturing the sound that a card is playing. +In this mode, a spare unused sound card is used (note that this card can be either real or virtual). +The built in audio of the computer should work. The quality of the card doesn't matter, since the audio data will not be routed through it. This requires using [Shared mode](#shared-mode). -Open the Sound Control Panel app, and locate the unused card in the "Playback" tab. Set it as default device. See [Device names](#device-names) for how to write the device name to enter in the CamillaDSP configuration. +Open the Sound Control Panel app, and locate the unused card in the "Playback" tab. +Set it as default device. See [Device names](#device-names) for how to write the device name to enter in the CamillaDSP configuration. ## Configuration of devices @@ -65,29 +80,41 @@ This example configuration will be used to explain the various options specific capture: type: Wasapi channels: 2 - device: "CABLE Output (VB-Audio Virtual Cable)" + device: "CABLE Output (VB-Audio Virtual Cable)" (*) format: FLOAT32LE exclusive: false (*) loopback: false (*) playback: type: Wasapi channels: 2 - device: "SPDIF Interface (FX-AUDIO-DAC-X6)" + device: "SPDIF Interface (FX-AUDIO-DAC-X6)" (*) format: S24LE3 exclusive: true (*) ``` +The parameters marked (*) are optional. ### Device names -The device names that are used for `device:` for both playback and capture are entered as shown in the Windows volume control. Click the speaker icon in the notification area, and then click the small up-arrow in the upper right corner of the volume control pop-up. This displays a list of all playback devices, with their names in the right format. The names can also be seen in the "Sound" control panel app. Look at either the "Playback" or "Recording" tab. The device name is built from the input/output name and card name, and the format is "{input/output name} ({card name})". For example, the VB-CABLE device name is "CABLE Output (VB-Audio Virtual Cable)", and the built in audio of a desktop computer can be "Speakers (Realtek(R) Audio)". +The device names that are used for `device` for both playback and capture are entered as shown in the Windows volume control. +Click the speaker icon in the notification area, and then click the small up-arrow in the upper right corner of the volume control pop-up. +This displays a list of all playback devices, with their names in the right format. +The names can also be seen in the "Sound" control panel app. Look at either the "Playback" or "Recording" tab. +The device name is built from the input/output name and card name, and the format is "{input/output name} ({card name})". +For example, the VB-CABLE device name is "CABLE Output (VB-Audio Virtual Cable)", +and the built in audio of a desktop computer can be "Speakers (Realtek(R) Audio)". -Specifying "default" will give the default capture or playback device. +Specifying `null` or leaving out `device` will give the default capture or playback device. To help with finding the name of playback and capture devices, use the Windows version of "cpal-listdevices" program from here: https://github.com/HEnquist/cpal-listdevices/releases -Just download the binary and run it in a terminal. It will list all devices with the names. The parameters shown are for shared mode, more sample rates and sample formats will likely be available in exclusive mode. +Just download the binary and run it in a terminal. It will list all devices with the names. +The parameters shown are for shared mode, more sample rates and sample formats will likely be available in exclusive mode. ### Shared or exclusive mode -Set `exclusive` to `true` to enable exclusive mode. Setting it to `false` or leaving it out means that shared mode will be used. Playback and capture are independent, they do not need to use the same mode. +Set `exclusive` to `true` to enable exclusive mode. +Setting it to `false` or leaving it out means that shared mode will be used. +Playback and capture are independent, they do not need to use the same mode. ### Loopback capture -Setting `loopback` to `true` enables loopback capture. This requires using shared mode for the capture device. See [Loopback capture](#loopback-capture) for more details. \ No newline at end of file +Setting `loopback` to `true` enables loopback capture. +This requires using shared mode for the capture device. +See [Loopback capture](#loopback-capture) for more details. \ No newline at end of file diff --git a/exampleconfigs/all_biquads.yml b/exampleconfigs/all_biquads.yml index 5da91e51..053802c8 100644 --- a/exampleconfigs/all_biquads.yml +++ b/exampleconfigs/all_biquads.yml @@ -3,7 +3,7 @@ devices: samplerate: 44100 chunksize: 1024 capture: - type: File + type: RawFile filename: "dummy" channels: 2 format: S16LE diff --git a/exampleconfigs/brokenconfig.yml b/exampleconfigs/brokenconfig.yml index 222368c2..e5e69be2 100644 --- a/exampleconfigs/brokenconfig.yml +++ b/exampleconfigs/brokenconfig.yml @@ -3,7 +3,7 @@ devices: samplerate: 44100 chunksize: 1024 capture: - type: File + type: RawFile filename: "dummy" channels: 2 format: S16LE @@ -48,12 +48,9 @@ pipeline: - type: Mixer name: mono - type: Filter - channel: 0 - names: - - lp1 - - type: Filter - channel: 1 + channels: [0, 1] names: - lp1 + diff --git a/exampleconfigs/ditherplay.yml b/exampleconfigs/ditherplay.yml index 12b176da..71d77398 100644 --- a/exampleconfigs/ditherplay.yml +++ b/exampleconfigs/ditherplay.yml @@ -3,7 +3,7 @@ devices: samplerate: 44100 chunksize: 4096 capture: - type: File + type: RawFile filename: "dummy" channels: 2 format: S16LE @@ -27,13 +27,13 @@ filters: dithereven: type: Dither parameters: - type: Uniform + type: Flat bits: 8 amplitude: 1.0 dithersimple: type: Dither parameters: - type: Simple + type: Highpass bits: 8 ditherfancy: type: Dither @@ -53,14 +53,9 @@ filters: pipeline: - type: Filter - channel: 0 - names: - - atten - - ditherfancy2 - - type: Filter - channel: 1 + channels: [0, 1] names: - atten - ditherfancy2 - + diff --git a/exampleconfigs/file_pb.yml b/exampleconfigs/file_pb.yml index cd43a66d..296505c7 100644 --- a/exampleconfigs/file_pb.yml +++ b/exampleconfigs/file_pb.yml @@ -5,7 +5,7 @@ devices: silence_threshold: -60 silence_timeout: 3.0 capture: - type: File + type: RawFile channels: 2 filename: "/home/henrik/test.raw" format: S16LE diff --git a/exampleconfigs/gainconfig.yml b/exampleconfigs/gainconfig.yml index ff4ec95c..30670079 100644 --- a/exampleconfigs/gainconfig.yml +++ b/exampleconfigs/gainconfig.yml @@ -3,7 +3,7 @@ devices: samplerate: 44100 chunksize: 1024 capture: - type: File + type: RawFile filename: "dummy" channels: 2 format: S16LE @@ -46,7 +46,7 @@ pipeline: - type: Mixer name: mono - type: Filter - channel: 0 + channels: [0, 1] names: - delay1 diff --git a/exampleconfigs/lf_compressor.yml b/exampleconfigs/lf_compressor.yml index d47170bc..c5adce0d 100644 --- a/exampleconfigs/lf_compressor.yml +++ b/exampleconfigs/lf_compressor.yml @@ -4,7 +4,7 @@ devices: chunksize: 4096 resampler: null capture: - type: File + type: RawFile filename: "dummy" channels: 2 format: S16LE @@ -94,19 +94,11 @@ pipeline: - type: Mixer name: to_four - type: Filter - channel: 0 - names: - - highpass - - type: Filter - channel: 1 + channels: [0, 1] names: - highpass - type: Filter - channel: 2 - names: - - lowpass - - type: Filter - channel: 3 + channels: [2, 3] names: - lowpass - type: Processor diff --git a/exampleconfigs/nofilters.yml b/exampleconfigs/nofilters.yml index 5532ba75..3ebdd377 100644 --- a/exampleconfigs/nofilters.yml +++ b/exampleconfigs/nofilters.yml @@ -3,7 +3,7 @@ devices: samplerate: 44100 chunksize: 1024 capture: - type: File + type: RawFile filename: "dummy" channels: 2 format: S16LE diff --git a/exampleconfigs/nomixers.yml b/exampleconfigs/nomixers.yml index 549478f3..3aa1af2d 100644 --- a/exampleconfigs/nomixers.yml +++ b/exampleconfigs/nomixers.yml @@ -3,7 +3,7 @@ devices: samplerate: 44100 chunksize: 1024 capture: - type: File + type: RawFile filename: "dummy" channels: 2 format: S16LE @@ -22,11 +22,7 @@ filters: pipeline: - type: Filter - channel: 0 - names: - - lowpass_fir - - type: Filter - channel: 1 + channels: [0, 1] names: - lowpass_fir diff --git a/exampleconfigs/pulseconfig.yml b/exampleconfigs/pulseconfig.yml index ac22294b..10fae712 100644 --- a/exampleconfigs/pulseconfig.yml +++ b/exampleconfigs/pulseconfig.yml @@ -58,14 +58,9 @@ mixers: pipeline: - type: Filter - channel: 0 + channels: [0, 1] names: - atten - lowpass_fir - - type: Filter - channel: 1 - names: - - atten - - lowpass_fir - + diff --git a/exampleconfigs/resample_file.yml b/exampleconfigs/resample_file.yml index 6c3a4a25..576dcfdb 100644 --- a/exampleconfigs/resample_file.yml +++ b/exampleconfigs/resample_file.yml @@ -12,7 +12,7 @@ devices: filename: "result_f64.raw" format: FLOAT64LE capture: - type: File + type: RawFile channels: 2 filename: "sine_120_44100_f64_2ch.raw" format: FLOAT64LE diff --git a/exampleconfigs/simpleconfig.yml b/exampleconfigs/simpleconfig.yml index 7e3b0c49..d9a2acd1 100644 --- a/exampleconfigs/simpleconfig.yml +++ b/exampleconfigs/simpleconfig.yml @@ -49,12 +49,8 @@ pipeline: - type: Mixer name: monomix - type: Filter - channel: 0 + channels: [0, 1] names: - lowpass_fir - - type: Filter - channel: 1 - names: - - lowpass_fir - + diff --git a/exampleconfigs/simpleconfig_plot.yml b/exampleconfigs/simpleconfig_plot.yml index 7510399b..fe6ac3f6 100644 --- a/exampleconfigs/simpleconfig_plot.yml +++ b/exampleconfigs/simpleconfig_plot.yml @@ -3,7 +3,7 @@ devices: samplerate: 44100 chunksize: 1024 capture: - type: File + type: RawFile filename: "dummy" channels: 2 format: S16LE @@ -62,22 +62,13 @@ pipeline: - type: Mixer name: mono - type: Filter - channel: 0 - names: - - lowpass_fir - - peak1 - - type: Filter - channel: 1 + channels: [0, 1] names: - lowpass_fir - peak1 - type: Filter - channel: 2 + channels: [2, 3] names: - highpass_fir - - type: Filter - channel: 3 - names: - - highpass_fir - + diff --git a/exampleconfigs/simpleconfig_resample.yml b/exampleconfigs/simpleconfig_resample.yml index 57a5196f..8270a4b7 100644 --- a/exampleconfigs/simpleconfig_resample.yml +++ b/exampleconfigs/simpleconfig_resample.yml @@ -7,7 +7,7 @@ devices: profile: Balanced capture_samplerate: 44100 capture: - type: File + type: RawFile filename: "dummy" channels: 2 format: S16LE diff --git a/exampleconfigs/tokens.yml b/exampleconfigs/tokens.yml index ae47c61e..e92b5f4d 100644 --- a/exampleconfigs/tokens.yml +++ b/exampleconfigs/tokens.yml @@ -3,7 +3,7 @@ devices: samplerate: 44100 chunksize: 1024 capture: - type: File + type: RawFile filename: "dummy" channels: 2 format: S16LE @@ -34,13 +34,9 @@ filters: pipeline: - type: Filter - channel: 0 + channels: [0, 1] names: - demofilter - filter$samplerate$ - - type: Filter - channel: 1 - names: - - demofilter - + diff --git a/pipeline.png b/pipeline.png deleted file mode 100644 index 6709dec1..00000000 Binary files a/pipeline.png and /dev/null differ diff --git a/show_config.py b/show_config.py deleted file mode 100644 index 73dbdc3f..00000000 --- a/show_config.py +++ /dev/null @@ -1,564 +0,0 @@ -# show_config.py - -import numpy as np -import numpy.fft as fft -import csv -import yaml -import sys -from matplotlib import pyplot as plt -from matplotlib.patches import Rectangle -import math - -class Conv(object): - def __init__(self, conf, fs): - if not conf: - conf = {values: [1.0]} - if 'filename' in conf: - fname = conf['filename'] - values = [] - if 'format' not in conf: - conf['format'] = "text" - if conf['format'] == "text": - with open(fname) as f: - values = [float(row[0]) for row in csv.reader(f)] - elif conf['format'] == "FLOAT64LE": - values = np.fromfile(fname, dtype=float) - elif conf['format'] == "FLOAT32LE": - values = np.fromfile(fname, dtype=np.float32) - elif conf['format'] == "S16LE": - values = np.fromfile(fname, dtype=np.int16)/(2**15-1) - elif conf['format'] == "S24LE": - values = np.fromfile(fname, dtype=np.int32)/(2**23-1) - elif conf['format'] == "S32LE": - values = np.fromfile(fname, dtype=np.int32)/(2**31-1) - else: - values = conf['values'] - self.impulse = values - self.fs = fs - - def gain_and_phase(self): - impulselen = len(self.impulse) - npoints = impulselen - if npoints < 300: - npoints = 300 - impulse = np.zeros(npoints*2) - impulse[0:impulselen] = self.impulse - impfft = fft.fft(impulse) - cut = impfft[0:npoints] - f = np.linspace(0, self.fs/2.0, npoints) - gain = 20*np.log10(np.abs(cut)) - phase = 180/np.pi*np.angle(cut) - return f, gain, phase - - def get_impulse(self): - t = np.linspace(0, len(self.impulse)/self.fs, len(self.impulse), endpoint=False) - return t, self.impulse - -class DiffEq(object): - def __init__(self, conf, fs): - self.fs = fs - self.a = conf['a'] - self.b = conf['b'] - if len(self.a)==0: - self.a=[1.0] - if len(self.b)==0: - self.b=[1.0] - - def gain_and_phase(self, f): - z = np.exp(1j*2*np.pi*f/self.fs); - A1=np.zeros(z.shape) - for n, bn in enumerate(self.b): - A1 = A1 + bn*z**(-n) - A2=np.zeros(z.shape) - for n, an in enumerate(self.a): - A2 = A2 + an*z**(-n) - A = A1/A2 - gain = 20*np.log10(np.abs(A)) - phase = 180/np.pi*np.angle(A) - return gain, phase - - def is_stable(self): - # TODO - return None - -class BiquadCombo(object): - - def Butterw_q(self, order): - odd = order%2 > 0 - n_so = math.floor(order/2.0) - qvalues = [] - for n in range(0, n_so): - q = 1/(2.0*math.sin((math.pi/order)*(n + 1/2))) - qvalues.append(q) - if odd: - qvalues.append(-1.0) - return qvalues - - def __init__(self, conf, fs): - self.ftype = conf['type'] - self.order = conf['order'] - self.freq = conf['freq'] - self.fs = fs - if self.ftype == "LinkwitzRileyHighpass": - #qvalues = self.LRtable[self.order] - q_temp = self.Butterw_q(self.order/2) - if (self.order/2)%2 > 0: - q_temp = q_temp[0:-1] - qvalues = q_temp + q_temp + [0.5] - else: - qvalues = q_temp + q_temp - type_so = "Highpass" - type_fo = "HighpassFO" - - elif self.ftype == "LinkwitzRileyLowpass": - q_temp = self.Butterw_q(self.order/2) - if (self.order/2)%2 > 0: - q_temp = q_temp[0:-1] - qvalues = q_temp + q_temp + [0.5] - else: - qvalues = q_temp + q_temp - type_so = "Lowpass" - type_fo = "LowpassFO" - elif self.ftype == "ButterworthHighpass": - qvalues = self.Butterw_q(self.order) - type_so = "Highpass" - type_fo = "HighpassFO" - elif self.ftype == "ButterworthLowpass": - qvalues = self.Butterw_q(self.order) - type_so = "Lowpass" - type_fo = "LowpassFO" - self.biquads = [] - print(qvalues) - for q in qvalues: - if q >= 0: - bqconf = {'freq': self.freq, 'q': q, 'type': type_so} - else: - bqconf = {'freq': self.freq, 'type': type_fo} - self.biquads.append(Biquad(bqconf, self.fs)) - - def is_stable(self): - # TODO - return None - - def gain_and_phase(self, f): - A = np.ones(f.shape) - for bq in self.biquads: - A = A * bq.complex_gain(f) - gain = 20*np.log10(np.abs(A)) - phase = 180/np.pi*np.angle(A) - return gain, phase - -class Biquad(object): - def __init__(self, conf, fs): - ftype = conf['type'] - if ftype == "Free": - a0 = 1.0 - a1 = conf['a1'] - a2 = conf['a1'] - b0 = conf['b0'] - b1 = conf['b1'] - b2 = conf['b2'] - if ftype == "Highpass": - freq = conf['freq'] - q = conf['q'] - omega = 2.0 * np.pi * freq / fs - sn = np.sin(omega) - cs = np.cos(omega) - alpha = sn / (2.0 * q) - b0 = (1.0 + cs) / 2.0 - b1 = -(1.0 + cs) - b2 = (1.0 + cs) / 2.0 - a0 = 1.0 + alpha - a1 = -2.0 * cs - a2 = 1.0 - alpha - elif ftype == "Lowpass": - freq = conf['freq'] - q = conf['q'] - omega = 2.0 * np.pi * freq / fs - sn = np.sin(omega) - cs = np.cos(omega) - alpha = sn / (2.0 * q) - b0 = (1.0 - cs) / 2.0 - b1 = 1.0 - cs - b2 = (1.0 - cs) / 2.0 - a0 = 1.0 + alpha - a1 = -2.0 * cs - a2 = 1.0 - alpha - elif ftype == "Peaking": - freq = conf['freq'] - q = conf['q'] - gain = conf['gain'] - omega = 2.0 * np.pi * freq / fs - sn = np.sin(omega) - cs = np.cos(omega) - ampl = 10.0**(gain / 40.0) - alpha = sn / (2.0 * q) - b0 = 1.0 + (alpha * ampl) - b1 = -2.0 * cs - b2 = 1.0 - (alpha * ampl) - a0 = 1.0 + (alpha / ampl) - a1 = -2.0 * cs - a2 = 1.0 - (alpha / ampl) - elif ftype == "HighshelfFO": - freq = conf['freq'] - gain = conf['gain'] - omega = 2.0 * np.pi * freq / fs - ampl = 10.0**(gain / 40.0) - tn = np.tan(omega/2) - b0 = ampl*tn + ampl**2 - b1 = ampl*tn - ampl**2 - b2 = 0.0 - a0 = ampl*tn + 1 - a1 = ampl*tn - 1 - a2 = 0.0 - elif ftype == "Highshelf": - freq = conf['freq'] - slope = conf['slope'] - gain = conf['gain'] - omega = 2.0 * np.pi * freq / fs - ampl = 10.0**(gain / 40.0) - sn = np.sin(omega) - cs = np.cos(omega) - alpha = sn / 2.0 * np.sqrt((ampl + 1.0 / ampl) * (1.0 / (slope/12.0) - 1.0) + 2.0) - beta = 2.0 * np.sqrt(ampl) * alpha - b0 = ampl * ((ampl + 1.0) + (ampl - 1.0) * cs + beta) - b1 = -2.0 * ampl * ((ampl - 1.0) + (ampl + 1.0) * cs) - b2 = ampl * ((ampl + 1.0) + (ampl - 1.0) * cs - beta) - a0 = (ampl + 1.0) - (ampl - 1.0) * cs + beta - a1 = 2.0 * ((ampl - 1.0) - (ampl + 1.0) * cs) - a2 = (ampl + 1.0) - (ampl - 1.0) * cs - beta - elif ftype == "LowshelfFO": - freq = conf['freq'] - gain = conf['gain'] - omega = 2.0 * np.pi * freq / fs - ampl = 10.0**(gain / 40.0) - tn = np.tan(omega/2) - b0 = ampl**2*tn + ampl - b1 = ampl**2*tn - ampl - b2 = 0.0 - a0 = tn + ampl - a1 = tn - ampl - a2 = 0.0 - elif ftype == "Lowshelf": - freq = conf['freq'] - slope = conf['slope'] - gain = conf['gain'] - omega = 2.0 * np.pi * freq / fs - ampl = 10.0**(gain / 40.0) - sn = np.sin(omega) - cs = np.cos(omega) - alpha = sn / 2.0 * np.sqrt((ampl + 1.0 / ampl) * (1.0 / (slope/12.0) - 1.0) + 2.0) - beta = 2.0 * np.sqrt(ampl) * alpha - b0 = ampl * ((ampl + 1.0) - (ampl - 1.0) * cs + beta) - b1 = 2.0 * ampl * ((ampl - 1.0) - (ampl + 1.0) * cs) - b2 = ampl * ((ampl + 1.0) - (ampl - 1.0) * cs - beta) - a0 = (ampl + 1.0) + (ampl - 1.0) * cs + beta - a1 = -2.0 * ((ampl - 1.0) + (ampl + 1.0) * cs) - a2 = (ampl + 1.0) + (ampl - 1.0) * cs - beta - elif ftype == "LowpassFO": - freq = conf['freq'] - omega = 2.0 * np.pi * freq / fs - k = np.tan(omega/2.0) - alpha = 1 + k - a0 = 1.0 - a1 = -((1 - k)/alpha) - a2 = 0.0 - b0 = k/alpha - b1 = k/alpha - b2 = 0 - elif ftype == "HighpassFO": - freq = conf['freq'] - omega = 2.0 * np.pi * freq / fs - k = np.tan(omega/2.0) - alpha = 1 + k - a0 = 1.0 - a1 = -((1 - k)/alpha) - a2 = 0.0 - b0 = 1.0/alpha - b1 = -1.0/alpha - b2 = 0 - elif ftype == "Notch": - freq = conf['freq'] - q = conf['q'] - omega = 2.0 * np.pi * freq / fs - sn = np.sin(omega) - cs = np.cos(omega) - alpha = sn / (2.0 * q) - b0 = 1.0 - b1 = -2.0 * cs - b2 = 1.0 - a0 = 1.0 + alpha - a1 = -2.0 * cs - a2 = 1.0 - alpha - elif ftype == "Bandpass": - freq = conf['freq'] - q = conf['q'] - omega = 2.0 * np.pi * freq / fs - sn = np.sin(omega) - cs = np.cos(omega) - alpha = sn / (2.0 * q) - b0 = alpha - b1 = 0.0 - b2 = -alpha - a0 = 1.0 + alpha - a1 = -2.0 * cs - a2 = 1.0 - alpha - elif ftype == "Allpass": - freq = conf['freq'] - q = conf['q'] - omega = 2.0 * np.pi * freq / fs - sn = np.sin(omega) - cs = np.cos(omega) - alpha = sn / (2.0 * q) - b0 = 1.0 - alpha - b1 = -2.0 * cs - b2 = 1.0 + alpha - a0 = 1.0 + alpha - a1 = -2.0 * cs - a2 = 1.0 - alpha - elif ftype == "AllpassFO": - freq = conf['freq'] - omega = 2.0 * np.pi * freq / fs - tn = np.tan(omega/2.0) - alpha = (tn + 1.0)/(tn - 1.0) - b0 = 1.0 - b1 = alpha - b2 = 0.0 - a0 = alpha - a1 = 1.0 - a2 = 0.0 - elif ftype == "LinkwitzTransform": - f0 = conf['freq_act'] - q0 = conf['q_act'] - qt = conf['q_target'] - ft = conf['freq_target'] - - d0i = (2.0 * np.pi * f0)**2 - d1i = (2.0 * np.pi * f0)/q0 - c0i = (2.0 * np.pi * ft)**2 - c1i = (2.0 * np.pi * ft)/qt - fc = (ft+f0)/2.0 - - gn = 2 * np.pi * fc/math.tan(np.pi*fc/fs) - cci = c0i + gn * c1i + gn**2 - - b0 = (d0i+gn*d1i + gn**2)/cci - b1 = 2*(d0i-gn**2)/cci - b2 = (d0i - gn*d1i + gn**2)/cci - a0 = 1.0 - a1 = 2.0 * (c0i-gn**2)/cci - a2 = ((c0i-gn*c1i + gn**2)/cci) - - - self.fs = fs - self.a1 = a1 / a0 - self.a2 = a2 / a0 - self.b0 = b0 / a0 - self.b1 = b1 / a0 - self.b2 = b2 / a0 - - def complex_gain(self, f): - z = np.exp(1j*2*np.pi*f/self.fs); - A = (self.b0 + self.b1*z**(-1) + self.b2*z**(-2))/(1.0 + self.a1*z**(-1) + self.a2*z**(-2)) - return A - - def gain_and_phase(self, f): - A = self.complex_gain(f) - gain = 20*np.log10(np.abs(A)) - phase = 180/np.pi*np.angle(A) - return gain, phase - - def is_stable(self): - return abs(self.a2)<1.0 and abs(self.a1) < (self.a2+1.0) - -class Block(object): - def __init__(self, label): - self.label = label - self.x = None - self.y = None - - def place(self, x, y): - self.x = x - self.y = y - - def draw(self, ax): - rect = Rectangle((self.x-0.5, self.y-0.25), 1.0, 0.5, linewidth=1,edgecolor='r',facecolor='none') - ax.add_patch(rect) - ax.text(self.x, self.y, self.label, horizontalalignment='center', verticalalignment='center') - - - def input_point(self): - return self.x-0.5, self.y - - def output_point(self): - return self.x+0.5, self.y - -def draw_arrow(ax, p0, p1, label=None): - x0, y0 = p0 - x1, y1 = p1 - ax.arrow(x0, y0, x1-x0, y1-y0, width=0.01, length_includes_head=True, head_width=0.1) - if y1 > y0: - hal = 'right' - val = 'bottom' - else: - hal = 'right' - val = 'top' - if label is not None: - ax.text(x0+(x1-x0)*2/3, y0+(y1-y0)*2/3, label, horizontalalignment=hal, verticalalignment=val) - -def draw_box(ax, level, size, label=None): - x0 = 2*level-0.75 - y0 = -size/2 - rect = Rectangle((x0, y0), 1.5, size, linewidth=1,edgecolor='g',facecolor='none', linestyle='--') - ax.add_patch(rect) - if label is not None: - ax.text(2*level, size/2, label, horizontalalignment='center', verticalalignment='bottom') - -def main(): - print('This script is deprecated. Please use the "plotcamillaconf" tool\nfrom the pycamilladsp-plot library instead.') - fname = sys.argv[1] - - conffile = open(fname) - - conf = yaml.safe_load(conffile) - print(conf) - - srate = conf['devices']['samplerate'] - #if "chunksize" in conf['devices']: - # buflen = conf['devices']['chunksize'] - #else: - # buflen = conf['devices']['buffersize'] - #print (srate) - fignbr = 1 - - if 'filters' in conf: - fvect = np.linspace(1, (srate*0.95)/2.0, 10000) - for filter, fconf in conf['filters'].items(): - if fconf['type'] in ('Biquad', 'DiffEq', 'BiquadCombo'): - if fconf['type'] == 'DiffEq': - kladd = DiffEq(fconf['parameters'], srate) - elif fconf['type'] == 'BiquadCombo': - kladd = BiquadCombo(fconf['parameters'], srate) - else: - kladd = Biquad(fconf['parameters'], srate) - plt.figure(num=filter) - magn, phase = kladd.gain_and_phase(fvect) - stable = kladd.is_stable() - plt.subplot(2,1,1) - plt.semilogx(fvect, magn) - plt.title("{}, stable: {}\nMagnitude".format(filter, stable)) - plt.subplot(2,1,2) - plt.semilogx(fvect, phase) - plt.title("Phase") - fignbr += 1 - elif fconf['type'] == 'Conv': - if 'parameters' in fconf: - kladd = Conv(fconf['parameters'], srate) - else: - kladd = Conv(None, srate) - plt.figure(num=filter) - ftemp, magn, phase = kladd.gain_and_phase() - plt.subplot(2,1,1) - plt.semilogx(ftemp, magn) - plt.title("FFT of {}".format(filter)) - plt.gca().set(xlim=(10, srate/2.0)) - #fignbr += 1 - #plt.figure(fignbr) - t, imp = kladd.get_impulse() - plt.subplot(2,1,2) - plt.plot(t, imp) - plt.title("Impulse response of {}".format(filter)) - fignbr += 1 - - stages = [] - fig = plt.figure(fignbr) - - ax = fig.add_subplot(111, aspect='equal') - # add input - channels = [] - active_channels = int(conf['devices']['capture']['channels']) - for n in range(active_channels): - label = "ch {}".format(n) - b = Block(label) - b.place(0, -active_channels/2 + 0.5 + n) - b.draw(ax) - channels.append([b]) - if 'device' in conf['devices']['capture']: - capturename = conf['devices']['capture']['device'] - else: - capturename = conf['devices']['capture']['filename'] - draw_box(ax, 0, active_channels, label=capturename) - stages.append(channels) - - # loop through pipeline - - total_length = 0 - stage_start = 0 - if 'pipeline' in conf: - for step in conf['pipeline']: - stage = len(stages) - if step['type'] == 'Mixer': - total_length += 1 - name = step['name'] - mixconf = conf['mixers'][name] - active_channels = int(mixconf['channels']['out']) - channels = [[]]*active_channels - for n in range(active_channels): - label = "ch {}".format(n) - b = Block(label) - b.place(total_length*2, -active_channels/2 + 0.5 + n) - b.draw(ax) - channels[n] = [b] - for mapping in mixconf['mapping']: - dest_ch = int(mapping['dest']) - for src in mapping['sources']: - src_ch = int(src['channel']) - label = "{} dB".format(src['gain']) - if src['inverted'] == 'False': - label = label + '\ninv.' - src_p = stages[-1][src_ch][-1].output_point() - dest_p = channels[dest_ch][0].input_point() - draw_arrow(ax, src_p, dest_p, label=label) - draw_box(ax, total_length, active_channels, label=name) - stages.append(channels) - stage_start = total_length - elif step['type'] == 'Filter': - ch_nbr = step['channel'] - for name in step['names']: - b = Block(name) - ch_step = stage_start + len(stages[-1][ch_nbr]) - total_length = max((total_length, ch_step)) - b.place(ch_step*2, -active_channels/2 + 0.5 + ch_nbr) - b.draw(ax) - src_p = stages[-1][ch_nbr][-1].output_point() - dest_p = b.input_point() - draw_arrow(ax, src_p, dest_p) - stages[-1][ch_nbr].append(b) - - - total_length += 1 - channels = [] - for n in range(active_channels): - label = "ch {}".format(n) - b = Block(label) - b.place(2*total_length, -active_channels/2 + 0.5 + n) - b.draw(ax) - src_p = stages[-1][n][-1].output_point() - dest_p = b.input_point() - draw_arrow(ax, src_p, dest_p) - channels.append([b]) - if 'device' in conf['devices']['playback']: - playname = conf['devices']['playback']['device'] - else: - playname = conf['devices']['playback']['filename'] - draw_box(ax, total_length, active_channels, label=playname) - stages.append(channels) - - nbr_chan = [len(s) for s in stages] - ylim = math.ceil(max(nbr_chan)/2.0) + 0.5 - ax.set(xlim=(-1, 2*total_length+1), ylim=(-ylim, ylim)) - plt.axis('off') - plt.show() - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/src/alsadevice.rs b/src/alsadevice.rs index 8456fbad..bf30dd11 100644 --- a/src/alsadevice.rs +++ b/src/alsadevice.rs @@ -1,39 +1,44 @@ extern crate alsa; extern crate nix; use crate::audiodevice::*; -use crate::config; -use crate::config::SampleFormat; +use crate::config::{Resampler, SampleFormat}; use crate::conversions::{buffer_to_chunk_rawbytes, chunk_to_buffer_rawbytes}; use crate::countertimer; -use alsa::ctl::{ElemId, ElemIface}; -use alsa::ctl::{ElemType, ElemValue}; +use alsa::ctl::{Ctl, ElemId, ElemIface, ElemType, ElemValue}; use alsa::hctl::{Elem, HCtl}; use alsa::pcm::{Access, Format, Frames, HwParams}; +use alsa::poll::Descriptors; use alsa::{Direction, ValueOr, PCM}; use alsa_sys; +use nix::errno::Errno; use parking_lot::{Mutex, RwLock, RwLockUpgradableReadGuard}; use rubato::VecResampler; use std::ffi::CString; use std::fmt::Debug; -use std::sync::mpsc; -use std::sync::mpsc::Receiver; -use std::sync::{Arc, Barrier}; +use std::sync::{mpsc, Arc, Barrier}; use std::thread; use std::time::Instant; +use audio_thread_priority::{ + demote_current_thread_from_real_time, promote_current_thread_to_real_time, +}; + use crate::alsadevice_buffermanager::{ CaptureBufferManager, DeviceBufferManager, PlaybackBufferManager, }; use crate::alsadevice_utils::{ - adjust_speed, list_channels_as_text, list_device_names, list_formats_as_text, - list_samplerates_as_text, state_desc, + find_elem, list_channels_as_text, list_device_names, list_formats_as_text, + list_samplerates_as_text, pick_preferred_format, process_events, state_desc, + sync_linked_controls, CaptureElements, CaptureParams, CaptureResult, ElemData, FileDescriptors, + PlaybackParams, }; +use crate::helpers::PIRateController; use crate::CommandMessage; use crate::PrcFmt; use crate::ProcessingState; use crate::Res; use crate::StatusMessage; -use crate::{CaptureStatus, PlaybackStatus}; +use crate::{CaptureStatus, PlaybackStatus, ProcessingParameters}; lazy_static! { static ref ALSA_MUTEX: Mutex<()> = Mutex::new(()); @@ -44,7 +49,7 @@ pub struct AlsaPlaybackDevice { pub samplerate: usize, pub chunksize: usize, pub channels: usize, - pub sample_format: SampleFormat, + pub sample_format: Option, pub target_level: usize, pub adjust_period: f32, pub enable_rate_adjust: bool, @@ -54,14 +59,17 @@ pub struct AlsaCaptureDevice { pub devname: String, pub samplerate: usize, pub capture_samplerate: usize, - pub resampler_config: Option, + pub resampler_config: Option, pub chunksize: usize, pub channels: usize, - pub sample_format: SampleFormat, + pub sample_format: Option, pub silence_threshold: PrcFmt, pub silence_timeout: PrcFmt, pub stop_on_rate_change: bool, pub rate_measure_interval: f32, + pub stop_on_inactive: bool, + pub link_volume_control: Option, + pub link_mute_control: Option, } struct CaptureChannels { @@ -75,39 +83,6 @@ struct PlaybackChannels { status: crossbeam_channel::Sender, } -struct CaptureParams { - channels: usize, - sample_format: SampleFormat, - silence_timeout: PrcFmt, - silence_threshold: PrcFmt, - chunksize: usize, - store_bytes_per_sample: usize, - bytes_per_frame: usize, - samplerate: usize, - capture_samplerate: usize, - async_src: bool, - capture_status: Arc>, - stop_on_rate_change: bool, - rate_measure_interval: f32, -} - -struct PlaybackParams { - channels: usize, - target_level: usize, - adjust_period: f32, - adjust_enabled: bool, - sample_format: SampleFormat, - playback_status: Arc>, - bytes_per_frame: usize, - samplerate: usize, - chunksize: usize, -} - -enum CaptureResult { - Normal, - Stalled, -} - #[derive(Debug)] enum PlaybackResult { Normal, @@ -121,14 +96,14 @@ fn play_buffer( io: &alsa::pcm::IO, millis_per_frame: f32, bytes_per_frame: usize, - buf_manager: &mut PlaybackBufferManager, + buf_manager: &PlaybackBufferManager, ) -> Res { let playback_state = pcmdevice.state_raw(); - //trace!("Playback state {:?}", playback_state); + xtrace!("Playback state {:?}", playback_state); if playback_state < 0 { // This should never happen but sometimes does anyway, // for example if a USB device is unplugged. - let nixerr = alsa::nix::errno::from_i32(-playback_state); + let nixerr = Errno::from_raw(-playback_state); error!( "PB: Alsa snd_pcm_state() of playback device returned an unexpected error: {}", nixerr @@ -140,6 +115,8 @@ fn play_buffer( buf_manager.sleep_for_target_delay(millis_per_frame); } else if playback_state == alsa_sys::SND_PCM_STATE_PREPARED as i32 { info!("PB: Starting playback from Prepared state"); + // This sleep applies for the first chunk and in combination with the threshold=1 (i.e. start at first write) + // and the next chunk generates the initial target delay. buf_manager.sleep_for_target_delay(millis_per_frame); } else if playback_state != alsa_sys::SND_PCM_STATE_RUNNING as i32 { warn!( @@ -179,11 +156,18 @@ fn play_buffer( return Ok(PlaybackResult::Stalled); } Err(err) => { - warn!( - "PB: device failed while waiting for available buffer space, error: {}", - err - ); - return Err(Box::new(err)); + if Errno::from_raw(err.errno()) == Errno::EPIPE { + warn!("PB: wait underrun, trying to recover. Error: {}", err); + trace!("snd_pcm_prepare"); + // Would recover() be better than prepare()? + pcmdevice.prepare()?; + } else { + warn!( + "PB: device failed while waiting for available buffer space, error: {}", + err + ); + return Err(Box::new(err)); + } } } @@ -209,11 +193,13 @@ fn play_buffer( continue; } } - Err(err) => { - if err.nix_error() == alsa::nix::errno::Errno::EAGAIN { + Err(err) => match Errno::from_raw(err.errno()) { + Errno::EAGAIN => { trace!("PB: encountered EAGAIN error on write, trying again"); - } else { - warn!("PB: write error, trying to recover. Error: {}", err); + continue; + } + Errno::EPIPE => { + warn!("PB: write underrun, trying to recover. Error: {}", err); trace!("snd_pcm_prepare"); // Would recover() be better than prepare()? pcmdevice.prepare()?; @@ -221,20 +207,30 @@ fn play_buffer( io.writei(buffer)?; break; } - } + _ => { + warn!("PB: write failed, error: {}", err); + return Err(Box::new(err)); + } + }, }; } Ok(PlaybackResult::Normal) } /// Capture a buffer. +#[allow(clippy::too_many_arguments)] fn capture_buffer( mut buffer: &mut [u8], pcmdevice: &alsa::PCM, io: &alsa::pcm::IO, - samplerate: usize, frames_to_read: usize, - bytes_per_frame: usize, + fds: &mut FileDescriptors, + ctl: &Option, + hctl: &Option, + elems: &CaptureElements, + status_channel: &crossbeam_channel::Sender, + params: &mut CaptureParams, + processing_params: &Arc, ) -> Res { let capture_state = pcmdevice.state_raw(); if capture_state == alsa_sys::SND_PCM_STATE_XRUN as i32 { @@ -243,7 +239,7 @@ fn capture_buffer( } else if capture_state < 0 { // This should never happen but sometimes does anyway, // for example if a USB device is unplugged. - let nixerr = alsa::nix::errno::from_i32(-capture_state); + let nixerr = Errno::from_raw(-capture_state); error!( "Alsa snd_pcm_state() of capture device returned an unexpected error: {}", capture_state @@ -256,12 +252,12 @@ fn capture_buffer( ); pcmdevice.start()?; } - let millis_per_chunk = 1000 * frames_to_read / samplerate; + let millis_per_chunk = 1000 * frames_to_read / params.samplerate; loop { - let mut timeout_millis = 4 * millis_per_chunk as u32; - if timeout_millis < 10 { - timeout_millis = 10; + let mut timeout_millis = 8 * millis_per_chunk as u32; + if timeout_millis < 20 { + timeout_millis = 20; } let start = if log_enabled!(log::Level::Trace) { Some(Instant::now()) @@ -269,25 +265,54 @@ fn capture_buffer( None }; trace!("Capture pcmdevice.wait with timeout {} ms", timeout_millis); - match pcmdevice.wait(Some(timeout_millis)) { - Ok(true) => { - trace!("Capture waited for {:?}, ready", start.map(|s| s.elapsed())); - } - Ok(false) => { - trace!("Wait timed out, capture device takes too long to capture frames"); - return Ok(CaptureResult::Stalled); - } - Err(err) => { - warn!( - "Capture device failed while waiting for available frames, error: {}", - err - ); - return Err(Box::new(err)); + loop { + match fds.wait(timeout_millis as i32) { + Ok(pollresult) => { + if pollresult.poll_res == 0 { + trace!("Wait timed out, capture device takes too long to capture frames"); + return Ok(CaptureResult::Stalled); + } + if pollresult.ctl { + trace!("Got a control event"); + if let Some(c) = ctl { + let event_result = + process_events(c, elems, status_channel, params, processing_params); + match event_result { + CaptureResult::Done => return Ok(event_result), + CaptureResult::Stalled => debug!("Capture device is stalled"), + CaptureResult::Normal => {} + }; + } + if let Some(h) = hctl { + let ev = h.handle_events().unwrap(); + trace!("hctl handle events {}", ev); + } + } + if pollresult.pcm { + trace!("Capture waited for {:?}", start.map(|s| s.elapsed())); + break; + } + } + Err(err) => { + if Errno::from_raw(err.errno()) == Errno::EPIPE { + warn!("Capture: wait overrun, trying to recover. Error: {}", err); + trace!("snd_pcm_prepare"); + // Would recover() be better than prepare()? + pcmdevice.prepare()?; + break; + } else { + warn!( + "Capture: device failed while waiting for available frames, error: {}", + err + ); + return Err(Box::new(err)); + } + } } } match io.readi(buffer) { Ok(frames_read) => { - let frames_req = buffer.len() / bytes_per_frame; + let frames_req = buffer.len() / params.bytes_per_frame; if frames_read == frames_req { trace!("Capture read {} frames as requested", frames_read); return Ok(CaptureResult::Normal); @@ -296,21 +321,26 @@ fn capture_buffer( "Capture read {} frames instead of the requested {}", frames_read, frames_req ); - buffer = &mut buffer[frames_read * bytes_per_frame..]; + buffer = &mut buffer[frames_read * params.bytes_per_frame..]; // repeat reading continue; } } - Err(err) => match err.nix_error() { - alsa::nix::errno::Errno::EIO => { - warn!("Capture failed with error: {}", err); + Err(err) => match Errno::from_raw(err.errno()) { + Errno::EIO => { + warn!("Capture: read failed with error: {}", err); return Err(Box::new(err)); } - // TODO: do we need separate handling of xruns that happen in the tiny - // window between state() and readi()? - alsa::nix::errno::Errno::EPIPE => { - warn!("Capture failed, error: {}", err); - return Err(Box::new(err)); + Errno::EAGAIN => { + trace!("Capture: encountered EAGAIN error on read, trying again"); + continue; + } + Errno::EPIPE => { + warn!("Capture: read overrun, trying to recover. Error: {}", err); + trace!("snd_pcm_prepare"); + // Would recover() be better than prepare()? + pcmdevice.prepare()?; + continue; } _ => { warn!("Capture failed, error: {}", err); @@ -326,10 +356,10 @@ fn open_pcm( devname: String, samplerate: u32, channels: u32, - sample_format: &SampleFormat, + sample_format: &Option, buf_manager: &mut dyn DeviceBufferManager, capture: bool, -) -> Res { +) -> Res<(alsa::PCM, SampleFormat)> { let direction = if capture { "Capture" } else { "Playback" }; debug!( "Available {} devices: {:?}", @@ -345,6 +375,7 @@ fn open_pcm( alsa::PCM::new(&devname, Direction::Playback, true)? }; // Set hardware parameters + let chosen_format; { let hwp = HwParams::any(&pcmdev)?; @@ -360,8 +391,17 @@ fn open_pcm( // Set sample format debug!("{}: {}", direction, list_formats_as_text(&hwp)); - debug!("{}: setting format to {}", direction, sample_format); - match sample_format { + chosen_format = match sample_format { + Some(sfmt) => *sfmt, + None => { + let preferred = pick_preferred_format(&hwp) + .ok_or(DeviceError::new("Unable to find a supported sample format"))?; + debug!("{}: Picked sample format {}", direction, preferred); + preferred + } + }; + debug!("{}: setting format to {}", direction, chosen_format); + match chosen_format { SampleFormat::S16LE => hwp.set_format(Format::s16())?, SampleFormat::S24LE => hwp.set_format(Format::s24())?, SampleFormat::S24LE3 => hwp.set_format(Format::s24_3())?, @@ -391,14 +431,14 @@ fn open_pcm( pcmdev.sw_params(&swp)?; debug!("{} device \"{}\" successfully opened", direction, devname); } - Ok(pcmdev) + Ok((pcmdev, chosen_format)) } fn playback_loop_bytes( channels: PlaybackChannels, pcmdevice: &alsa::PCM, params: PlaybackParams, - buf_manager: &mut PlaybackBufferManager, + buf_manager: &PlaybackBufferManager, ) { let mut timer = countertimer::Stopwatch::new(); let mut chunk_stats = ChunkStats { @@ -410,7 +450,14 @@ fn playback_loop_bytes( let adjust = params.adjust_period > 0.0 && params.adjust_enabled; let millis_per_frame: f32 = 1000.0 / params.samplerate as f32; let mut device_stalled = false; - + let mut pcm_paused = false; + let can_pause = pcmdevice + .hw_params_current() + .map(|p| p.can_pause()) + .unwrap_or_default(); + if can_pause { + debug!("Playback device supports pausing the stream") + } let io = pcmdevice.io_bytes(); debug!("Playback loop uses a buffer of {} frames", params.chunksize); let mut buffer = vec![0u8; params.chunksize * params.bytes_per_frame]; @@ -433,8 +480,29 @@ fn playback_loop_bytes( if element_uac2_gadget.is_some() { info!("Playback device supports rate adjust"); } - let mut capture_speed: f64 = 1.0; - let mut prev_delay_diff: Option = None; + + let mut rate_controller = PIRateController::new_with_default_gains( + params.samplerate, + params.adjust_period as f64, + params.target_level, + ); + trace!("PB: {:?}", buf_manager); + let thread_handle = match promote_current_thread_to_real_time( + params.chunksize as u32, + params.samplerate as u32, + ) { + Ok(h) => { + debug!("Playback thread has real-time priority."); + Some(h) + } + Err(err) => { + warn!( + "Playback thread could not get real time priority, error: {}", + err + ); + None + } + }; loop { let eos_in_drain = if device_stalled { drain_check_eos(&channels.audio) @@ -452,19 +520,18 @@ fn playback_loop_bytes( match msg { Ok(AudioMessage::Audio(chunk)) => { // measure delay only on running non-stalled device - let delay_at_chunk_recvd = if !device_stalled + let avail_at_chunk_recvd = if !device_stalled && pcmdevice.state_raw() == alsa_sys::SND_PCM_STATE_RUNNING as i32 { - pcmdevice.status().ok().map(|status| status.get_delay()) + pcmdevice.avail().ok() } else { None }; - //trace!("PB: Delay at chunk rcvd: {:?}", delay_at_chunk_recvd); + //trace!("PB: Avail at chunk rcvd: {:?}", avail_at_chunk_recvd); conversion_result = chunk_to_buffer_rawbytes(&chunk, &mut buffer, ¶ms.sample_format); - trace!("PB: {:?}", buf_manager); let playback_res = play_buffer( &buffer, pcmdevice, @@ -473,6 +540,7 @@ fn playback_loop_bytes( params.bytes_per_frame, buf_manager, ); + pcm_paused = false; device_stalled = match playback_res { Ok(PlaybackResult::Normal) => { if device_stalled { @@ -521,8 +589,7 @@ fn playback_loop_bytes( if !device_stalled { // updates only for non-stalled device chunk.update_stats(&mut chunk_stats); - { - let mut playback_status = params.playback_status.write(); + if let Some(mut playback_status) = params.playback_status.try_write() { if conversion_result.1 > 0 { playback_status.clipped_samples += conversion_result.1; } @@ -532,61 +599,70 @@ fn playback_loop_bytes( playback_status .signal_peak .add_record(chunk_stats.peak_linear()); + } else { + xtrace!("playback status blocked, skip update"); } - if let Some(delay) = delay_at_chunk_recvd { - if delay != 0 { - buffer_avg.add_value(delay as f64); - } + if let Some(avail) = avail_at_chunk_recvd { + let delay = buf_manager.current_delay(avail); + buffer_avg.add_value(delay as f64); } if timer.larger_than_millis((1000.0 * params.adjust_period) as u64) { if let Some(avg_delay) = buffer_avg.average() { timer.restart(); buffer_avg.restart(); if adjust { - let (new_capture_speed, new_delay_diff) = adjust_speed( + let capture_speed = rate_controller.next(avg_delay); + if let Some(elem_uac2_gadget) = &element_uac2_gadget { + let mut elval = ElemValue::new(ElemType::Integer).unwrap(); + // speed is reciprocal on playback side + elval + .set_integer(0, (1_000_000.0 / capture_speed) as i32) + .unwrap(); + elem_uac2_gadget.write(&elval).unwrap(); + debug!("Set gadget playback speed to {}", capture_speed); + } else { + debug!("Send SetSpeed message for speed {}", capture_speed); + channels + .status + .send(StatusMessage::SetSpeed(capture_speed)) + .unwrap_or(()); + } + } + if let Some(mut playback_status) = params.playback_status.try_write() { + playback_status.buffer_level = avg_delay as usize; + debug!( + "PB: buffer level: {:.1}, signal rms: {:?}", avg_delay, - params.target_level, - prev_delay_diff, - capture_speed, + playback_status.signal_rms.last_sqrt() ); - if prev_delay_diff.is_some() { - // not first cycle - capture_speed = new_capture_speed; - if let Some(elem_uac2_gadget) = &element_uac2_gadget { - let mut elval = ElemValue::new(ElemType::Integer).unwrap(); - // speed is reciprocal on playback side - elval - .set_integer(0, (1_000_000.0 / capture_speed) as i32) - .unwrap(); - elem_uac2_gadget.write(&elval).unwrap(); - } else { - channels - .status - .send(StatusMessage::SetSpeed(capture_speed)) - .unwrap_or(()); - } - } - prev_delay_diff = Some(new_delay_diff); + } else { + xtrace!("playback params blocked, skip rms update"); } - let mut playback_status = params.playback_status.write(); - playback_status.buffer_level = avg_delay as usize; - debug!( - "PB: buffer level: {:.1}, signal rms: {:?}", - avg_delay, - playback_status.signal_rms.last() - ); } } } } Ok(AudioMessage::Pause) => { trace!("PB: Pause message received"); + if can_pause && !pcm_paused { + let pause_res = pcmdevice.pause(true); + trace!("pcm_pause result {:?}", pause_res); + if pause_res.is_ok() { + pcm_paused = true + } + } } Ok(AudioMessage::EndOfStream) => { channels .status .send(StatusMessage::PlaybackDone) .unwrap_or(()); + // Only drain if the device isn't paused + if !pcm_paused { + let drain_res = pcmdevice.drain(); + // Draining isn't strictly needed, ignore any error and don't retry + trace!("pcm_drain result {:?}", drain_res); + } break; } Err(err) => { @@ -595,13 +671,29 @@ fn playback_loop_bytes( .status .send(StatusMessage::PlaybackError(err.to_string())) .unwrap_or(()); + // Only drain if the device isn't paused + if !pcm_paused { + let drain_res = pcmdevice.drain(); + // Draining isn't strictly needed, ignore any error and don't retry + trace!("pcm_drain result {:?}", drain_res); + } break; } } } + if let Some(h) = thread_handle { + match demote_current_thread_from_real_time(h) { + Ok(_) => { + debug!("Playback thread returned to normal priority.") + } + Err(_) => { + warn!("Could not bring the playback thread back to normal priority.") + } + }; + } } -fn drain_check_eos(audio: &Receiver) -> Option { +fn drain_check_eos(audio: &mpsc::Receiver) -> Option { let mut eos: Option = None; while let Some(msg) = audio.try_iter().next() { if let AudioMessage::EndOfStream = msg { @@ -614,9 +706,10 @@ fn drain_check_eos(audio: &Receiver) -> Option { fn capture_loop_bytes( channels: CaptureChannels, pcmdevice: &alsa::PCM, - params: CaptureParams, + mut params: CaptureParams, mut resampler: Option>>, buf_manager: &mut CaptureBufferManager, + processing_params: &Arc, ) { let io = pcmdevice.io_bytes(); let pcminfo = pcmdevice.info().unwrap(); @@ -624,26 +717,77 @@ fn capture_loop_bytes( let device = pcminfo.get_device(); let subdevice = pcminfo.get_subdevice(); - let mut element_loopback: Option = None; - let mut element_uac2_gadget: Option = None; + let fds = pcmdevice.get().unwrap(); + trace!("File descriptors: {:?}", fds); + let nbr_pcm_fds = fds.len(); + let mut file_descriptors = FileDescriptors { fds, nbr_pcm_fds }; + + let mut element_loopback: Option = None; + let mut element_uac2_gadget: Option = None; + + let mut capture_elements = CaptureElements::default(); + // Virtual devices such as pcm plugins don't have a hw card ID // Only try to create the HCtl when the device has an ID - let h = (card >= 0).then(|| HCtl::new(&format!("hw:{}", card), false).unwrap()); - if let Some(h) = &h { - h.load().unwrap(); - let mut elid_loopback = ElemId::new(ElemIface::PCM); - elid_loopback.set_device(device); - elid_loopback.set_subdevice(subdevice); - elid_loopback.set_name(&CString::new("PCM Rate Shift 100000").unwrap()); - element_loopback = h.find_elem(&elid_loopback); + let hctl = (card >= 0).then(|| HCtl::new(&format!("hw:{}", card), true).unwrap()); + let ctl = (card >= 0).then(|| Ctl::new(&format!("hw:{}", card), true).unwrap()); - let mut elid_uac2_gadget = ElemId::new(ElemIface::PCM); - elid_uac2_gadget.set_device(device); - elid_uac2_gadget.set_subdevice(subdevice); - elid_uac2_gadget.set_name(&CString::new("Capture Pitch 1000000").unwrap()); - element_uac2_gadget = h.find_elem(&elid_uac2_gadget); + if let Some(c) = &ctl { + c.subscribe_events(true).unwrap(); } + if let Some(h) = &hctl { + let ctl_fds = h.get().unwrap(); + file_descriptors.fds.extend(ctl_fds.iter()); + //println!("{:?}", file_descriptors.fds); + h.load().unwrap(); + element_loopback = find_elem( + h, + ElemIface::PCM, + Some(device), + Some(subdevice), + "PCM Rate Shift 100000", + ); + element_uac2_gadget = find_elem( + h, + ElemIface::PCM, + Some(device), + Some(subdevice), + "Capture Pitch 1000000", + ); + + capture_elements.find_elements( + h, + device, + subdevice, + ¶ms.link_volume_control, + ¶ms.link_mute_control, + ); + if let Some(c) = &ctl { + if let Some(ref vol_elem) = capture_elements.volume { + let vol_db = vol_elem.read_volume_in_db(c); + info!("Using initial volume from Alsa: {:?}", vol_db); + if let Some(vol) = vol_db { + params.linked_volume_value = Some(vol); + channels + .status + .send(StatusMessage::SetVolume(vol)) + .unwrap_or_default(); + } + } + if let Some(ref mute_elem) = capture_elements.mute { + let active = mute_elem.read_as_bool(); + info!("Using initial active switch from Alsa: {:?}", active); + if let Some(active_val) = active { + params.linked_mute_value = Some(!active_val); + channels + .status + .send(StatusMessage::SetMute(!active_val)) + .unwrap_or_default(); + } + } + } + } if element_loopback.is_some() || element_uac2_gadget.is_some() { info!("Capture device supports rate adjust"); if params.samplerate == params.capture_samplerate && resampler.is_some() { @@ -682,6 +826,22 @@ fn capture_loop_bytes( peak: vec![0.0; params.channels], }; let mut channel_mask = vec![true; params.channels]; + let thread_handle = match promote_current_thread_to_real_time( + params.chunksize as u32, + params.samplerate as u32, + ) { + Ok(h) => { + debug!("Capture thread has real-time priority."); + Some(h) + } + Err(err) => { + warn!( + "Capture thread could not get real time priority, error: {}", + err + ); + None + } + }; loop { match channels.command.try_recv() { Ok(CommandMessage::Exit) => { @@ -695,16 +855,16 @@ fn capture_loop_bytes( break; } Ok(CommandMessage::SetSpeed { speed }) => { - let mut elval = ElemValue::new(ElemType::Integer).unwrap(); rate_adjust = speed; if let Some(elem_loopback) = &element_loopback { - elval.set_integer(0, (100_000.0 / speed) as i32).unwrap(); - elem_loopback.write(&elval).unwrap(); + debug!("Setting capture loopback speed to {}", speed); + elem_loopback.write_as_int((100_000.0 / speed) as i32); } else if let Some(elem_uac2_gadget) = &element_uac2_gadget { - elval.set_integer(0, (speed * 1_000_000.0) as i32).unwrap(); - elem_uac2_gadget.write(&elval).unwrap(); + debug!("Setting capture gadget speed to {}", speed); + elem_uac2_gadget.write_as_int((speed * 1_000_000.0) as i32); } else if let Some(resampl) = &mut resampler { if params.async_src { + debug!("Setting async resampler speed to {}", speed); if resampl.set_resample_ratio_relative(speed, true).is_err() { debug!("Failed to set resampling speed to {}", speed); } @@ -744,16 +904,20 @@ fn capture_loop_bytes( &mut buffer[0..capture_bytes], pcmdevice, &io, - params.capture_samplerate, capture_frames as usize, - params.bytes_per_frame, + &mut file_descriptors, + &ctl, + &hctl, + &capture_elements, + &channels.status, + &mut params, + processing_params, ); match capture_res { Ok(CaptureResult::Normal) => { - //trace!("Captured {} bytes", capture_bytes); + xtrace!("Captured {} bytes", capture_bytes); averager.add_value(capture_bytes); - { - let capture_status = params.capture_status.upgradable_read(); + if let Some(capture_status) = params.capture_status.try_upgradable_read() { if averager.larger_than_millis(capture_status.update_interval as u64) { device_stalled = false; let bytes_per_sec = averager.average(); @@ -761,12 +925,19 @@ fn capture_loop_bytes( let measured_rate_f = bytes_per_sec / (params.channels * params.store_bytes_per_sample) as f64; trace!("Measured sample rate is {:.1} Hz", measured_rate_f); - let mut capture_status = RwLockUpgradableReadGuard::upgrade(capture_status); // to write lock - capture_status.measured_samplerate = measured_rate_f as usize; - capture_status.signal_range = value_range as f32; - capture_status.rate_adjust = rate_adjust as f32; - capture_status.state = state; + if let Ok(mut capture_status) = + RwLockUpgradableReadGuard::try_upgrade(capture_status) + { + capture_status.measured_samplerate = measured_rate_f as usize; + capture_status.signal_range = value_range as f32; + capture_status.rate_adjust = rate_adjust as f32; + capture_status.state = state; + } else { + xtrace!("capture status upgrade blocked, skip update"); + } } + } else { + xtrace!("capture status blocked, skip update"); } watcher_averager.add_value(capture_bytes); if watcher_averager.larger_than_millis(rate_measure_interval_ms) { @@ -808,6 +979,13 @@ fn capture_loop_bytes( params.capture_status.write().state = ProcessingState::Stalled; } } + Ok(CaptureResult::Done) => { + info!("Capture stopped"); + let msg = AudioMessage::EndOfStream; + channels.audio.send(msg).unwrap_or(()); + params.capture_status.write().state = ProcessingState::Inactive; + return; + } Err(msg) => { channels .status @@ -826,16 +1004,18 @@ fn capture_loop_bytes( ¶ms.capture_status.read().used_channels, ); chunk.update_stats(&mut chunk_stats); - { - let mut capture_status = params.capture_status.write(); + if let Some(mut capture_status) = params.capture_status.try_write() { capture_status .signal_rms .add_record_squared(chunk_stats.rms_linear()); capture_status .signal_peak .add_record(chunk_stats.peak_linear()); + } else { + xtrace!("capture status blocked, skip rms update"); } value_range = chunk.maxval - chunk.minval; + trace!("Captured chunk with value range {}", value_range); if device_stalled { state = ProcessingState::Stalled; } else { @@ -867,6 +1047,17 @@ fn capture_loop_bytes( break; } } + sync_linked_controls(processing_params, &mut params, &mut capture_elements, &ctl); + } + if let Some(h) = thread_handle { + match demote_current_thread_from_real_time(h) { + Ok(_) => { + debug!("Capture thread returned to normal priority.") + } + Err(_) => { + warn!("Could not bring the capture thread back to normal priority.") + } + }; } params.capture_status.write().state = ProcessingState::Inactive; } @@ -890,7 +1081,7 @@ fn nbr_capture_bytes_and_frames( buf: &mut Vec, ) -> (usize, Frames) { let (capture_bytes_new, capture_frames_new) = if let Some(resampl) = &resampler { - //trace!("Resampler needs {} frames", resampl.input_frames_next()); + xtrace!("Resampler needs {} frames", resampl.input_frames_next()); let frames = resampl.input_frames_next(); ( frames * params.channels * params.store_bytes_per_sample, @@ -926,8 +1117,7 @@ impl PlaybackDevice for AlsaPlaybackDevice { let samplerate = self.samplerate; let chunksize = self.chunksize; let channels = self.channels; - let bytes_per_sample = self.sample_format.bytes_per_sample(); - let sample_format = self.sample_format; + let conf_sample_format = self.sample_format; let mut buf_manager = PlaybackBufferManager::new(chunksize as Frames, target_level as Frames); let handle = thread::Builder::new() @@ -937,16 +1127,16 @@ impl PlaybackDevice for AlsaPlaybackDevice { devname, samplerate as u32, channels as u32, - &sample_format, + &conf_sample_format, &mut buf_manager, false, ) { - Ok(pcmdevice) => { + Ok((pcmdevice, sample_format)) => { match status_channel.send(StatusMessage::PlaybackReady) { Ok(()) => {} Err(_err) => {} } - + let bytes_per_sample = sample_format.bytes_per_sample(); barrier.wait(); debug!("Starting playback loop"); let pb_params = PlaybackParams { @@ -964,7 +1154,7 @@ impl PlaybackDevice for AlsaPlaybackDevice { audio: channel, status: status_channel, }; - playback_loop_bytes(pb_channels, &pcmdevice, pb_params, &mut buf_manager); + playback_loop_bytes(pb_channels, &pcmdevice, pb_params, &buf_manager); } Err(err) => { let send_result = @@ -990,6 +1180,7 @@ impl CaptureDevice for AlsaCaptureDevice { status_channel: crossbeam_channel::Sender, command_channel: mpsc::Receiver, capture_status: Arc>, + processing_params: Arc, ) -> Res>> { let devname = self.devname.clone(); let samplerate = self.samplerate; @@ -997,14 +1188,16 @@ impl CaptureDevice for AlsaCaptureDevice { let chunksize = self.chunksize; let channels = self.channels; - let store_bytes_per_sample = self.sample_format.bytes_per_sample(); let silence_timeout = self.silence_timeout; let silence_threshold = self.silence_threshold; - let sample_format = self.sample_format; + let conf_sample_format = self.sample_format; let resampler_config = self.resampler_config; let async_src = resampler_is_async(&resampler_config); let stop_on_rate_change = self.stop_on_rate_change; let rate_measure_interval = self.rate_measure_interval; + let stop_on_inactive = self.stop_on_inactive; + let link_volume_control = self.link_volume_control.clone(); + let link_mute_control = self.link_mute_control.clone(); let mut buf_manager = CaptureBufferManager::new( chunksize as Frames, samplerate as f32 / capture_samplerate as f32, @@ -1024,15 +1217,16 @@ impl CaptureDevice for AlsaCaptureDevice { devname, capture_samplerate as u32, channels as u32, - &sample_format, + &conf_sample_format, &mut buf_manager, true, ) { - Ok(pcmdevice) => { + Ok((pcmdevice, sample_format)) => { match status_channel.send(StatusMessage::CaptureReady) { Ok(()) => {} Err(_err) => {} } + let store_bytes_per_sample = sample_format.bytes_per_sample(); barrier.wait(); debug!("Starting captureloop"); let cap_params = CaptureParams { @@ -1049,6 +1243,11 @@ impl CaptureDevice for AlsaCaptureDevice { capture_status, stop_on_rate_change, rate_measure_interval, + stop_on_inactive, + link_volume_control, + link_mute_control, + linked_mute_value: None, + linked_volume_value: None, }; let cap_channels = CaptureChannels { audio: channel, @@ -1061,6 +1260,7 @@ impl CaptureDevice for AlsaCaptureDevice { cap_params, resampler, &mut buf_manager, + &processing_params, ); } Err(err) => { diff --git a/src/alsadevice_buffermanager.rs b/src/alsadevice_buffermanager.rs index 33258e2a..fd61aef7 100644 --- a/src/alsadevice_buffermanager.rs +++ b/src/alsadevice_buffermanager.rs @@ -91,6 +91,7 @@ pub trait DeviceBufferManager { hwp.set_period_size_near(alt_period_frames, alsa::ValueOr::Nearest)?; } } + debug!("Device is using a period size of {} frames", data.period); Ok(()) } @@ -98,12 +99,7 @@ pub trait DeviceBufferManager { fn apply_avail_min(&mut self, swp: &SwParams) -> Res<()> { let data = self.data_mut(); // maximum timing safety - headroom for one io_size only - if data.io_size < data.period { - warn!( - "Trying to set avail_min to {}, must be larger than or equal to period of {}", - data.io_size, data.period - ); - } else if data.io_size > data.bufsize { + if data.io_size > data.bufsize { let msg = format!("Trying to set avail_min to {}, must be smaller than or equal to device buffer size of {}", data.io_size, data.bufsize); error!("{}", msg); @@ -124,11 +120,13 @@ pub trait DeviceBufferManager { Ok(()) } - fn frames_to_stall(&mut self) -> Frames { - let data = self.data_mut(); + fn frames_to_stall(&self) -> Frames { + let data = self.data(); // +1 to make sure the device really stalls data.bufsize - data.avail_min + 1 } + + fn current_delay(&self, avail: Frames) -> Frames; } #[derive(Debug)] @@ -186,6 +184,10 @@ impl DeviceBufferManager for CaptureBufferManager { self.data.threshold = threshold; Ok(()) } + + fn current_delay(&self, avail: Frames) -> Frames { + avail + } } #[derive(Debug)] @@ -210,7 +212,7 @@ impl PlaybackBufferManager { } } - pub fn sleep_for_target_delay(&mut self, millis_per_frame: f32) { + pub fn sleep_for_target_delay(&self, millis_per_frame: f32) { let sleep_millis = (self.target_level as f32 * millis_per_frame) as u64; trace!( "Sleeping for {} frames = {} ms", @@ -237,4 +239,8 @@ impl DeviceBufferManager for PlaybackBufferManager { self.data.threshold = threshold; Ok(()) } + + fn current_delay(&self, avail: Frames) -> Frames { + self.data.bufsize - avail + } } diff --git a/src/alsadevice_utils.rs b/src/alsadevice_utils.rs index ea4c1b00..fd3f0928 100644 --- a/src/alsadevice_utils.rs +++ b/src/alsadevice_utils.rs @@ -1,12 +1,17 @@ use crate::config::SampleFormat; -use crate::Res; +use crate::{CaptureStatus, PlaybackStatus, PrcFmt, Res, StatusMessage}; use alsa::card::Iter; -use alsa::ctl::{Ctl, DeviceIter}; +use alsa::ctl::{Ctl, DeviceIter, ElemId, ElemIface, ElemType, ElemValue}; use alsa::device_name::HintIter; +use alsa::hctl::{Elem, HCtl}; use alsa::pcm::{Format, HwParams}; -use alsa::Card; -use alsa::Direction; +use alsa::{Card, Direction}; use alsa_sys; +use parking_lot::RwLock; +use std::ffi::CString; +use std::sync::Arc; + +use crate::ProcessingParameters; const STANDARD_RATES: [u32; 17] = [ 5512, 8000, 11025, 16000, 22050, 32000, 44100, 48000, 64000, 88200, 96000, 176400, 192000, @@ -19,7 +24,46 @@ pub enum SupportedValues { Discrete(Vec), } -fn get_card_names(card: &Card, input: bool, names: &mut Vec<(String, String)>) -> Res<()> { +pub struct CaptureParams { + pub channels: usize, + pub sample_format: SampleFormat, + pub silence_timeout: PrcFmt, + pub silence_threshold: PrcFmt, + pub chunksize: usize, + pub store_bytes_per_sample: usize, + pub bytes_per_frame: usize, + pub samplerate: usize, + pub capture_samplerate: usize, + pub async_src: bool, + pub capture_status: Arc>, + pub stop_on_rate_change: bool, + pub rate_measure_interval: f32, + pub stop_on_inactive: bool, + pub link_volume_control: Option, + pub link_mute_control: Option, + pub linked_volume_value: Option, + pub linked_mute_value: Option, +} + +pub struct PlaybackParams { + pub channels: usize, + pub target_level: usize, + pub adjust_period: f32, + pub adjust_enabled: bool, + pub sample_format: SampleFormat, + pub playback_status: Arc>, + pub bytes_per_frame: usize, + pub samplerate: usize, + pub chunksize: usize, +} + +pub enum CaptureResult { + Normal, + Stalled, + Done, +} + +pub fn get_card_names(card: &Card, input: bool, names: &mut Vec<(String, String)>) -> Res<()> { let dir = if input { Direction::Capture } else { @@ -209,6 +253,31 @@ pub fn list_formats(hwp: &HwParams) -> Res> { Ok(formats) } +pub fn pick_preferred_format(hwp: &HwParams) -> Option { + // Start with integer formats, in descending quality + if hwp.test_format(Format::s32()).is_ok() { + return Some(SampleFormat::S32LE); + } + // The two 24-bit formats are equivalent, the order does not matter + if hwp.test_format(Format::S243LE).is_ok() { + return Some(SampleFormat::S24LE3); + } + if hwp.test_format(Format::s24()).is_ok() { + return Some(SampleFormat::S24LE); + } + if hwp.test_format(Format::s16()).is_ok() { + return Some(SampleFormat::S16LE); + } + // float formats are unusual, try these last + if hwp.test_format(Format::float()).is_ok() { + return Some(SampleFormat::FLOAT32LE); + } + if hwp.test_format(Format::float64()).is_ok() { + return Some(SampleFormat::FLOAT64LE); + } + None +} + pub fn list_formats_as_text(hwp: &HwParams) -> String { let supported_formats_res = list_formats(hwp); if let Ok(formats) = supported_formats_res { @@ -218,49 +287,345 @@ pub fn list_formats_as_text(hwp: &HwParams) -> String { } } -pub fn adjust_speed( - avg_delay: f64, - target_delay: usize, - prev_diff: Option, - mut capture_speed: f64, -) -> (f64, f64) { - let latency = avg_delay * capture_speed; - let diff = latency - target_delay as f64; - match prev_diff { - None => (1.0, diff), - Some(prev_diff) => { - let equality_range = target_delay as f64 / 100.0; // in frames - let speed_delta = 1e-5; - if diff > 0.0 { - if diff > (prev_diff + equality_range) { - // playback latency grows, need to slow down capture more - capture_speed -= 3.0 * speed_delta; - } else if is_within(diff, prev_diff, equality_range) { - // positive, not changed from last cycle, need to slow down capture a bit - capture_speed -= speed_delta; +pub struct ElemData<'a> { + element: Elem<'a>, + numid: u32, +} + +impl ElemData<'_> { + pub fn read_as_int(&self) -> Option { + self.element + .read() + .ok() + .and_then(|elval| elval.get_integer(0)) + } + + pub fn read_as_bool(&self) -> Option { + self.element + .read() + .ok() + .and_then(|elval| elval.get_boolean(0)) + } + + pub fn read_volume_in_db(&self, ctl: &Ctl) -> Option { + self.read_as_int().and_then(|intval| { + ctl.convert_to_db(&self.element.get_id().unwrap(), intval as i64) + .ok() + .map(|v| v.to_db()) + }) + } + + pub fn write_volume_in_db(&self, ctl: &Ctl, value: f32) { + let intval = ctl.convert_from_db( + &self.element.get_id().unwrap(), + alsa::mixer::MilliBel::from_db(value), + alsa::Round::Floor, + ); + if let Ok(val) = intval { + self.write_as_int(val as i32); + } + } + + pub fn write_as_int(&self, value: i32) { + let mut elval = ElemValue::new(ElemType::Integer).unwrap(); + if elval.set_integer(0, value).is_some() { + self.element.write(&elval).unwrap_or_default(); + } + } + + pub fn write_as_bool(&self, value: bool) { + let mut elval = ElemValue::new(ElemType::Boolean).unwrap(); + if elval.set_boolean(0, value).is_some() { + self.element.write(&elval).unwrap_or_default(); + } + } +} + +#[derive(Default)] +pub struct CaptureElements<'a> { + pub loopback_active: Option>, + // pub loopback_rate: Option>, + // pub loopback_format: Option>, + // pub loopback_channels: Option>, + pub gadget_rate: Option>, + pub volume: Option>, + pub mute: Option>, +} + +pub struct FileDescriptors { + pub fds: Vec, + pub nbr_pcm_fds: usize, +} + +#[derive(Debug)] +pub struct PollResult { + pub poll_res: usize, + pub pcm: bool, + pub ctl: bool, +} + +impl FileDescriptors { + pub fn wait(&mut self, timeout: i32) -> alsa::Result { + let nbr_ready = alsa::poll::poll(&mut self.fds, timeout)?; + trace!("Got {} ready fds", nbr_ready); + let mut nbr_found = 0; + let mut pcm_res = false; + for fd in self.fds.iter().take(self.nbr_pcm_fds) { + if fd.revents > 0 { + pcm_res = true; + nbr_found += 1; + if nbr_found == nbr_ready { + // We are done, let's return early + + return Ok(PollResult { + poll_res: nbr_ready, + pcm: pcm_res, + ctl: false, + }); + } + } + } + // There were other ready file descriptors than PCM, must be controls + Ok(PollResult { + poll_res: nbr_ready, + pcm: pcm_res, + ctl: true, + }) + } +} + +pub fn process_events( + ctl: &Ctl, + elems: &CaptureElements, + status_channel: &crossbeam_channel::Sender, + params: &mut CaptureParams, + processing_params: &Arc, +) -> CaptureResult { + while let Ok(Some(ev)) = ctl.read() { + let nid = ev.get_id().get_numid(); + debug!("Event from numid {}", nid); + let action = get_event_action(nid, elems, ctl, params); + match action { + EventAction::SourceInactive => { + if params.stop_on_inactive { + debug!( + "Stopping, capture device is inactive and stop_on_inactive is set to true" + ); + status_channel + .send(StatusMessage::CaptureDone) + .unwrap_or_default(); + return CaptureResult::Done; + } + } + EventAction::FormatChange(value) => { + debug!("Stopping, capture device sample format changed"); + status_channel + .send(StatusMessage::CaptureFormatChange(value)) + .unwrap_or_default(); + return CaptureResult::Done; + } + EventAction::SetVolume(vol) => { + debug!("Alsa volume change event, set main fader to {} dB", vol); + processing_params.set_target_volume(0, vol); + params.linked_volume_value = Some(vol); + //status_channel + // .send(StatusMessage::SetVolume(vol)) + // .unwrap_or_default(); + } + EventAction::SetMute(mute) => { + debug!("Alsa mute change event, set mute state to {}", mute); + processing_params.set_mute(0, mute); + params.linked_mute_value = Some(mute); + //status_channel + // .send(StatusMessage::SetMute(mute)) + // .unwrap_or_default(); + } + EventAction::None => {} + } + } + CaptureResult::Normal +} + +pub enum EventAction { + None, + SetVolume(f32), + SetMute(bool), + FormatChange(usize), + SourceInactive, +} + +pub fn get_event_action( + numid: u32, + elems: &CaptureElements, + ctl: &Ctl, + params: &mut CaptureParams, +) -> EventAction { + if let Some(eldata) = &elems.loopback_active { + if eldata.numid == numid { + let value = eldata.read_as_bool(); + debug!("Loopback active: {:?}", value); + if let Some(active) = value { + if active { + return EventAction::None; + } + return EventAction::SourceInactive; + } + } + } + // Include this if the notify functionality of the loopback gets fixed + /* + if let Some(eldata) = &elems.loopback_rate { + if eldata.numid == numid { + let value = eldata.read_as_int(); + debug!("Gadget rate: {:?}", value); + if let Some(rate) = value { + debug!("Loopback rate: {}", rate); + return EventAction::FormatChange(rate); + } + } + } + if let Some(eldata) = &elems.loopback_format { + if eldata.numid == numid { + let value = eldata.read_as_int(); + debug!("Gadget rate: {:?}", value); + if let Some(format) = value { + debug!("Loopback format: {}", format); + return EventAction::FormatChange(TODO add sample format!); + } + } + } + if let Some(eldata) = &elems.loopback_channels { + if eldata.numid == numid { + debug!("Gadget rate: {:?}", value); + if let Some(chans) = value { + debug!("Loopback channels: {}", chans); + return EventAction::FormatChange(TODO add channels!); + } + } + } */ + if let Some(eldata) = &elems.volume { + if eldata.numid == numid { + let vol_db = eldata.read_volume_in_db(ctl); + debug!("Mixer volume control: {:?} dB", vol_db); + if let Some(vol) = vol_db { + params.linked_volume_value = Some(vol); + return EventAction::SetVolume(vol); + } + } + } + if let Some(eldata) = &elems.mute { + if eldata.numid == numid { + let active = eldata.read_as_bool(); + debug!("Mixer switch active: {:?}", active); + if let Some(active_val) = active { + params.linked_mute_value = Some(!active_val); + return EventAction::SetMute(!active_val); + } + } + } + if let Some(eldata) = &elems.gadget_rate { + if eldata.numid == numid { + let value = eldata.read_as_int(); + debug!("Gadget rate: {:?}", value); + if let Some(rate) = value { + if rate == 0 { + return EventAction::SourceInactive; } - } else if diff < 0.0 { - if diff < (prev_diff - equality_range) { - // playback latency sinks, need to speed up capture more - capture_speed += 3.0 * speed_delta; - } else if is_within(diff, prev_diff, equality_range) { - // negative, not changed from last cycle, need to speed up capture a bit - capture_speed += speed_delta + if rate as usize != params.capture_samplerate { + return EventAction::FormatChange(rate as usize); } + debug!("Capture device resumed with unchanged sample rate"); + return EventAction::None; } - debug!( - "Avg. buffer delay: {:.1}, target delay: {:.1}, diff: {}, prev_div: {}, corrected capture rate: {:.4}%", - avg_delay, - target_delay, - diff, - prev_diff, - 100.0 * capture_speed - ); - (capture_speed, diff) } } + trace!("Ignoring event from control with numid {}", numid); + EventAction::None +} + +impl<'a> CaptureElements<'a> { + pub fn find_elements( + &mut self, + h: &'a HCtl, + device: u32, + subdevice: u32, + volume_name: &Option, + mute_name: &Option, + ) { + self.loopback_active = find_elem( + h, + ElemIface::PCM, + Some(device), + Some(subdevice), + "PCM Slave Active", + ); + // self.loopback_rate = find_elem(h, ElemIface::PCM, device, subdevice, "PCM Slave Rate"); + // self.loopback_format = find_elem(h, ElemIface::PCM, device, subdevice, "PCM Slave Format"); + // self.loopback_channels = find_elem(h, ElemIface::PCM, device, subdevice, "PCM Slave Channels"); + self.gadget_rate = find_elem( + h, + ElemIface::PCM, + Some(device), + Some(subdevice), + "Capture Rate", + ); + self.volume = volume_name + .as_ref() + .and_then(|name| find_elem(h, ElemIface::Mixer, None, None, name)); + self.mute = mute_name + .as_ref() + .and_then(|name| find_elem(h, ElemIface::Mixer, None, None, name)); + } +} + +pub fn find_elem<'a>( + hctl: &'a HCtl, + iface: ElemIface, + device: Option, + subdevice: Option, + name: &str, +) -> Option> { + let mut elem_id = ElemId::new(iface); + if let Some(dev) = device { + elem_id.set_device(dev); + } + if let Some(subdev) = subdevice { + elem_id.set_subdevice(subdev); + } + elem_id.set_name(&CString::new(name).unwrap()); + let element = hctl.find_elem(&elem_id); + debug!("Look up element with name {}", name); + element.map(|e| { + let numid = e.get_id().map(|id| id.get_numid()).unwrap_or_default(); + debug!("Found element with name {} and numid {}", name, numid); + ElemData { element: e, numid } + }) } -pub fn is_within(value: f64, target: f64, equality_range: f64) -> bool { - value <= (target + equality_range) && value >= (target - equality_range) +pub fn sync_linked_controls( + processing_params: &Arc, + capture_params: &mut CaptureParams, + elements: &mut CaptureElements, + ctl: &Option, +) { + if let Some(c) = ctl { + if let Some(vol) = capture_params.linked_volume_value { + let target_vol = processing_params.target_volume(0); + if (vol - target_vol).abs() > 0.1 { + debug!("Updating linked volume control to {} dB", target_vol); + } + if let Some(vol_elem) = &elements.volume { + vol_elem.write_volume_in_db(c, target_vol); + } + } + if let Some(mute) = capture_params.linked_mute_value { + let target_mute = processing_params.is_mute(0); + if mute != target_mute { + debug!("Updating linked switch control to {}", !target_mute); + if let Some(mute_elem) = &elements.mute { + mute_elem.write_as_bool(!target_mute); + } + } + } + } } diff --git a/src/audiodevice.rs b/src/audiodevice.rs index e94bd70e..da104cab 100644 --- a/src/audiodevice.rs +++ b/src/audiodevice.rs @@ -16,6 +16,7 @@ use crate::coreaudiodevice; ))] use crate::cpaldevice; use crate::filedevice; +use crate::generatordevice; #[cfg(feature = "pulse-backend")] use crate::pulsedevice; #[cfg(target_os = "windows")] @@ -36,7 +37,7 @@ use crate::CommandMessage; use crate::PrcFmt; use crate::Res; use crate::StatusMessage; -use crate::{CaptureStatus, PlaybackStatus}; +use crate::{CaptureStatus, PlaybackStatus, ProcessingParameters}; pub const RATE_CHANGE_THRESHOLD_COUNT: usize = 3; pub const RATE_CHANGE_THRESHOLD_VALUE: f32 = 0.04; @@ -186,6 +187,11 @@ impl AudioChunk { *peakval = peak; *rmsval = rms; } + xtrace!( + "Stats: rms {:?}, peak {:?}", + stats.rms_db(), + stats.peak_db() + ); } pub fn update_channel_mask(&self, mask: &mut [bool]) { @@ -232,6 +238,7 @@ pub trait CaptureDevice { status_channel: crossbeam_channel::Sender, command_channel: mpsc::Receiver, capture_status: Arc>, + processing_params: Arc, ) -> Res>>; } @@ -269,6 +276,7 @@ pub fn new_playback_device(conf: config::Devices) -> Box { channels, filename, format, + wav_header, .. } => Box::new(filedevice::FilePlaybackDevice { destination: filedevice::PlaybackDest::Filename(filename), @@ -276,15 +284,20 @@ pub fn new_playback_device(conf: config::Devices) -> Box { chunksize: conf.chunksize, channels, sample_format: format, + wav_header: wav_header.unwrap_or(false), }), config::PlaybackDevice::Stdout { - channels, format, .. + channels, + format, + wav_header, + .. } => Box::new(filedevice::FilePlaybackDevice { destination: filedevice::PlaybackDest::Stdout, samplerate: conf.samplerate, chunksize: conf.chunksize, channels, sample_format: format, + wav_header: wav_header.unwrap_or(false), }), #[cfg(target_os = "macos")] config::PlaybackDevice::CoreAudio(ref dev) => { @@ -542,6 +555,10 @@ pub fn new_capture_device(conf: config::Devices) -> Box { channels, ref device, format, + stop_on_inactive, + ref link_volume_control, + ref link_mute_control, + .. } => Box::new(alsadevice::AlsaCaptureDevice { devname: device.clone(), samplerate: conf.samplerate, @@ -554,12 +571,16 @@ pub fn new_capture_device(conf: config::Devices) -> Box { silence_timeout: conf.silence_timeout(), stop_on_rate_change: conf.stop_on_rate_change(), rate_measure_interval: conf.rate_measure_interval(), + stop_on_inactive: stop_on_inactive.unwrap_or_default(), + link_volume_control: link_volume_control.clone(), + link_mute_control: link_mute_control.clone(), }), #[cfg(feature = "pulse-backend")] config::CaptureDevice::Pulse { channels, ref device, format, + .. } => Box::new(pulsedevice::PulseCaptureDevice { devname: device.clone(), samplerate: conf.samplerate, @@ -571,14 +592,14 @@ pub fn new_capture_device(conf: config::Devices) -> Box { silence_threshold: conf.silence_threshold(), silence_timeout: conf.silence_timeout(), }), - config::CaptureDevice::File(ref dev) => Box::new(filedevice::FileCaptureDevice { + config::CaptureDevice::RawFile(ref dev) => Box::new(filedevice::FileCaptureDevice { source: filedevice::CaptureSource::Filename(dev.filename.clone()), samplerate: conf.samplerate, capture_samplerate, resampler_config: conf.resampler, chunksize: conf.chunksize, channels: dev.channels, - sample_format: dev.format, + sample_format: Some(dev.format), extra_samples: dev.extra_samples(), silence_threshold: conf.silence_threshold(), silence_timeout: conf.silence_timeout(), @@ -587,6 +608,22 @@ pub fn new_capture_device(conf: config::Devices) -> Box { stop_on_rate_change: conf.stop_on_rate_change(), rate_measure_interval: conf.rate_measure_interval(), }), + config::CaptureDevice::WavFile(ref dev) => Box::new(filedevice::FileCaptureDevice { + source: filedevice::CaptureSource::Filename(dev.filename.clone()), + samplerate: conf.samplerate, + capture_samplerate, + resampler_config: conf.resampler, + chunksize: conf.chunksize, + channels: 0, + sample_format: None, + extra_samples: dev.extra_samples(), + silence_threshold: conf.silence_threshold(), + silence_timeout: conf.silence_timeout(), + skip_bytes: 0, + read_bytes: 0, + stop_on_rate_change: conf.stop_on_rate_change(), + rate_measure_interval: conf.rate_measure_interval(), + }), config::CaptureDevice::Stdin(ref dev) => Box::new(filedevice::FileCaptureDevice { source: filedevice::CaptureSource::Stdin, samplerate: conf.samplerate, @@ -594,7 +631,7 @@ pub fn new_capture_device(conf: config::Devices) -> Box { resampler_config: conf.resampler, chunksize: conf.chunksize, channels: dev.channels, - sample_format: dev.format, + sample_format: Some(dev.format), extra_samples: dev.extra_samples(), silence_threshold: conf.silence_threshold(), silence_timeout: conf.silence_timeout(), @@ -603,6 +640,14 @@ pub fn new_capture_device(conf: config::Devices) -> Box { stop_on_rate_change: conf.stop_on_rate_change(), rate_measure_interval: conf.rate_measure_interval(), }), + config::CaptureDevice::SignalGenerator { + signal, channels, .. + } => Box::new(generatordevice::GeneratorDevice { + signal, + samplerate: conf.samplerate, + channels, + chunksize: conf.chunksize, + }), #[cfg(all(target_os = "linux", feature = "bluez-backend"))] config::CaptureDevice::Bluez(ref dev) => Box::new(filedevice::FileCaptureDevice { source: filedevice::CaptureSource::BluezDBus(dev.service(), dev.dbus_path.clone()), @@ -611,7 +656,7 @@ pub fn new_capture_device(conf: config::Devices) -> Box { resampler_config: conf.resampler, chunksize: conf.chunksize, channels: dev.channels, - sample_format: dev.format, + sample_format: Some(dev.format), extra_samples: 0, silence_threshold: conf.silence_threshold(), silence_timeout: conf.silence_timeout(), @@ -665,6 +710,7 @@ pub fn new_capture_device(conf: config::Devices) -> Box { config::CaptureDevice::Jack { channels, ref device, + .. } => Box::new(cpaldevice::CpalCaptureDevice { devname: device.clone(), host: cpaldevice::CpalHost::Jack, @@ -682,21 +728,6 @@ pub fn new_capture_device(conf: config::Devices) -> Box { } } -pub fn calculate_speed(avg_level: f64, target_level: usize, adjust_period: f32, srate: u32) -> f64 { - let diff = avg_level as isize - target_level as isize; - let rel_diff = (diff as f64) / (srate as f64); - let speed = 1.0 - 0.5 * rel_diff / adjust_period as f64; - debug!( - "Avg. buffer level: {:.1}, target level: {:.1}, corrected capture rate: {:.4}%, ({:+.1}Hz at {}Hz)", - avg_level, - target_level, - 100.0 * speed, - srate as f64 * (speed-1.0), - srate - ); - speed -} - #[cfg(test)] mod tests { use crate::audiodevice::{rms_and_peak, AudioChunk, ChunkStats}; diff --git a/src/basicfilters.rs b/src/basicfilters.rs index 67e83253..369bc675 100644 --- a/src/basicfilters.rs +++ b/src/basicfilters.rs @@ -38,6 +38,7 @@ pub struct Volume { chunksize: usize, processing_params: Arc, fader: usize, + volume_limit: f32, } impl Volume { @@ -45,6 +46,7 @@ impl Volume { pub fn new( name: &str, ramp_time_ms: f32, + limit: f32, current_volume: f32, mute: bool, chunksize: usize, @@ -75,6 +77,7 @@ impl Volume { chunksize, processing_params, fader, + volume_limit: limit, } } @@ -91,6 +94,7 @@ impl Volume { Self::new( name, conf.ramp_time(), + conf.limit(), current_volume, mute, chunksize, @@ -126,13 +130,16 @@ impl Volume { let shared_vol = self.processing_params.target_volume(self.fader); let shared_mute = self.processing_params.is_mute(self.fader); + // are we above the set limit? + let target_volume = shared_vol.min(self.volume_limit); + // Volume setting changed - if (shared_vol - self.target_volume).abs() > 0.01 || self.mute != shared_mute { + if (target_volume - self.target_volume).abs() > 0.01 || self.mute != shared_mute { if self.ramptime_in_chunks > 0 { trace!( "starting ramp: {} -> {}, mute: {}", self.current_volume, - shared_vol, + target_volume, shared_mute ); self.ramp_start = self.current_volume; @@ -141,22 +148,22 @@ impl Volume { trace!( "switch volume without ramp: {} -> {}, mute: {}", self.current_volume, - shared_vol, + target_volume, shared_mute ); self.current_volume = if shared_mute { 0.0 } else { - shared_vol as PrcFmt + target_volume as PrcFmt }; self.ramp_step = 0; } - self.target_volume = shared_vol; + self.target_volume = target_volume; self.target_linear_gain = if shared_mute { 0.0 } else { let tempgain: PrcFmt = 10.0; - tempgain.powf(shared_vol as PrcFmt / 20.0) + tempgain.powf(target_volume as PrcFmt / 20.0) }; self.mute = shared_mute; } @@ -167,6 +174,7 @@ impl Volume { // Not in a ramp if self.ramp_step == 0 { + xtrace!("Vol: applying linear gain {}", self.target_linear_gain); for waveform in chunk.waveforms.iter_mut() { for item in waveform.iter_mut() { *item *= self.target_linear_gain; @@ -175,7 +183,7 @@ impl Volume { } // Ramping else if self.ramp_step <= self.ramptime_in_chunks { - trace!("ramp step {}", self.ramp_step); + trace!("Vol: ramp step {}", self.ramp_step); let ramp = self.make_ramp(); self.ramp_step += 1; if self.ramp_step > self.ramptime_in_chunks { @@ -240,6 +248,10 @@ impl Filter for Volume { / (1000.0 * self.chunksize as f32 / self.samplerate as f32)) .round() as usize; self.fader = conf.fader as usize; + self.volume_limit = conf.limit(); + if (self.volume_limit as PrcFmt) < self.current_volume { + self.current_volume = self.volume_limit as PrcFmt; + } } else { // This should never happen unless there is a bug somewhere else panic!("Invalid config change!"); diff --git a/src/bin.rs b/src/bin.rs index ac4b3efd..976d9dce 100644 --- a/src/bin.rs +++ b/src/bin.rs @@ -2,8 +2,6 @@ extern crate alsa; extern crate camillalib; extern crate clap; -#[cfg(feature = "FFTW")] -extern crate fftw; extern crate lazy_static; #[cfg(feature = "pulse-backend")] extern crate libpulse_binding as pulse; @@ -12,7 +10,6 @@ extern crate libpulse_simple_binding as psimple; extern crate parking_lot; extern crate rand; extern crate rand_distr; -#[cfg(not(feature = "FFTW"))] extern crate realfft; extern crate rubato; extern crate serde; @@ -26,7 +23,7 @@ extern crate flexi_logger; #[macro_use] extern crate log; -use clap::{crate_authors, crate_description, crate_version, App, AppSettings, Arg}; +use clap::{crate_authors, crate_description, crate_name, crate_version, Arg, ArgAction, Command}; use crossbeam_channel::select; use parking_lot::{Mutex, RwLock, RwLockUpgradableReadGuard}; use std::env; @@ -105,6 +102,15 @@ pub fn custom_logger_format( ) } +fn parse_gain_value(v: &str) -> Result { + if let Ok(gain) = v.parse::() { + if (-120.0..=20.0).contains(&gain) { + return Ok(gain); + } + } + Err(String::from("Must be a number between -120 and +20")) +} + fn run( shared_configs: SharedConfigs, status_structs: StatusStructs, @@ -144,7 +150,7 @@ fn run( tx_pb, rx_cap, rx_pipeconf, - status_structs.processing, + status_structs.processing.clone(), ); // Playback thread @@ -170,6 +176,7 @@ fn run( tx_status_cap, rx_command_cap, status_structs.capture.clone(), + status_structs.processing.clone(), ) .unwrap(); @@ -391,6 +398,14 @@ fn run( debug!("Capture thread has already exited"); } } + StatusMessage::SetVolume(vol) => { + debug!("SetVolume message to {} dB received", vol); + status_structs.processing.set_target_volume(0, vol); + } + StatusMessage::SetMute(mute) => { + debug!("SetMute message to {} received", mute); + status_structs.processing.set_mute(0, mute); + } }, Err(err) => { warn!("Capture, Playback and Processing threads have exited: {}", err); @@ -425,9 +440,6 @@ fn main_process() -> i32 { if cfg!(feature = "secure-websocket") { features.push("secure-websocket"); } - if cfg!(feature = "FFTW") { - features.push("FFTW"); - } if cfg!(feature = "32bit") { features.push("32bit"); } @@ -441,235 +453,321 @@ fn main_process() -> i32 { let capture_types = format!("Capture: {}", cap_types.join(", ")); let longabout = format!( - "{}\n\n{}\n\nSupported device types:\n{}\n{}", + "{} v{}\n{}\n{}\n\n{}\n\nSupported device types:\n{}\n{}", + crate_name!(), + crate_version!(), + crate_authors!(), crate_description!(), featurelist, capture_types, playback_types ); - let clapapp = App::new("CamillaDSP") + let clapapp = Command::new("CamillaDSP") .version(crate_version!()) - .about(longabout.as_str()) + .about(longabout) .author(crate_authors!()) - .setting(AppSettings::ArgRequiredElseHelp) + //.setting(AppSettings::ArgRequiredElseHelp) .arg( - Arg::with_name("configfile") + Arg::new("configfile") .help("The configuration file to use") .index(1) - //.required(true), - .required_unless_one(&["wait", "statefile"]), + .value_name("CONFIGFILE") + .action(ArgAction::Set) + .value_parser(clap::builder::NonEmptyStringValueParser::new()) + .required_unless_present_any(["wait", "statefile"]), ) .arg( - Arg::with_name("statefile") + Arg::new("statefile") .help("Use the given file to persist the state") - .short("s") + .short('s') .long("statefile") - .takes_value(true) - .display_order(2), + .value_name("STATEFILE") + .action(ArgAction::Set) + .display_order(2) + .value_parser(clap::builder::NonEmptyStringValueParser::new()), ) .arg( - Arg::with_name("check") + Arg::new("check") .help("Check config file and exit") - .short("c") + .short('c') .long("check") - .requires("configfile"), + .requires("configfile") + .action(ArgAction::SetTrue), ) .arg( - Arg::with_name("verbosity") - .short("v") - .multiple(true) - .help("Increase message verbosity"), + Arg::new("verbosity") + .help("Increase message verbosity") + .short('v') + .action(ArgAction::Count), ) .arg( - Arg::with_name("loglevel") - .short("l") + Arg::new("loglevel") + .help("Set log level") + .short('l') .long("loglevel") + .value_name("LOGLEVEL") .display_order(100) - .takes_value(true) - .possible_value("trace") - .possible_value("debug") - .possible_value("info") - .possible_value("warn") - .possible_value("error") - .possible_value("off") - .help("Set log level") - .conflicts_with("verbosity"), + .conflicts_with("verbosity") + .action(ArgAction::Set) + .value_parser(["trace", "debug", "info", "warn", "error", "off"]), ) .arg( - Arg::with_name("logfile") - .short("o") + Arg::new("logfile") + .help("Write logs to the given file path") + .short('o') .long("logfile") - .display_order(100) - .takes_value(true) - .help("Write logs to file"), + .value_name("LOGFILE") + .display_order(101) + .action(ArgAction::Set), ) .arg( - Arg::with_name("gain") - .help("Set initial gain in dB for Volume and Loudness filters") - .short("g") + Arg::new("log_rotate_size") + .help("Rotate log file when the size in bytes exceeds this value") + .long("log_rotate_size") + .value_name("ROTATE_SIZE") + .display_order(102) + .requires("logfile") + .value_parser(clap::value_parser!(u32).range(1000..)) + .action(ArgAction::Set), + ) + .arg( + Arg::new("log_keep_nbr") + .help("Number of previous log files to keep") + .long("log_keep_nbr") + .value_name("KEEP_NBR") + .display_order(103) + .requires("log_rotate_size") + .value_parser(clap::value_parser!(u32)) + .action(ArgAction::Set), + ) + .arg( + Arg::new("gain") + .help("Initial gain in dB for main volume control") + .short('g') .long("gain") - .display_order(200) - .takes_value(true) - .validator(|v: String| -> Result<(), String> { - if let Ok(gain) = v.parse::() { - if (-120.0..=20.0).contains(&gain) { - return Ok(()); - } - } - Err(String::from("Must be a number between -120 and +20")) - }), + .value_name("GAIN") + .display_order(300) + .action(ArgAction::Set) + .value_parser(parse_gain_value), + ) + .arg( + Arg::new("gain1") + .help("Initial gain in dB for Aux1 fader") + .long("gain1") + .value_name("GAIN1") + .display_order(301) + .action(ArgAction::Set) + .value_parser(parse_gain_value), ) .arg( - Arg::with_name("mute") - .help("Start with Volume and Loudness filters muted") - .short("m") + Arg::new("gain2") + .help("Initial gain in dB for Aux2 fader") + .long("gain2") + .value_name("GAIN2") + .display_order(302) + .action(ArgAction::Set) + .value_parser(parse_gain_value), + ) + .arg( + Arg::new("gain3") + .help("Initial gain in dB for Aux3 fader") + .long("gain3") + .value_name("GAIN3") + .display_order(303) + .action(ArgAction::Set) + .value_parser(parse_gain_value), + ) + .arg( + Arg::new("gain4") + .help("Initial gain in dB for Aux4 fader") + .long("gain4") + .value_name("GAIN4") + .display_order(304) + .action(ArgAction::Set) + .value_parser(parse_gain_value), + ) + .arg( + Arg::new("mute") + .help("Start with main volume control muted") + .short('m') .long("mute") - .display_order(200), + .display_order(310) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("mute1") + .help("Start with Aux1 fader muted") + .long("mute1") + .display_order(311) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("mute2") + .help("Start with Aux2 fader muted") + .long("mute2") + .display_order(312) + .action(ArgAction::SetTrue), ) .arg( - Arg::with_name("samplerate") + Arg::new("mute3") + .help("Start with Aux3 fader muted") + .long("mute3") + .display_order(313) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("mute4") + .help("Start with Aux4 fader muted") + .long("mute4") + .display_order(314) + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("samplerate") .help("Override samplerate in config") - .short("r") + .short('r') .long("samplerate") - .display_order(300) - .takes_value(true) - .validator(|v: String| -> Result<(), String> { - if let Ok(rate) = v.parse::() { - if rate > 0 { - return Ok(()); - } - } - Err(String::from("Must be an integer > 0")) - }), + .value_name("SAMPLERATE") + .display_order(400) + .action(ArgAction::Set) + .value_parser(clap::builder::RangedU64ValueParser::::new().range(1..)), ) .arg( - Arg::with_name("channels") + Arg::new("channels") .help("Override number of channels of capture device in config") - .short("n") + .short('n') .long("channels") - .display_order(300) - .takes_value(true) - .validator(|v: String| -> Result<(), String> { - if let Ok(rate) = v.parse::() { - if rate > 0 { - return Ok(()); - } - } - Err(String::from("Must be an integer > 0")) - }), + .value_name("CHANNELS") + .display_order(400) + .action(ArgAction::Set) + .value_parser(clap::builder::RangedU64ValueParser::::new().range(1..)), ) .arg( - Arg::with_name("extra_samples") + Arg::new("extra_samples") .help("Override number of extra samples in config") - .short("e") + .short('e') .long("extra_samples") - .display_order(300) - .takes_value(true) - .validator(|v: String| -> Result<(), String> { - if let Ok(_samples) = v.parse::() { - return Ok(()); - } - Err(String::from("Must be an integer > 0")) - }), + .value_name("EXTRA_SAMPLES") + .display_order(400) + .action(ArgAction::Set) + .value_parser(clap::builder::RangedU64ValueParser::::new().range(1..)), ) .arg( - Arg::with_name("format") - .short("f") + Arg::new("format") + .short('f') .long("format") - .display_order(310) - .takes_value(true) - .possible_value("S16LE") - .possible_value("S24LE") - .possible_value("S24LE3") - .possible_value("S32LE") - .possible_value("FLOAT32LE") - .possible_value("FLOAT64LE") + .value_name("FORMAT") + .display_order(410) + .action(ArgAction::Set) + .value_parser([ + "S16LE", + "S24LE", + "S24LE3", + "S32LE", + "FLOAT32LE", + "FLOAT64LE", + ]) .help("Override sample format of capture device in config"), ); #[cfg(feature = "websocket")] let clapapp = clapapp .arg( - Arg::with_name("port") + Arg::new("port") .help("Port for websocket server") - .short("p") + .short('p') .long("port") + .value_name("PORT") .display_order(200) - .takes_value(true) - .validator(|v: String| -> Result<(), String> { - if let Ok(port) = v.parse::() { - if port > 0 && port < 65535 { - return Ok(()); - } - } - Err(String::from("Must be an integer between 0 and 65535")) - }), + .action(ArgAction::Set) + .value_parser(clap::builder::RangedU64ValueParser::::new().range(0..65535)), ) .arg( - Arg::with_name("address") + Arg::new("address") .help("IP address to bind websocket server to") - .short("a") + .short('a') .long("address") + .value_name("ADDRESS") .display_order(200) - .takes_value(true) + .action(ArgAction::Set) .requires("port") - .validator(|val: String| -> Result<(), String> { + .value_parser(|val: &str| -> Result { if val.parse::().is_ok() { - return Ok(()); + return Ok(val.to_string()); } Err(String::from("Must be a valid IP address")) }), ) .arg( - Arg::with_name("wait") - .short("w") + Arg::new("wait") + .short('w') .long("wait") + .display_order(200) .help("Wait for config from websocket") - .requires("port"), + .requires("port") + .action(ArgAction::SetTrue), ); #[cfg(feature = "secure-websocket")] let clapapp = clapapp .arg( - Arg::with_name("cert") - .long("cert") - .takes_value(true) + Arg::new("cert") .help("Path to .pfx/.p12 certificate file") + .long("cert") + .value_name("CERT") + .display_order(220) + .action(ArgAction::Set) .requires("port"), ) .arg( - Arg::with_name("pass") - .long("pass") - .takes_value(true) + Arg::new("pass") .help("Password for .pfx/.p12 certificate file") + .long("pass") + .value_name("PASS") + .display_order(220) + .action(ArgAction::Set) .requires("port"), ); let matches = clapapp.get_matches(); - let mut loglevel = match matches.occurrences_of("verbosity") { + let mut loglevel = match matches.get_count("verbosity") { 0 => "info", 1 => "debug", 2 => "trace", _ => "trace", }; - if let Some(level) = matches.value_of("loglevel") { + if let Some(level) = matches.get_one::("loglevel") { loglevel = level; } - let logger = if let Some(logfile) = matches.value_of("logfile") { + let logger = if let Some(logfile) = matches.get_one::("logfile") { let mut path = PathBuf::from(logfile); if !path.is_absolute() { let mut fullpath = std::env::current_dir().unwrap(); fullpath.push(path); path = fullpath; } - flexi_logger::Logger::try_with_str(loglevel) + let mut logger = flexi_logger::Logger::try_with_str(loglevel) .unwrap() .format(custom_logger_format) .log_to_file(flexi_logger::FileSpec::try_from(path).unwrap()) - .write_mode(flexi_logger::WriteMode::Async) - .start() - .unwrap() + .write_mode(flexi_logger::WriteMode::Async); + + let cleanup = if let Some(keep_nbr) = matches.get_one::("log_keep_nbr") { + flexi_logger::Cleanup::KeepLogFiles(*keep_nbr as usize) + } else { + flexi_logger::Cleanup::Never + }; + + if let Some(rotate_size) = matches.get_one::("log_rotate_size") { + logger = logger.rotate( + flexi_logger::Criterion::Size(*rotate_size as u64), + flexi_logger::Naming::Timestamps, + cleanup, + ); + } + + logger.start().unwrap() } else { flexi_logger::Logger::try_with_str(loglevel) .unwrap() @@ -701,25 +799,19 @@ fn main_process() -> i32 { #[cfg(target_os = "windows")] wasapi::initialize_mta().unwrap(); - let mut configname = matches.value_of("configfile").map(|path| path.to_string()); + let mut configname = matches.get_one::("configfile").cloned(); { let mut overrides = config::OVERRIDES.write(); - overrides.samplerate = matches - .value_of("samplerate") - .map(|s| s.parse::().unwrap()); - overrides.extra_samples = matches - .value_of("extra_samples") - .map(|s| s.parse::().unwrap()); - overrides.channels = matches - .value_of("channels") - .map(|s| s.parse::().unwrap()); + overrides.samplerate = matches.get_one::("samplerate").copied(); + overrides.extra_samples = matches.get_one::("extra_samples").copied(); + overrides.channels = matches.get_one::("channels").copied(); overrides.sample_format = matches - .value_of("format") + .get_one::("format") .map(|s| config::SampleFormat::from_name(s).unwrap()); } - let statefilename = matches.value_of("statefile").map(|path| path.to_string()); + let statefilename: Option = matches.get_one::("statefile").cloned(); let state = if let Some(filename) = &statefilename { statefile::load_state(filename) } else { @@ -727,28 +819,41 @@ fn main_process() -> i32 { }; debug!("Loaded state: {state:?}"); - let initial_volumes = - if let Some(v) = matches.value_of("gain").map(|s| s.parse::().unwrap()) { - debug!("Using command line argument for initial volume"); - [v, v, v, v, v] - } else if let Some(s) = &state { - debug!("Using statefile for initial volume"); - s.volume - } else { - debug!("Using default initial volume"); - [ - ProcessingParameters::DEFAULT_VOLUME, - ProcessingParameters::DEFAULT_VOLUME, - ProcessingParameters::DEFAULT_VOLUME, - ProcessingParameters::DEFAULT_VOLUME, - ProcessingParameters::DEFAULT_VOLUME, - ] - }; + let mut initial_volumes = if let Some(s) = &state { + debug!("Using statefile for initial volume"); + s.volume + } else { + debug!("Using default initial volume"); + [ + ProcessingParameters::DEFAULT_VOLUME, + ProcessingParameters::DEFAULT_VOLUME, + ProcessingParameters::DEFAULT_VOLUME, + ProcessingParameters::DEFAULT_VOLUME, + ProcessingParameters::DEFAULT_VOLUME, + ] + }; + if let Some(v) = matches.get_one::("gain") { + debug!("Using command line argument for initial main volume"); + initial_volumes[0] = *v; + } + if let Some(v) = matches.get_one::("gain1") { + debug!("Using command line argument for initial Aux1 volume"); + initial_volumes[1] = *v; + } + if let Some(v) = matches.get_one::("gain2") { + debug!("Using command line argument for initial Aux2 volume"); + initial_volumes[2] = *v; + } + if let Some(v) = matches.get_one::("gain3") { + debug!("Using command line argument for initial Aux3 volume"); + initial_volumes[3] = *v; + } + if let Some(v) = matches.get_one::("gain4") { + debug!("Using command line argument for initial Aux4 volume"); + initial_volumes[4] = *v; + } - let initial_mutes = if matches.is_present("mute") { - debug!("Using command line argument for initial mute"); - [true, true, true, true, true] - } else if let Some(s) = &state { + let mut initial_mutes = if let Some(s) = &state { debug!("Using statefile for initial mute"); s.mute } else { @@ -761,13 +866,33 @@ fn main_process() -> i32 { ProcessingParameters::DEFAULT_MUTE, ] }; + if matches.get_flag("mute") { + debug!("Using command line argument for initial main mute"); + initial_mutes[0] = true; + } + if matches.get_flag("mute1") { + debug!("Using command line argument for initial Aux1 mute"); + initial_mutes[1] = true; + } + if matches.get_flag("mute2") { + debug!("Using command line argument for initial Aux2 mute"); + initial_mutes[2] = true; + } + if matches.get_flag("mute3") { + debug!("Using command line argument for initial Aux3 mute"); + initial_mutes[3] = true; + } + if matches.get_flag("mute4") { + debug!("Using command line argument for initial Aux4 mute"); + initial_mutes[4] = true; + } debug!("Initial mute: {initial_mutes:?}"); debug!("Initial volume: {initial_volumes:?}"); debug!("Read config file {:?}", configname); - if matches.is_present("check") { + if matches.get_flag("check") { match config::load_validate_config(&configname.unwrap()) { Ok(_) => { println!("Config is valid"); @@ -783,7 +908,7 @@ fn main_process() -> i32 { if configname.is_none() { if let Some(s) = &state { - configname = s.config_path.clone(); + configname.clone_from(&s.config_path) } } @@ -900,7 +1025,7 @@ fn main_process() -> i32 { } }); - let wait = matches.is_present("wait"); + let wait = matches.get_flag("wait"); let capture_status = Arc::new(RwLock::new(CaptureStatus { measured_samplerate: 0, @@ -941,9 +1066,12 @@ fn main_process() -> i32 { let active_config_path_clone = active_config_path.clone(); let unsaved_state_changes = Arc::new(AtomicBool::new(false)); - if let Some(port_str) = matches.value_of("port") { - let serveraddress = matches.value_of("address").unwrap_or("127.0.0.1"); - let serverport = port_str.parse::().unwrap(); + if let Some(port) = matches.get_one::("port") { + let serveraddress = matches + .get_one::("address") + .cloned() + .unwrap_or("127.0.0.1".to_string()); + let serverport = *port; let shared_data = socketserver::SharedData { active_config: active_config.clone(), @@ -960,11 +1088,11 @@ fn main_process() -> i32 { }; let server_params = socketserver::ServerParameters { port: serverport, - address: serveraddress, + address: &serveraddress, #[cfg(feature = "secure-websocket")] - cert_file: matches.value_of("cert"), + cert_file: matches.get_one::("cert").map(|x| x.as_str()), #[cfg(feature = "secure-websocket")] - cert_pass: matches.value_of("pass"), + cert_pass: matches.get_one::("pass").map(|x| x.as_str()), }; socketserver::start_server(server_params, shared_data); } diff --git a/src/compressor.rs b/src/compressor.rs index b3744cf2..bec6d978 100644 --- a/src/compressor.rs +++ b/src/compressor.rs @@ -151,8 +151,6 @@ impl Processor for Compressor { } fn update_parameters(&mut self, config: config::Processor) { - // TODO remove when there is more than one type of Processor. - #[allow(irrefutable_let_patterns)] if let config::Processor::Compressor { parameters: config, .. } = config diff --git a/src/config.rs b/src/config.rs index 9e117f6d..ad334032 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,8 @@ use crate::compressor; use crate::filters; use crate::mixer; +use crate::noisegate; +use crate::wavtools::{find_data_in_wav_stream, WavParams}; use parking_lot::RwLock; use serde::{de, Deserialize, Serialize}; //use serde_with; @@ -16,6 +18,7 @@ use std::path::{Path, PathBuf}; use crate::PrcFmt; type Res = Result>; +#[derive(Clone)] pub struct Overrides { pub samplerate: Option, pub sample_format: Option, @@ -119,7 +122,67 @@ impl fmt::Display for SampleFormat { } } -#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)] +#[allow(clippy::upper_case_acronyms)] +#[derive(Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq)] +#[serde(deny_unknown_fields)] +// Similar to SampleFormat, but also includes TEXT +pub enum FileFormat { + TEXT, + S16LE, + S24LE, + S24LE3, + S32LE, + FLOAT32LE, + FLOAT64LE, +} + +impl FileFormat { + pub fn bits_per_sample(&self) -> usize { + match self { + FileFormat::S16LE => 16, + FileFormat::S24LE => 24, + FileFormat::S24LE3 => 24, + FileFormat::S32LE => 32, + FileFormat::FLOAT32LE => 32, + FileFormat::FLOAT64LE => 64, + FileFormat::TEXT => 0, + } + } + + pub fn bytes_per_sample(&self) -> usize { + match self { + FileFormat::S16LE => 2, + FileFormat::S24LE => 4, + FileFormat::S24LE3 => 3, + FileFormat::S32LE => 4, + FileFormat::FLOAT32LE => 4, + FileFormat::FLOAT64LE => 8, + FileFormat::TEXT => 0, + } + } + + pub fn from_sample_format(sample_format: &SampleFormat) -> Self { + match sample_format { + SampleFormat::S16LE => FileFormat::S16LE, + SampleFormat::S24LE => FileFormat::S24LE, + SampleFormat::S24LE3 => FileFormat::S24LE3, + SampleFormat::S32LE => FileFormat::S32LE, + SampleFormat::FLOAT32LE => FileFormat::FLOAT32LE, + SampleFormat::FLOAT64LE => FileFormat::FLOAT64LE, + } + } +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +#[serde(tag = "type")] +pub enum Signal { + Sine { freq: f64, level: PrcFmt }, + Square { freq: f64, level: PrcFmt }, + WhiteNoise { level: PrcFmt }, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] #[serde(tag = "type")] pub enum CaptureDevice { @@ -129,7 +192,16 @@ pub enum CaptureDevice { #[serde(deserialize_with = "validate_nonzero_usize")] channels: usize, device: String, - format: SampleFormat, + #[serde(default)] + format: Option, + #[serde(default)] + stop_on_inactive: Option, + #[serde(default)] + link_volume_control: Option, + #[serde(default)] + link_mute_control: Option, + #[serde(default)] + labels: Option>>, }, #[cfg(all(target_os = "linux", feature = "bluez-backend"))] #[serde(alias = "BLUEZ", alias = "bluez")] @@ -141,9 +213,11 @@ pub enum CaptureDevice { channels: usize, device: String, format: SampleFormat, + #[serde(default)] + labels: Option>>, }, - #[serde(alias = "FILE", alias = "file")] - File(CaptureDeviceFile), + RawFile(CaptureDeviceRawFile), + WavFile(CaptureDeviceWavFile), #[serde(alias = "STDIN", alias = "stdin")] Stdin(CaptureDeviceStdin), #[cfg(target_os = "macos")] @@ -167,6 +241,15 @@ pub enum CaptureDevice { #[serde(deserialize_with = "validate_nonzero_usize")] channels: usize, device: String, + #[serde(default)] + labels: Option>>, + }, + SignalGenerator { + #[serde(deserialize_with = "validate_nonzero_usize")] + channels: usize, + signal: Signal, + #[serde(default)] + labels: Option>>, }, } @@ -179,7 +262,10 @@ impl CaptureDevice { CaptureDevice::Bluez(dev) => dev.channels, #[cfg(feature = "pulse-backend")] CaptureDevice::Pulse { channels, .. } => *channels, - CaptureDevice::File(dev) => dev.channels, + CaptureDevice::RawFile(dev) => dev.channels, + CaptureDevice::WavFile(dev) => { + dev.wav_info().map(|info| info.channels).unwrap_or_default() + } CaptureDevice::Stdin(dev) => dev.channels, #[cfg(target_os = "macos")] CaptureDevice::CoreAudio(dev) => dev.channels, @@ -196,13 +282,14 @@ impl CaptureDevice { ) ))] CaptureDevice::Jack { channels, .. } => *channels, + CaptureDevice::SignalGenerator { channels, .. } => *channels, } } } #[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)] #[serde(deny_unknown_fields)] -pub struct CaptureDeviceFile { +pub struct CaptureDeviceRawFile { #[serde(deserialize_with = "validate_nonzero_usize")] pub channels: usize, pub filename: String, @@ -213,9 +300,11 @@ pub struct CaptureDeviceFile { pub skip_bytes: Option, #[serde(default)] pub read_bytes: Option, + #[serde(default)] + pub labels: Option>>, } -impl CaptureDeviceFile { +impl CaptureDeviceRawFile { pub fn extra_samples(&self) -> usize { self.extra_samples.unwrap_or_default() } @@ -227,6 +316,35 @@ impl CaptureDeviceFile { } } +#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct CaptureDeviceWavFile { + pub filename: String, + #[serde(default)] + pub extra_samples: Option, + #[serde(default)] + pub labels: Option>>, +} + +impl CaptureDeviceWavFile { + pub fn extra_samples(&self) -> usize { + self.extra_samples.unwrap_or_default() + } + + pub fn wav_info(&self) -> Res { + let fname = &self.filename; + let f = match File::open(fname) { + Ok(f) => f, + Err(err) => { + let msg = format!("Could not open input file '{fname}'. Reason: {err}"); + return Err(ConfigError::new(&msg).into()); + } + }; + let file = BufReader::new(&f); + find_data_in_wav_stream(file) + } +} + #[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)] #[serde(deny_unknown_fields)] pub struct CaptureDeviceStdin { @@ -239,6 +357,8 @@ pub struct CaptureDeviceStdin { pub skip_bytes: Option, #[serde(default)] pub read_bytes: Option, + #[serde(default)] + pub labels: Option>>, } impl CaptureDeviceStdin { @@ -265,6 +385,8 @@ pub struct CaptureDeviceBluez { // from D-Bus properties pub format: SampleFormat, pub channels: usize, + #[serde(default)] + pub labels: Option>>, } #[cfg(all(target_os = "linux", feature = "bluez-backend"))] @@ -286,6 +408,8 @@ pub struct CaptureDeviceWasapi { exclusive: Option, #[serde(default)] loopback: Option, + #[serde(default)] + pub labels: Option>>, } #[cfg(target_os = "windows")] @@ -308,6 +432,8 @@ pub struct CaptureDeviceCA { pub device: Option, #[serde(default)] pub format: Option, + #[serde(default)] + pub labels: Option>>, } #[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)] @@ -320,7 +446,8 @@ pub enum PlaybackDevice { #[serde(deserialize_with = "validate_nonzero_usize")] channels: usize, device: String, - format: SampleFormat, + #[serde(default)] + format: Option, }, #[cfg(feature = "pulse-backend")] #[serde(alias = "PULSE", alias = "pulse")] @@ -336,12 +463,16 @@ pub enum PlaybackDevice { channels: usize, filename: String, format: SampleFormat, + #[serde(default)] + wav_header: Option, }, #[serde(alias = "STDOUT", alias = "stdout")] Stdout { #[serde(deserialize_with = "validate_nonzero_usize")] channels: usize, format: SampleFormat, + #[serde(default)] + wav_header: Option, }, #[cfg(target_os = "macos")] #[serde(alias = "COREAUDIO", alias = "coreaudio")] @@ -463,6 +594,12 @@ pub struct Devices { pub rate_measure_interval: Option, #[serde(default)] pub volume_ramp_time: Option, + #[serde(default)] + pub volume_limit: Option, + #[serde(default)] + pub multithreaded: Option, + #[serde(default)] + pub worker_threads: Option, } // Getters for all the defaults @@ -506,6 +643,18 @@ impl Devices { pub fn ramp_time(&self) -> f32 { self.volume_ramp_time.unwrap_or(400.0) } + + pub fn volume_limit(&self) -> f32 { + self.volume_limit.unwrap_or(50.0) + } + + pub fn multithreaded(&self) -> bool { + self.multithreaded.unwrap_or(false) + } + + pub fn worker_threads(&self) -> usize { + self.worker_threads.unwrap_or(0) + } } #[derive(Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq)] @@ -626,45 +775,6 @@ pub enum Filter { }, } -#[allow(clippy::upper_case_acronyms)] -#[derive(Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq)] -#[serde(deny_unknown_fields)] -pub enum FileFormat { - TEXT, - S16LE, - S24LE, - S24LE3, - S32LE, - FLOAT32LE, - FLOAT64LE, -} - -impl FileFormat { - pub fn bits_per_sample(&self) -> usize { - match self { - FileFormat::S16LE => 16, - FileFormat::S24LE => 24, - FileFormat::S24LE3 => 24, - FileFormat::S32LE => 32, - FileFormat::FLOAT32LE => 32, - FileFormat::FLOAT64LE => 64, - FileFormat::TEXT => 0, - } - } - - pub fn bytes_per_sample(&self) -> usize { - match self { - FileFormat::S16LE => 2, - FileFormat::S24LE => 4, - FileFormat::S24LE3 => 3, - FileFormat::S32LE => 4, - FileFormat::FLOAT32LE => 4, - FileFormat::FLOAT64LE => 8, - FileFormat::TEXT => 0, - } - } -} - #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(tag = "type")] #[serde(deny_unknown_fields)] @@ -902,12 +1012,17 @@ pub struct VolumeParameters { #[serde(default)] pub ramp_time: Option, pub fader: VolumeFader, + pub limit: Option, } impl VolumeParameters { pub fn ramp_time(&self) -> f32 { self.ramp_time.unwrap_or(400.0) } + + pub fn limit(&self) -> f32 { + self.limit.unwrap_or(50.0) + } } #[derive(Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq)] @@ -1126,6 +1241,8 @@ pub struct Mixer { pub description: Option, pub channels: MixerChannels, pub mapping: Vec, + #[serde(default)] + pub labels: Option>>, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -1137,6 +1254,11 @@ pub enum Processor { description: Option, parameters: CompressorParameters, }, + NoiseGate { + #[serde(default)] + description: Option, + parameters: NoiseGateParameters, + }, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -1177,6 +1299,30 @@ impl CompressorParameters { } } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct NoiseGateParameters { + pub channels: usize, + #[serde(default)] + pub monitor_channels: Option>, + #[serde(default)] + pub process_channels: Option>, + pub attack: PrcFmt, + pub release: PrcFmt, + pub threshold: PrcFmt, + pub attenuation: PrcFmt, +} + +impl NoiseGateParameters { + pub fn monitor_channels(&self) -> Vec { + self.monitor_channels.clone().unwrap_or_default() + } + + pub fn process_channels(&self) -> Vec { + self.process_channels.clone().unwrap_or_default() + } +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct LimiterParameters { @@ -1202,6 +1348,7 @@ pub enum PipelineStep { } #[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)] +#[serde(deny_unknown_fields)] pub struct PipelineStepMixer { pub name: String, #[serde(default)] @@ -1217,8 +1364,10 @@ impl PipelineStepMixer { } #[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)] +#[serde(deny_unknown_fields)] pub struct PipelineStepFilter { - pub channel: usize, + #[serde(default)] + pub channels: Option>, pub names: Vec, #[serde(default)] pub description: Option, @@ -1233,6 +1382,7 @@ impl PipelineStepFilter { } #[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)] +#[serde(deny_unknown_fields)] pub struct PipelineStepProcessor { pub name: String, #[serde(default)] @@ -1283,7 +1433,7 @@ pub fn load_config(filename: &str) -> Res { let file = match File::open(filename) { Ok(f) => f, Err(err) => { - let msg = format!("Could not open config file '{filename}'. Error: {err}"); + let msg = format!("Could not open config file '{filename}'. Reason: {err}"); return Err(ConfigError::new(&msg).into()); } }; @@ -1292,7 +1442,7 @@ pub fn load_config(filename: &str) -> Res { let _number_of_bytes: usize = match buffered_reader.read_to_string(&mut contents) { Ok(number_of_bytes) => number_of_bytes, Err(err) => { - let msg = format!("Could not read config file '{filename}'. Error: {err}"); + let msg = format!("Could not read config file '{filename}'. Reason: {err}"); return Err(ConfigError::new(&msg).into()); } }; @@ -1303,15 +1453,24 @@ pub fn load_config(filename: &str) -> Res { return Err(ConfigError::new(&msg).into()); } }; - //Ok(configuration) - //apply_overrides(&mut configuration); - //replace_tokens_in_config(&mut configuration); - //replace_relative_paths_in_config(&mut configuration, filename); Ok(configuration) } fn apply_overrides(configuration: &mut Configuration) { - let overrides = OVERRIDES.read(); + let mut overrides = OVERRIDES.read().clone(); + // Only one match arm for now, might be more later. + #[allow(clippy::single_match)] + match &configuration.devices.capture { + CaptureDevice::WavFile(dev) => { + if let Ok(wav_info) = dev.wav_info() { + overrides.channels = Some(wav_info.channels); + overrides.sample_format = Some(wav_info.sample_format); + overrides.samplerate = Some(wav_info.sample_rate); + debug!("Updating overrides with values from wav input file, rate {}, format: {}, channels: {}", wav_info.sample_rate, wav_info.sample_format, wav_info.channels); + } + } + _ => {} + } if let Some(rate) = overrides.samplerate { let cfg_rate = configuration.devices.samplerate; let cfg_chunksize = configuration.devices.chunksize; @@ -1331,7 +1490,7 @@ fn apply_overrides(configuration: &mut Configuration) { configuration.devices.chunksize = scaled_chunksize; #[allow(unreachable_patterns)] match &mut configuration.devices.capture { - CaptureDevice::File(dev) => { + CaptureDevice::RawFile(dev) => { let new_extra = dev.extra_samples() * rate / cfg_rate; debug!( "Scale extra samples: {} -> {}", @@ -1364,7 +1523,7 @@ fn apply_overrides(configuration: &mut Configuration) { debug!("Apply override for extra_samples: {}", extra); #[allow(unreachable_patterns)] match &mut configuration.devices.capture { - CaptureDevice::File(dev) => { + CaptureDevice::RawFile(dev) => { dev.extra_samples = Some(extra); } CaptureDevice::Stdin(dev) => { @@ -1376,9 +1535,10 @@ fn apply_overrides(configuration: &mut Configuration) { if let Some(chans) = overrides.channels { debug!("Apply override for capture channels: {}", chans); match &mut configuration.devices.capture { - CaptureDevice::File(dev) => { + CaptureDevice::RawFile(dev) => { dev.channels = chans; } + CaptureDevice::WavFile(_dev) => {} CaptureDevice::Stdin(dev) => { dev.channels = chans; } @@ -1415,20 +1575,24 @@ fn apply_overrides(configuration: &mut Configuration) { CaptureDevice::Jack { channels, .. } => { *channels = chans; } + CaptureDevice::SignalGenerator { channels, .. } => { + *channels = chans; + } } } if let Some(fmt) = overrides.sample_format { debug!("Apply override for capture sample format: {}", fmt); match &mut configuration.devices.capture { - CaptureDevice::File(dev) => { + CaptureDevice::RawFile(dev) => { dev.format = fmt; } + CaptureDevice::WavFile(_dev) => {} CaptureDevice::Stdin(dev) => { dev.format = fmt; } #[cfg(target_os = "linux")] CaptureDevice::Alsa { format, .. } => { - *format = fmt; + *format = Some(fmt); } #[cfg(all(target_os = "linux", feature = "bluez-backend"))] CaptureDevice::Bluez(dev) => { @@ -1459,6 +1623,7 @@ fn apply_overrides(configuration: &mut Configuration) { CaptureDevice::Jack { .. } => { error!("Not possible to override capture format for Jack, ignoring"); } + CaptureDevice::SignalGenerator { .. } => {} } } } @@ -1635,7 +1800,7 @@ pub fn config_diff(currentconf: &Configuration, newconf: &Configuration) -> Conf } if let (Some(newprocs), Some(oldprocs)) = (&newconf.processors, ¤tconf.processors) { for (proc, params) in newprocs { - // The pipeline didn't change, any added compressor isn't included and can be skipped + // The pipeline didn't change, any added processor isn't included and can be skipped if let Some(current_proc) = oldprocs.get(proc) { if params != current_proc { processors.push(proc.to_string()); @@ -1658,12 +1823,17 @@ pub fn validate_config(conf: &mut Configuration, filename: Option<&str>) -> Res< if let Some(fname) = filename { replace_relative_paths_in_config(conf, fname); } + #[cfg(target_os = "linux")] + let target_level_limit = if matches!(conf.devices.playback, PlaybackDevice::Alsa { .. }) { + 4 * conf.devices.chunksize + } else { + 2 * conf.devices.chunksize + }; + #[cfg(not(target_os = "linux"))] + let target_level_limit = 2 * conf.devices.chunksize; - if conf.devices.target_level() >= 2 * conf.devices.chunksize { - let msg = format!( - "target_level can't be larger than {}", - 2 * conf.devices.chunksize - ); + if conf.devices.target_level() > target_level_limit { + let msg = format!("target_level cannot be larger than {}", target_level_limit); return Err(ConfigError::new(&msg).into()); } if let Some(period) = conf.devices.adjust_period { @@ -1686,6 +1856,12 @@ pub fn validate_config(conf: &mut Configuration, filename: Option<&str>) -> Res< if conf.devices.ramp_time() < 0.0 { return Err(ConfigError::new("Volume ramp time cannot be negative").into()); } + if conf.devices.volume_limit() > 50.0 { + return Err(ConfigError::new("Volume limit cannot be above +50 dB").into()); + } + if conf.devices.volume_limit() < -150.0 { + return Err(ConfigError::new("Volume limit cannot be less than -150 dB").into()); + } #[cfg(target_os = "windows")] if let CaptureDevice::Wasapi(dev) = &conf.devices.capture { if dev.format == SampleFormat::FLOAT64LE { @@ -1767,6 +1943,31 @@ pub fn validate_config(conf: &mut Configuration, filename: Option<&str>) -> Res< .into()); } } + if let CaptureDevice::RawFile(dev) = &conf.devices.capture { + let fname = &dev.filename; + match File::open(fname) { + Ok(f) => f, + Err(err) => { + let msg = format!("Could not open input file '{fname}'. Reason: {err}"); + return Err(ConfigError::new(&msg).into()); + } + }; + } + if let CaptureDevice::WavFile(dev) = &conf.devices.capture { + let fname = &dev.filename; + let f = match File::open(fname) { + Ok(f) => f, + Err(err) => { + let msg = format!("Could not open input file '{fname}'. Reason: {err}"); + return Err(ConfigError::new(&msg).into()); + } + }; + let file = BufReader::new(&f); + let _wav_info = find_data_in_wav_stream(file).map_err(|err| { + let msg = format!("Error reading wav file '{fname}'. Reason: {err}"); + ConfigError::new(&msg) + })?; + } let mut num_channels = conf.devices.capture.channels(); let fs = conf.devices.samplerate; if let Some(pipeline) = &conf.pipeline { @@ -1807,9 +2008,20 @@ pub fn validate_config(conf: &mut Configuration, filename: Option<&str>) -> Res< } PipelineStep::Filter(step) => { if !step.is_bypassed() { - if step.channel >= num_channels { - let msg = format!("Use of non existing channel {}", step.channel); - return Err(ConfigError::new(&msg).into()); + if let Some(channels) = &step.channels { + for channel in channels { + if *channel >= num_channels { + let msg = format!("Use of non existing channel {}", channel); + return Err(ConfigError::new(&msg).into()); + } + } + for idx in 1..channels.len() { + if channels[idx..].contains(&channels[idx - 1]) { + let msg = + format!("Use of duplicated channel {}", &channels[idx - 1]); + return Err(ConfigError::new(&msg).into()); + } + } } for name in &step.names { if let Some(filters) = &conf.filters { @@ -1860,6 +2072,26 @@ pub fn validate_config(conf: &mut Configuration, filename: Option<&str>) -> Res< } } } + Processor::NoiseGate { parameters, .. } => { + let channels = parameters.channels; + if channels != num_channels { + let msg = format!( + "NoiseGate '{}' has wrong number of channels. Expected {}, found {}.", + step.name, num_channels, channels + ); + return Err(ConfigError::new(&msg).into()); + } + match noisegate::validate_noise_gate(parameters) { + Ok(_) => {} + Err(err) => { + let msg = format!( + "Invalid noise gate '{}'. Reason: {}", + step.name, err + ); + return Err(ConfigError::new(&msg).into()); + } + } + } } } } else { diff --git a/src/conversions.rs b/src/conversions.rs index ec08cc2e..559b5757 100644 --- a/src/conversions.rs +++ b/src/conversions.rs @@ -61,6 +61,7 @@ pub fn chunk_to_buffer_rawbytes( } clipped += PrcFmt::write_samples(&nextframe, &mut cursor, &rawformat).unwrap(); } + xtrace!("Convert, nbr clipped: {}, peak: {}", clipped, peak); if clipped > 0 { warn!( "Clipping detected, {} samples clipped, peak +{:.2} dB ({:.1}%)", diff --git a/src/coreaudiodevice.rs b/src/coreaudiodevice.rs index 8e093fa9..d04daf09 100644 --- a/src/coreaudiodevice.rs +++ b/src/coreaudiodevice.rs @@ -3,6 +3,7 @@ use crate::config; use crate::config::{ConfigError, SampleFormat}; use crate::conversions::{buffer_to_chunk_rawbytes, chunk_to_buffer_rawbytes}; use crate::countertimer; +use crate::helpers::PIRateController; use crossbeam_channel::{bounded, TryRecvError, TrySendError}; use dispatch::Semaphore; use parking_lot::{RwLock, RwLockUpgradableReadGuard}; @@ -12,16 +13,15 @@ use std::ffi::CStr; use std::mem; use std::os::raw::{c_char, c_void}; use std::ptr::{null, null_mut}; -use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::mpsc; -use std::sync::{Arc, Barrier}; +use std::sync::{Arc, Barrier, Mutex}; use std::thread; use std::time::Duration; use coreaudio::audio_unit::audio_format::LinearPcmFlags; use coreaudio::audio_unit::macos_helpers::{ audio_unit_from_device_id, find_matching_physical_format, get_audio_device_ids, - get_default_device_id, get_device_id_from_name, get_device_name, get_hogging_pid, + get_audio_device_supports_scope, get_default_device_id, get_device_name, get_hogging_pid, get_supported_physical_stream_formats, set_device_physical_stream_format, set_device_sample_rate, toggle_hog_mode, AliveListener, RateListener, }; @@ -32,8 +32,13 @@ use coreaudio::error::Error as CoreAudioError; use coreaudio::sys::*; +use audio_thread_priority::{ + demote_current_thread_from_real_time, promote_current_thread_to_real_time, +}; + use crate::CommandMessage; use crate::PrcFmt; +use crate::ProcessingParameters; use crate::ProcessingState; use crate::Res; use crate::StatusMessage; @@ -62,6 +67,7 @@ fn take_ownership(device_id: AudioDeviceID) -> Res { } fn release_ownership(device_id: AudioDeviceID) -> Res<()> { + trace!("Releasing any device ownership for device id {}", device_id); let device_owner_pid = match get_hogging_pid(device_id) { Ok(pid) => pid, Err(CoreAudioError::AudioCodec(AudioCodecError::UnknownProperty)) => return Ok(()), @@ -84,6 +90,26 @@ fn release_ownership(device_id: AudioDeviceID) -> Res<()> { Ok(()) } +/// Find the device id for a device name and scope (input or output). +/// Some devices are listed as two device ids with the same name, +/// where one supports playback and the other capture. +pub fn get_device_id_from_name_and_scope(name: &str, input: bool) -> Option { + let scope = match input { + false => Scope::Output, + true => Scope::Input, + }; + if let Ok(all_ids) = get_audio_device_ids() { + return all_ids + .iter() + .find(|id| { + get_device_name(**id).unwrap_or_default() == name + && get_audio_device_supports_scope(**id, scope).unwrap_or_default() + }) + .copied(); + } + None +} + #[derive(Clone, Debug)] pub struct CoreaudioPlaybackDevice { pub devname: Option, @@ -171,7 +197,7 @@ fn open_coreaudio_playback( ) -> Res<(AudioUnit, AudioDeviceID)> { let device_id = if let Some(name) = devname { trace!("Available playback devices: {:?}", list_device_names(false)); - match get_device_id_from_name(name) { + match get_device_id_from_name_and_scope(name, false) { Some(dev) => dev, None => { let msg = format!("Could not find playback device '{name}'"); @@ -187,10 +213,12 @@ fn open_coreaudio_playback( } } }; + trace!("Playback device id: {}", device_id); let mut audio_unit = audio_unit_from_device_id(device_id, false) .map_err(|e| ConfigError::new(&format!("{e}")))?; + trace!("Created playback audio unit"); if exclusive { take_ownership(device_id)?; } else { @@ -230,6 +258,7 @@ fn open_coreaudio_playback( return Err(ConfigError::new(msg).into()); } } else { + trace!("Set playback device sample rate"); set_device_sample_rate(device_id, samplerate as f64) .map_err(|e| ConfigError::new(&format!("{e}")))?; } @@ -259,7 +288,7 @@ fn open_coreaudio_capture( ) -> Res<(AudioUnit, AudioDeviceID)> { let device_id = if let Some(name) = devname { debug!("Available capture devices: {:?}", list_device_names(true)); - match get_device_id_from_name(name) { + match get_device_id_from_name_and_scope(name, true) { Some(dev) => dev, None => { let msg = format!("Could not find capture device '{name}'"); @@ -363,10 +392,10 @@ impl PlaybackDevice for CoreaudioPlaybackDevice { .name("CoreaudioPlayback".to_string()) .spawn(move || { // Devices typically request around 1000 frames per buffer, set a reasonable capacity for the channel - let channel_capacity = 8 * 1024 / chunksize + 1; + let channel_capacity = 8 * 1024 / chunksize + 3; debug!("Using a playback channel capacity of {channel_capacity} chunks."); let (tx_dev, rx_dev) = bounded(channel_capacity); - let buffer_fill = Arc::new(AtomicUsize::new(0)); + let buffer_fill = Arc::new(Mutex::new(countertimer::DeviceBufferEstimator::new(samplerate))); let buffer_fill_clone = buffer_fill.clone(); let mut buffer_avg = countertimer::Averager::new(); let mut timer = countertimer::Stopwatch::new(); @@ -375,11 +404,9 @@ impl PlaybackDevice for CoreaudioPlaybackDevice { peak: vec![0.0; channels], }; let blockalign = 4 * channels; - // Rough guess of the number of frames per callback. - let callback_frames = 512; - // TODO check if always 512! - //trace!("Estimated playback callback period to {} frames", callback_frames); + let mut rate_controller = PIRateController::new_with_default_gains(samplerate, adjust_period as f64, target_level); + let mut rate_adjust_value = 1.0; trace!("Build output stream"); let mut conversion_result; let mut sample_queue: VecDeque = @@ -439,16 +466,11 @@ impl PlaybackDevice for CoreaudioPlaybackDevice { let byte = sample_queue.pop_front().unwrap_or(0); *bufferbyte = byte; } - let mut curr_buffer_fill = + let curr_buffer_fill = sample_queue.len() / blockalign + rx_dev.len() * chunksize; - // Reduce the measured buffer fill by approximtely one callback size - // to force a larger. - if curr_buffer_fill > callback_frames { - curr_buffer_fill -= callback_frames; - } else { - curr_buffer_fill = 0; + if let Ok(mut estimator) = buffer_fill_clone.try_lock() { + estimator.add(curr_buffer_fill) } - buffer_fill_clone.store(curr_buffer_fill, Ordering::Relaxed); Ok(()) }); match callback_res { @@ -485,6 +507,20 @@ impl PlaybackDevice for CoreaudioPlaybackDevice { return; } } + let thread_handle = + match promote_current_thread_to_real_time(chunksize as u32, samplerate as u32) { + Ok(h) => { + debug!("Playback thread has real-time priority."); + Some(h) + } + Err(err) => { + warn!( + "Playback thread could not get real time priority, error: {}", + err + ); + None + } + }; 'deviceloop: loop { if !alive_listener.is_alive() { error!("Playback device is no longer alive"); @@ -497,25 +533,32 @@ impl PlaybackDevice for CoreaudioPlaybackDevice { } match channel.recv() { Ok(AudioMessage::Audio(chunk)) => { - buffer_avg.add_value(buffer_fill.load(Ordering::Relaxed) as f64); + buffer_avg.add_value(buffer_fill.try_lock().map(|b| b.estimate() as f64).unwrap_or_default()); if adjust && timer.larger_than_millis((1000.0 * adjust_period) as u64) { if let Some(av_delay) = buffer_avg.average() { - let speed = calculate_speed( - av_delay, - target_level, - adjust_period, - samplerate as u32, - ); + let speed = rate_controller.next(av_delay); + let changed = (speed - rate_adjust_value).abs() > 0.000_001; + timer.restart(); buffer_avg.restart(); - debug!( - "Current buffer level {:.1}, set capture rate to {:.4}%", - av_delay, - 100.0 * speed - ); - status_channel - .send(StatusMessage::SetSpeed(speed)) - .unwrap_or(()); + if changed { + debug!( + "Current buffer level {:.1}, set capture rate to {:.4}%", + av_delay, + 100.0 * speed + ); + status_channel + .send(StatusMessage::SetSpeed(speed)) + .unwrap_or(()); + rate_adjust_value = speed; + } + else { + debug!( + "Current buffer level {:.1}, leaving capture rate at {:.4}%", + av_delay, + 100.0 * rate_adjust_value + ); + } playback_status.write().buffer_level = av_delay as usize; } } @@ -531,8 +574,7 @@ impl PlaybackDevice for CoreaudioPlaybackDevice { &mut buf, &SampleFormat::FLOAT32LE, ); - { - let mut playback_status = playback_status.write(); + if let Some(mut playback_status) = playback_status.try_write() { if conversion_result.1 > 0 { playback_status.clipped_samples += conversion_result.1; } @@ -543,6 +585,9 @@ impl PlaybackDevice for CoreaudioPlaybackDevice { .signal_peak .add_record(chunk_stats.peak_linear()); } + else { + xtrace!("playback status blocket, skip rms update"); + } match tx_dev.send(PlaybackDeviceMessage::Data(buf)) { Ok(_) => {} Err(err) => { @@ -572,6 +617,16 @@ impl PlaybackDevice for CoreaudioPlaybackDevice { } } } + if let Some(h) = thread_handle { + match demote_current_thread_from_real_time(h) { + Ok(_) => { + debug!("Playback thread returned to normal priority.") + } + Err(_) => { + warn!("Could not bring the playback thread back to normal priority.") + } + }; + } release_ownership(device_id).unwrap_or(()); })?; Ok(Box::new(handle)) @@ -584,7 +639,7 @@ fn nbr_capture_frames( ) -> usize { if let Some(resampl) = &resampler { #[cfg(feature = "debug")] - trace!("Resampler needs {resampl.input_frames_next()} frames"); + trace!("Resampler needs {} frames", resampl.input_frames_next()); resampl.input_frames_next() } else { capture_frames @@ -600,6 +655,7 @@ impl CaptureDevice for CoreaudioCaptureDevice { status_channel: crossbeam_channel::Sender, command_channel: mpsc::Receiver, capture_status: Arc>, + _processing_params: Arc, ) -> Res>> { let devname = self.devname.clone(); let samplerate = self.samplerate; @@ -760,6 +816,19 @@ impl CaptureDevice for CoreaudioCaptureDevice { return; }, } + let thread_handle = match promote_current_thread_to_real_time(chunksize as u32, samplerate as u32) { + Ok(h) => { + debug!("Capture thread has real-time priority."); + Some(h) + } + Err(err) => { + warn!( + "Capture thread could not get real time priority, error: {}", + err + ); + None + } + }; 'deviceloop: loop { match command_channel.try_recv() { Ok(CommandMessage::Exit) => { @@ -851,13 +920,15 @@ impl CaptureDevice for CoreaudioCaptureDevice { tries += 1; } if data_queue.len() < (blockalign * capture_frames) { - { - let mut capture_status = capture_status.write(); + if let Some(mut capture_status) = capture_status.try_write() { capture_status.measured_samplerate = 0; capture_status.signal_range = 0.0; capture_status.rate_adjust = 0.0; capture_status.state = ProcessingState::Stalled; } + else { + xtrace!("capture status blocked, skip update"); + } let msg = AudioMessage::Pause; if channel.send(msg).is_err() { info!("Processing thread has already stopped."); @@ -876,8 +947,7 @@ impl CaptureDevice for CoreaudioCaptureDevice { &capture_status.read().used_channels, ); averager.add_value(capture_frames + data_queue.len()/blockalign - prev_len/blockalign); - { - let capture_status = capture_status.upgradable_read(); + if let Some(capture_status) = capture_status.try_upgradable_read() { if averager.larger_than_millis(capture_status.update_interval as u64) { let samples_per_sec = averager.average(); @@ -887,13 +957,20 @@ impl CaptureDevice for CoreaudioCaptureDevice { "Measured sample rate is {:.1} Hz", measured_rate_f ); - let mut capture_status = RwLockUpgradableReadGuard::upgrade(capture_status); // to write lock - capture_status.measured_samplerate = measured_rate_f as usize; - capture_status.signal_range = value_range as f32; - capture_status.rate_adjust = rate_adjust as f32; - capture_status.state = state; + if let Ok(mut capture_status) = RwLockUpgradableReadGuard::try_upgrade(capture_status) { + capture_status.measured_samplerate = measured_rate_f as usize; + capture_status.signal_range = value_range as f32; + capture_status.rate_adjust = rate_adjust as f32; + capture_status.state = state; + } + else { + xtrace!("capture status upgrade blocked, skip update"); + } } } + else { + xtrace!("capture status blocked, skip update"); + } watcher_averager.add_value(capture_frames + data_queue.len()/blockalign - prev_len/blockalign); if watcher_averager.larger_than_millis(rate_measure_interval) { @@ -917,12 +994,13 @@ impl CaptureDevice for CoreaudioCaptureDevice { } prev_len = data_queue.len(); chunk.update_stats(&mut chunk_stats); - //trace!("Capture rms {:?}, peak {:?}", chunk_stats.rms_db(), chunk_stats.peak_db()); - { - let mut capture_status = capture_status.write(); + if let Some(mut capture_status) = capture_status.try_write() { capture_status.signal_rms.add_record_squared(chunk_stats.rms_linear()); capture_status.signal_peak.add_record(chunk_stats.peak_linear()); } + else { + xtrace!("capture status blocked, skip rms update"); + } value_range = chunk.maxval - chunk.minval; state = silence_counter.update(value_range); if state == ProcessingState::Running { @@ -951,6 +1029,16 @@ impl CaptureDevice for CoreaudioCaptureDevice { } } } + if let Some(h) = thread_handle { + match demote_current_thread_from_real_time(h) { + Ok(_) => { + debug!("Capture thread returned to normal priority.") + } + Err(_) => { + warn!("Could not bring the capture thread back to normal priority.") + } + }; + } capture_status.write().state = ProcessingState::Inactive; })?; Ok(Box::new(handle)) diff --git a/src/countertimer.rs b/src/countertimer.rs index 175e1b5d..07661cc0 100644 --- a/src/countertimer.rs +++ b/src/countertimer.rs @@ -4,6 +4,37 @@ use crate::ProcessingState; use std::collections::VecDeque; use std::time::{Duration, Instant}; +pub struct DeviceBufferEstimator { + update_time: Instant, + frames: usize, + sample_rate: f32, +} + +impl DeviceBufferEstimator { + pub fn new(sample_rate: usize) -> Self { + DeviceBufferEstimator { + update_time: Instant::now(), + frames: 0, + sample_rate: sample_rate as f32, + } + } + + pub fn add(&mut self, frames: usize) { + self.update_time = Instant::now(); + self.frames = frames; + } + + pub fn estimate(&self) -> usize { + let now = Instant::now(); + let time_passed = now.duration_since(self.update_time).as_secs_f32(); + let frames_consumed = (self.sample_rate * time_passed) as usize; + if frames_consumed >= self.frames { + return 0; + } + self.frames - frames_consumed + } +} + /// A counter for watching if the signal has been silent /// for longer than a given limit. pub struct SilenceCounter { diff --git a/src/cpaldevice.rs b/src/cpaldevice.rs index 9a69bbc5..b86e933f 100644 --- a/src/cpaldevice.rs +++ b/src/cpaldevice.rs @@ -5,6 +5,7 @@ use crate::conversions::{ chunk_to_queue_float, chunk_to_queue_int, queue_to_chunk_float, queue_to_chunk_int, }; use crate::countertimer; +use crate::helpers::PIRateController; use cpal; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use cpal::Device; @@ -21,6 +22,7 @@ use std::time; use crate::CommandMessage; use crate::NewValue; use crate::PrcFmt; +use crate::ProcessingParameters; use crate::ProcessingState; use crate::Res; use crate::StatusMessage; @@ -240,6 +242,8 @@ impl PlaybackDevice for CpalPlaybackDevice { let mut timer = countertimer::Stopwatch::new(); let mut chunk_stats = ChunkStats{rms: vec![0.0; channels], peak: vec![0.0; channels]}; + let mut rate_controller = PIRateController::new_with_default_gains(samplerate, adjust_period as f64, target_level); + let stream = match sample_format { SampleFormat::S16LE => { trace!("Build i16 output stream"); @@ -385,12 +389,7 @@ impl PlaybackDevice for CpalPlaybackDevice { && timer.larger_than_millis((1000.0 * adjust_period) as u64) { if let Some(av_delay) = buffer_avg.average() { - let speed = calculate_speed( - av_delay, - target_level, - adjust_period, - samplerate as u32, - ); + let speed = rate_controller.next(av_delay); timer.restart(); buffer_avg.restart(); debug!( @@ -474,6 +473,7 @@ impl CaptureDevice for CpalCaptureDevice { status_channel: crossbeam_channel::Sender, command_channel: mpsc::Receiver, capture_status: Arc>, + _processing_params: Arc, ) -> Res>> { let host_cfg = self.host.clone(); let devname = self.devname.clone(); @@ -691,7 +691,6 @@ impl CaptureDevice for CpalCaptureDevice { trace!("Measured sample rate is {:.1} Hz", measured_rate_f); } chunk.update_stats(&mut chunk_stats); - //trace!("Capture rms {:?}, peak {:?}", chunk_stats.rms_db(), chunk_stats.peak_db()); { let mut capture_status = capture_status.write(); capture_status.signal_rms.add_record_squared(chunk_stats.rms_linear()); diff --git a/src/dither.rs b/src/dither.rs index 2b7ff226..4a8a28f1 100644 --- a/src/dither.rs +++ b/src/dither.rs @@ -10,7 +10,7 @@ pub struct Dither<'a> { pub name: String, pub scalefact: PrcFmt, // have to `Box` because `dyn Ditherer` is not `Sized`. - ditherer: Box, + ditherer: Box, shaper: Option>, } @@ -453,7 +453,7 @@ impl<'a> NoiseShaper<'a> { } impl<'a> Dither<'a> { - pub fn new( + pub fn new( name: &str, bits: usize, ditherer: D, @@ -551,7 +551,7 @@ impl<'a> Dither<'a> { } } -impl<'a> Filter for Dither<'a> { +impl Filter for Dither<'_> { fn name(&self) -> &str { &self.name } diff --git a/src/fftconv_fftw.rs b/src/fftconv_fftw.rs deleted file mode 100644 index fef1f316..00000000 --- a/src/fftconv_fftw.rs +++ /dev/null @@ -1,362 +0,0 @@ -use crate::config; -use crate::filters; -use crate::filters::Filter; -use fftw::array::AlignedVec; -use fftw::plan::*; -use fftw::types::*; -//use helpers::{multiply_add_elements, multiply_elements}; - -// Sample format -use crate::PrcFmt; -#[cfg(feature = "32bit")] -pub type ComplexFmt = c32; -#[cfg(not(feature = "32bit"))] -pub type ComplexFmt = c64; -use crate::Res; - -// -- Duplcated from helpers.rs, needed until fftw updates to num-complex 0.3 -pub fn multiply_elements( - result: &mut [ComplexFmt], - slice_a: &[ComplexFmt], - slice_b: &[ComplexFmt], -) { - let len = result.len(); - let mut res = &mut result[..len]; - let mut val_a = &slice_a[..len]; - let mut val_b = &slice_b[..len]; - - while res.len() >= 8 { - res[0] = val_a[0] * val_b[0]; - res[1] = val_a[1] * val_b[1]; - res[2] = val_a[2] * val_b[2]; - res[3] = val_a[3] * val_b[3]; - res[4] = val_a[4] * val_b[4]; - res[5] = val_a[5] * val_b[5]; - res[6] = val_a[6] * val_b[6]; - res[7] = val_a[7] * val_b[7]; - res = &mut res[8..]; - val_a = &val_a[8..]; - val_b = &val_b[8..]; - } - for (r, val) in res - .iter_mut() - .zip(val_a.iter().zip(val_b.iter()).map(|(a, b)| *a * *b)) - { - *r = val; - } -} - -// element-wise add product, result = result + slice_a * slice_b -pub fn multiply_add_elements( - result: &mut [ComplexFmt], - slice_a: &[ComplexFmt], - slice_b: &[ComplexFmt], -) { - let len = result.len(); - let mut res = &mut result[..len]; - let mut val_a = &slice_a[..len]; - let mut val_b = &slice_b[..len]; - - while res.len() >= 8 { - res[0] += val_a[0] * val_b[0]; - res[1] += val_a[1] * val_b[1]; - res[2] += val_a[2] * val_b[2]; - res[3] += val_a[3] * val_b[3]; - res[4] += val_a[4] * val_b[4]; - res[5] += val_a[5] * val_b[5]; - res[6] += val_a[6] * val_b[6]; - res[7] += val_a[7] * val_b[7]; - res = &mut res[8..]; - val_a = &val_a[8..]; - val_b = &val_b[8..]; - } - for (r, val) in res - .iter_mut() - .zip(val_a.iter().zip(val_b.iter()).map(|(a, b)| *a * *b)) - { - *r += val; - } -} -// -- Duplcated from helpers.rs, needed until fftw updates to num-complex 0.3 - -pub struct FftConv { - name: String, - npoints: usize, - nsegments: usize, - overlap: Vec, - coeffs_f: Vec>, - #[cfg(feature = "32bit")] - fft: R2CPlan32, - #[cfg(not(feature = "32bit"))] - fft: R2CPlan64, - #[cfg(feature = "32bit")] - ifft: C2RPlan32, - #[cfg(not(feature = "32bit"))] - ifft: C2RPlan64, - input_buf: AlignedVec, - input_f: Vec>, - temp_buf: AlignedVec, - output_buf: AlignedVec, - index: usize, -} - -impl FftConv { - /// Create a new FFT colvolution filter. - pub fn new(name: &str, data_length: usize, coeffs: &[PrcFmt]) -> Self { - let name = name.to_string(); - let input_buf = AlignedVec::::new(2 * data_length); - let temp_buf = AlignedVec::::new(data_length + 1); - let output_buf = AlignedVec::::new(2 * data_length); - #[cfg(feature = "32bit")] - let mut fft: R2CPlan32 = R2CPlan::aligned(&[2 * data_length], Flag::MEASURE).unwrap(); - #[cfg(not(feature = "32bit"))] - let mut fft: R2CPlan64 = R2CPlan::aligned(&[2 * data_length], Flag::MEASURE).unwrap(); - let ifft = C2RPlan::aligned(&[2 * data_length], Flag::MEASURE).unwrap(); - - let nsegments = ((coeffs.len() as PrcFmt) / (data_length as PrcFmt)).ceil() as usize; - - let input_f = vec![AlignedVec::::new(data_length + 1); nsegments]; - let mut coeffs_f = vec![AlignedVec::::new(data_length + 1); nsegments]; - let mut coeffs_al = vec![AlignedVec::::new(2 * data_length); nsegments]; - - debug!("Conv {} is using {} segments", name, nsegments); - - for (n, coeff) in coeffs.iter().enumerate() { - coeffs_al[n / data_length][n % data_length] = coeff / (2.0 * data_length as PrcFmt); - } - - for (segment, segment_f) in coeffs_al.iter_mut().zip(coeffs_f.iter_mut()) { - fft.r2c(segment, segment_f).unwrap(); - } - - FftConv { - name, - npoints: data_length, - nsegments, - overlap: vec![0.0; data_length], - coeffs_f, - fft, - ifft, - input_f, - input_buf, - output_buf, - temp_buf, - index: 0, - } - } - - pub fn from_config(name: &str, data_length: usize, conf: config::ConvParameters) -> Self { - let values = match conf { - config::ConvParameters::Values { values } => values, - config::ConvParameters::Raw(params) => filters::read_coeff_file( - ¶ms.filename, - ¶ms.format(), - params.read_bytes_lines(), - params.skip_bytes_lines(), - ) - .unwrap(), - config::ConvParameters::Wav(params) => { - filters::read_wav(¶ms.filename, params.channel()).unwrap() - } - config::ConvParameters::Dummy { length } => { - let mut values = vec![0.0; length]; - values[0] = 1.0; - values - } - }; - FftConv::new(name, data_length, &values) - } -} - -impl Filter for FftConv { - fn name(&self) -> &str { - &self.name - } - - /// Process a waveform by FT, then multiply transform with transform of filter, and then transform back. - fn process_waveform(&mut self, waveform: &mut [PrcFmt]) -> Res<()> { - // Copy to input buffer - self.input_buf[0..self.npoints].copy_from_slice(waveform); - - // FFT and store result in history, update index - self.index = (self.index + 1) % self.nsegments; - self.fft - .r2c(&mut self.input_buf, self.input_f[self.index].as_slice_mut()) - .unwrap(); - - // Loop through history of input FTs, multiply with filter FTs, accumulate result - let segm = 0; - let hist_idx = (self.index + self.nsegments - segm) % self.nsegments; - multiply_elements( - &mut self.temp_buf, - &self.input_f[hist_idx], - &self.coeffs_f[segm], - ); - for segm in 1..self.nsegments { - let hist_idx = (self.index + self.nsegments - segm) % self.nsegments; - multiply_add_elements( - &mut self.temp_buf, - &self.input_f[hist_idx], - &self.coeffs_f[segm], - ); - } - - // IFFT result, store result anv overlap - self.ifft - .c2r(&mut self.temp_buf, &mut self.output_buf) - .unwrap(); - for (n, item) in waveform.iter_mut().enumerate().take(self.npoints) { - *item = self.output_buf[n] + self.overlap[n]; - } - self.overlap - .copy_from_slice(&self.output_buf[self.npoints..]); - Ok(()) - } - - fn update_parameters(&mut self, conf: config::Filter) { - if let config::Filter::Conv { - parameters: conf, .. - } = conf - { - let coeffs = match conf { - config::ConvParameters::Values { values } => values, - config::ConvParameters::Raw(params) => filters::read_coeff_file( - ¶ms.filename, - ¶ms.format(), - params.read_bytes_lines(), - params.skip_bytes_lines(), - ) - .unwrap(), - config::ConvParameters::Wav(params) => { - filters::read_wav(¶ms.filename, params.channel()).unwrap() - } - config::ConvParameters::Dummy { length } => { - let mut values = vec![0.0; length]; - values[0] = 1.0; - values - } - }; - - let nsegments = ((coeffs.len() as PrcFmt) / (self.npoints as PrcFmt)).ceil() as usize; - - if nsegments == self.nsegments { - // Same length, lets keep history - } else { - // length changed, clearing history - self.nsegments = nsegments; - let input_f = vec![AlignedVec::::new(self.npoints + 1); nsegments]; - self.input_f = input_f; - } - - let mut coeffs_f = vec![AlignedVec::::new(self.npoints + 1); nsegments]; - let mut coeffs_al = vec![AlignedVec::::new(2 * self.npoints); nsegments]; - - debug!("conv using {} segments", nsegments); - - for (n, coeff) in coeffs.iter().enumerate() { - coeffs_al[n / self.npoints][n % self.npoints] = - coeff / (2.0 * self.npoints as PrcFmt); - } - - for (segment, segment_f) in coeffs_al.iter_mut().zip(coeffs_f.iter_mut()) { - self.fft.r2c(segment, segment_f).unwrap(); - } - self.coeffs_f = coeffs_f; - } else { - // This should never happen unless there is a bug somewhere else - panic!("Invalid config change!"); - } - } -} - -/// Validate a FFT convolution config. -pub fn validate_config(conf: &config::ConvParameters) -> Res<()> { - match conf { - config::ConvParameters::Values { .. } | config::ConvParameters::Dummy { .. } => Ok(()), - config::ConvParameters::Raw(params) => { - let coeffs = filters::read_coeff_file( - ¶ms.filename, - ¶ms.format(), - params.read_bytes_lines(), - params.skip_bytes_lines(), - )?; - if coeffs.is_empty() { - return Err(config::ConfigError::new("Conv coefficients are empty").into()); - } - Ok(()) - } - config::ConvParameters::Wav(params) => { - let coeffs = filters::read_wav(¶ms.filename, params.channel())?; - if coeffs.is_empty() { - return Err(config::ConfigError::new("Conv coefficients are empty").into()); - } - Ok(()) - } - } -} - -#[cfg(test)] -mod tests { - use crate::config::ConvParameters; - use crate::fftconv_fftw::FftConv; - use crate::filters::Filter; - use crate::PrcFmt; - - fn is_close(left: PrcFmt, right: PrcFmt, maxdiff: PrcFmt) -> bool { - println!("{} - {}", left, right); - (left - right).abs() < maxdiff - } - - fn compare_waveforms(left: Vec, right: Vec, maxdiff: PrcFmt) -> bool { - for (val_l, val_r) in left.iter().zip(right.iter()) { - if !is_close(*val_l, *val_r, maxdiff) { - return false; - } - } - true - } - - #[test] - fn check_result() { - let coeffs = vec![0.5, 0.5]; - let conf = ConvParameters::Values { values: coeffs }; - let mut filter = FftConv::from_config("test", 8, conf); - let mut wave1 = vec![1.0, 1.0, 1.0, 0.0, 0.0, -1.0, 0.0, 0.0]; - let expected = vec![0.5, 1.0, 1.0, 0.5, 0.0, -0.5, -0.5, 0.0]; - filter.process_waveform(&mut wave1).unwrap(); - assert!(compare_waveforms(wave1, expected, 1e-7)); - } - - #[test] - fn check_result_segmented() { - let mut coeffs = Vec::::new(); - for m in 0..32 { - coeffs.push(m as PrcFmt); - } - let mut filter = FftConv::new("test", 8, &coeffs); - let mut wave1 = vec![0.0 as PrcFmt; 8]; - let mut wave2 = vec![0.0 as PrcFmt; 8]; - let mut wave3 = vec![0.0 as PrcFmt; 8]; - let mut wave4 = vec![0.0 as PrcFmt; 8]; - let mut wave5 = vec![0.0 as PrcFmt; 8]; - - wave1[0] = 1.0; - filter.process_waveform(&mut wave1).unwrap(); - filter.process_waveform(&mut wave2).unwrap(); - filter.process_waveform(&mut wave3).unwrap(); - filter.process_waveform(&mut wave4).unwrap(); - filter.process_waveform(&mut wave5).unwrap(); - - let exp1 = Vec::from(&coeffs[0..8]); - let exp2 = Vec::from(&coeffs[8..16]); - let exp3 = Vec::from(&coeffs[16..24]); - let exp4 = Vec::from(&coeffs[24..32]); - let exp5 = vec![0.0 as PrcFmt; 8]; - - assert!(compare_waveforms(wave1, exp1, 1e-5)); - assert!(compare_waveforms(wave2, exp2, 1e-5)); - assert!(compare_waveforms(wave3, exp3, 1e-5)); - assert!(compare_waveforms(wave4, exp4, 1e-5)); - assert!(compare_waveforms(wave5, exp5, 1e-5)); - } -} diff --git a/src/filedevice.rs b/src/filedevice.rs index 770e5276..fffa0ebc 100644 --- a/src/filedevice.rs +++ b/src/filedevice.rs @@ -25,12 +25,13 @@ use crate::filedevice_bluez; use crate::filereader::BlockingReader; #[cfg(target_os = "linux")] use crate::filereader_nonblock::NonBlockingReader; +use crate::wavtools::{find_data_in_wav, write_wav_header}; use crate::CommandMessage; use crate::PrcFmt; use crate::ProcessingState; use crate::Res; use crate::StatusMessage; -use crate::{CaptureStatus, PlaybackStatus}; +use crate::{CaptureStatus, PlaybackStatus, ProcessingParameters}; pub struct FilePlaybackDevice { pub destination: PlaybackDest, @@ -38,6 +39,7 @@ pub struct FilePlaybackDevice { pub samplerate: usize, pub channels: usize, pub sample_format: SampleFormat, + pub wav_header: bool, } #[derive(Clone)] @@ -61,7 +63,7 @@ pub struct FileCaptureDevice { pub capture_samplerate: usize, pub resampler_config: Option, pub channels: usize, - pub sample_format: SampleFormat, + pub sample_format: Option, pub silence_threshold: PrcFmt, pub silence_timeout: PrcFmt, pub extra_samples: usize, @@ -119,6 +121,8 @@ impl PlaybackDevice for FilePlaybackDevice { let channels = self.channels; let store_bytes_per_sample = self.sample_format.bytes_per_sample(); let sample_format = self.sample_format; + let samplerate = self.samplerate; + let wav_header = self.wav_header; let handle = thread::Builder::new() .name("FilePlayback".to_string()) .spawn(move || { @@ -140,6 +144,16 @@ impl PlaybackDevice for FilePlaybackDevice { }; barrier.wait(); debug!("starting playback loop"); + if wav_header { + match write_wav_header(&mut file, channels, sample_format, samplerate) { + Ok(()) => debug!("Wrote Wav header"), + Err(err) => { + status_channel + .send(StatusMessage::PlaybackError(err.to_string())) + .unwrap_or(()); + } + } + } let mut buffer = vec![0u8; chunksize * channels * store_bytes_per_sample]; loop { match channel.recv() { @@ -159,8 +173,7 @@ impl PlaybackDevice for FilePlaybackDevice { } }; chunk.update_stats(&mut chunk_stats); - { - let mut playback_status = playback_status.write(); + if let Some(mut playback_status) = playback_status.try_write() { if nbr_clipped > 0 { playback_status.clipped_samples += nbr_clipped; } @@ -170,12 +183,9 @@ impl PlaybackDevice for FilePlaybackDevice { playback_status .signal_peak .add_record(chunk_stats.peak_linear()); + } else { + xtrace!("playback status blocked, skip rms update"); } - trace!( - "Playback signal RMS: {:?}, peak: {:?}", - chunk_stats.rms_db(), - chunk_stats.peak_db() - ); } Ok(AudioMessage::Pause) => { trace!("Pause message received"); @@ -218,38 +228,32 @@ fn nbr_capture_bytes( store_bytes_per_sample: usize, ) -> usize { if let Some(resampl) = &resampler { - //let new_capture_bytes = resampl.input_frames_next() * channels * store_bytes_per_sample; - //trace!( - // "Resampler needs {} frames, will read {} bytes", - // resampl.input_frames_next(), - // new_capture_bytes - //); - //new_capture_bytes resampl.input_frames_next() * channels * store_bytes_per_sample } else { capture_bytes } } -fn capture_bytes( +// Adjust buffer size if needed, and check if capture is done +fn limit_capture_bytes( bytes_to_read: usize, nbr_bytes_read: usize, capture_bytes: usize, buf: &mut Vec, -) -> usize { - let capture_bytes = if bytes_to_read == 0 +) -> (usize, bool) { + let (capture_bytes, done) = if bytes_to_read == 0 || (bytes_to_read > 0 && (nbr_bytes_read + capture_bytes) <= bytes_to_read) { - capture_bytes + (capture_bytes, false) } else { debug!("Stopping capture, reached read_bytes limit"); - bytes_to_read - nbr_bytes_read + (bytes_to_read - nbr_bytes_read, true) }; if capture_bytes > buf.len() { debug!("Capture buffer too small, extending"); buf.append(&mut vec![0u8; capture_bytes - buf.len()]); } - capture_bytes + (capture_bytes, done) } fn capture_loop( @@ -258,13 +262,14 @@ fn capture_loop( msg_channels: CaptureChannels, mut resampler: Option>>, ) { - debug!("starting captureloop"); + debug!("preparing captureloop"); let chunksize_bytes = params.channels * params.chunksize * params.store_bytes_per_sample; let bytes_per_frame = params.channels * params.store_bytes_per_sample; let mut buf = vec![0u8; params.buffer_bytes]; let mut bytes_read = 0; let mut bytes_to_capture = chunksize_bytes; let mut bytes_to_capture_tmp; + let mut capture_done: bool; let mut extra_bytes_left = params.extra_bytes; let mut nbr_bytes_read = 0; let rate_measure_interval_ms = (1000.0 * params.rate_measure_interval) as u64; @@ -328,7 +333,7 @@ fn capture_loop( params.channels, params.store_bytes_per_sample, ); - bytes_to_capture_tmp = capture_bytes( + (bytes_to_capture_tmp, capture_done) = limit_capture_bytes( params.read_bytes, nbr_bytes_read, bytes_to_capture, @@ -336,8 +341,8 @@ fn capture_loop( ); //let read_res = read_retry(&mut file, &mut buf[0..capture_bytes_temp]); let read_res = file.read(&mut buf[0..bytes_to_capture_tmp]); - match read_res { - Ok(ReadResult::EndOfFile(bytes)) => { + match (read_res, capture_done) { + (Ok(ReadResult::EndOfFile(bytes)), _) | (Ok(ReadResult::Complete(bytes)), true) => { bytes_read = bytes; nbr_bytes_read += bytes; if bytes > 0 { @@ -377,7 +382,7 @@ fn capture_loop( break; } } - Ok(ReadResult::Timeout(bytes)) => { + (Ok(ReadResult::Timeout(bytes)), _) => { bytes_read = bytes; nbr_bytes_read += bytes; if bytes > 0 { @@ -412,7 +417,8 @@ fn capture_loop( continue; } } - Ok(ReadResult::Complete(bytes)) => { + (Ok(ReadResult::Complete(bytes)), false) => { + trace!("successfully read {} bytes", bytes); if stalled { debug!("Leaving stalled state, resuming processing"); stalled = false; @@ -423,20 +429,26 @@ fn capture_loop( nbr_bytes_read += bytes; averager.add_value(bytes); - { - let capture_status = params.capture_status.upgradable_read(); + if let Some(capture_status) = params.capture_status.try_upgradable_read() { if averager.larger_than_millis(capture_status.update_interval as u64) { let bytes_per_sec = averager.average(); averager.restart(); let measured_rate_f = bytes_per_sec / (params.channels * params.store_bytes_per_sample) as f64; trace!("Measured sample rate is {:.1} Hz", measured_rate_f); - let mut capture_status = RwLockUpgradableReadGuard::upgrade(capture_status); // to write lock - capture_status.measured_samplerate = measured_rate_f as usize; - capture_status.signal_range = value_range as f32; - capture_status.rate_adjust = rate_adjust as f32; - capture_status.state = state; + if let Ok(mut capture_status) = + RwLockUpgradableReadGuard::try_upgrade(capture_status) + { + capture_status.measured_samplerate = measured_rate_f as usize; + capture_status.signal_range = value_range as f32; + capture_status.rate_adjust = rate_adjust as f32; + capture_status.state = state; + } else { + xtrace!("capture status upgrade blocked, skip update"); + } } + } else { + xtrace!("capture status blocked, skip update"); } watcher_averager.add_value(bytes); if watcher_averager.larger_than_millis(rate_measure_interval_ms) { @@ -463,7 +475,7 @@ fn capture_loop( trace!("Measured sample rate is {:.1} Hz", measured_rate_f); } } - Err(err) => { + (Err(err), _) => { debug!("Encountered a read error"); msg_channels .status @@ -479,19 +491,15 @@ fn capture_loop( ¶ms.capture_status.read().used_channels, ); chunk.update_stats(&mut chunk_stats); - //trace!( - // "Capture rms {:?}, peak {:?}", - // chunk_stats.rms_db(), - // chunk_stats.peak_db() - //); - { - let mut capture_status = params.capture_status.write(); + if let Some(mut capture_status) = params.capture_status.try_write() { capture_status .signal_rms .add_record_squared(chunk_stats.rms_linear()); capture_status .signal_peak .add_record(chunk_stats.peak_linear()); + } else { + xtrace!("capture status blocked, skip rms update"); } value_range = chunk.maxval - chunk.minval; state = silence_counter.update(value_range); @@ -538,13 +546,34 @@ impl CaptureDevice for FileCaptureDevice { status_channel: crossbeam_channel::Sender, command_channel: mpsc::Receiver, capture_status: Arc>, + _processing_params: Arc, ) -> Res>> { let source = self.source.clone(); let samplerate = self.samplerate; let chunksize = self.chunksize; let capture_samplerate = self.capture_samplerate; - let channels = self.channels; - let store_bytes_per_sample = self.sample_format.bytes_per_sample(); + let mut channels = self.channels; + let mut skip_bytes = self.skip_bytes; + let mut read_bytes = self.read_bytes; + let sample_format = match &self.source { + CaptureSource::Filename(fname) => { + if self.sample_format.is_none() { + // No format was given, try to get from the file. + // Only works if the file is in wav format. + // Also update channels and read & skip bytes. + let wav_info = find_data_in_wav(fname)?; + skip_bytes = wav_info.data_offset; + read_bytes = wav_info.data_length; + channels = wav_info.channels; + wav_info.sample_format + } else { + self.sample_format.unwrap() + } + } + _ => self.sample_format.unwrap(), + }; + let store_bytes_per_sample = sample_format.bytes_per_sample(); + let extra_bytes = self.extra_samples * store_bytes_per_sample * channels; let buffer_bytes = 2.0f32.powf( (capture_samplerate as f32 / samplerate as f32 * chunksize as f32) .log2() @@ -553,12 +582,8 @@ impl CaptureDevice for FileCaptureDevice { * 2 * channels * store_bytes_per_sample; - let sample_format = self.sample_format; let resampler_config = self.resampler_config; let async_src = resampler_is_async(&resampler_config); - let extra_bytes = self.extra_samples * store_bytes_per_sample * channels; - let skip_bytes = self.skip_bytes; - let read_bytes = self.read_bytes; let silence_timeout = self.silence_timeout; let silence_threshold = self.silence_threshold; let stop_on_rate_change = self.stop_on_rate_change; diff --git a/src/filedevice_bluez.rs b/src/filedevice_bluez.rs index 4fde2d91..49ce50ae 100644 --- a/src/filedevice_bluez.rs +++ b/src/filedevice_bluez.rs @@ -17,32 +17,32 @@ pub struct WrappedBluezFd { impl WrappedBluezFd { fn new_from_open_message(r: Arc) -> WrappedBluezFd { let (pipe_fd, ctrl_fd): (OwnedFd, OwnedFd) = r.body().unwrap(); - return WrappedBluezFd { - pipe_fd: pipe_fd, + WrappedBluezFd { + pipe_fd, _ctrl_fd: ctrl_fd, _msg: r, - }; + } } } impl Read for WrappedBluezFd { fn read(&mut self, buf: &mut [u8]) -> io::Result { - nix::unistd::read(self.pipe_fd.as_raw_fd(), buf).map_err(|e| io::Error::from(e)) + nix::unistd::read(self.pipe_fd.as_raw_fd(), buf).map_err(io::Error::from) } } impl AsRawFd for WrappedBluezFd { fn as_raw_fd(&self) -> RawFd { - return self.pipe_fd.as_raw_fd(); + self.pipe_fd.as_raw_fd() } } -pub fn open_bluez_dbus_fd( +pub fn open_bluez_dbus_fd<'a>( service: String, path: String, chunksize: usize, samplerate: usize, -) -> Result>, zbus::Error> { +) -> Result>, zbus::Error> { let conn1 = Connection::system()?; let res = conn1.call_method(Some(service), path, Some("org.bluealsa.PCM1"), "Open", &())?; @@ -50,5 +50,5 @@ pub fn open_bluez_dbus_fd( WrappedBluezFd::new_from_open_message(res), 2 * 1000 * chunksize as u64 / samplerate as u64, )); - return Ok(reader); + Ok(reader) } diff --git a/src/filereader_nonblock.rs b/src/filereader_nonblock.rs index 58bed5c6..0994879e 100644 --- a/src/filereader_nonblock.rs +++ b/src/filereader_nonblock.rs @@ -3,24 +3,25 @@ use nix; use std::error::Error; use std::io::ErrorKind; use std::io::Read; -use std::os::unix::io::AsRawFd; +use std::os::unix::io::{AsRawFd, BorrowedFd}; use std::time; use std::time::Duration; use crate::filedevice::{ReadResult, Reader}; -pub struct NonBlockingReader { - poll: nix::poll::PollFd, +pub struct NonBlockingReader<'a, R: 'a> { + poll: nix::poll::PollFd<'a>, signals: nix::sys::signal::SigSet, timeout: Option, timelimit: time::Duration, inner: R, } -impl NonBlockingReader { +impl<'a, R: Read + AsRawFd + 'a> NonBlockingReader<'a, R> { pub fn new(inner: R, timeout_millis: u64) -> Self { let flags = nix::poll::PollFlags::POLLIN; - let poll = nix::poll::PollFd::new(inner.as_raw_fd(), flags); + let poll: nix::poll::PollFd<'_> = + nix::poll::PollFd::new(unsafe { BorrowedFd::borrow_raw(inner.as_raw_fd()) }, flags); let mut signals = nix::sys::signal::SigSet::empty(); signals.add(nix::sys::signal::Signal::SIGIO); let timelimit = time::Duration::from_millis(timeout_millis); @@ -35,13 +36,13 @@ impl NonBlockingReader { } } -impl Reader for NonBlockingReader { +impl<'a, R: Read + AsRawFd + 'a> Reader for NonBlockingReader<'a, R> { fn read(&mut self, data: &mut [u8]) -> Result> { let mut buf = &mut *data; let mut bytes_read = 0; let start = time::Instant::now(); loop { - let res = nix::poll::ppoll(&mut [self.poll], self.timeout, self.signals)?; + let res = nix::poll::ppoll(&mut [self.poll], self.timeout, Some(self.signals))?; //println!("loop..."); if res == 0 { return Ok(ReadResult::Timeout(bytes_read)); diff --git a/src/filters.rs b/src/filters.rs index b87ab355..598fa48a 100644 --- a/src/filters.rs +++ b/src/filters.rs @@ -7,75 +7,26 @@ use crate::config; use crate::conversions; use crate::diffeq; use crate::dither; -#[cfg(not(feature = "FFTW"))] use crate::fftconv; -#[cfg(feature = "FFTW")] -use crate::fftconv_fftw as fftconv; use crate::limiter; use crate::loudness; use crate::mixer; +use crate::noisegate; use rawsample::SampleReader; use std::collections::HashMap; -use std::convert::TryInto; use std::fs::File; use std::io::BufReader; -use std::io::{BufRead, Read, Seek, SeekFrom}; +use std::io::{BufRead, Seek, SeekFrom}; use std::sync::Arc; use std::time::Instant; +use rayon::prelude::*; + use crate::PrcFmt; use crate::ProcessingParameters; use crate::Res; -/// Windows Guid -/// Used to give sample format in the extended WAVEFORMATEXTENSIBLE wav header -#[derive(Debug, PartialEq, Eq)] -struct Guid { - data1: u32, - data2: u16, - data3: u16, - data4: [u8; 8], -} - -impl Guid { - fn from_slice(data: &[u8; 16]) -> Guid { - let data1 = u32::from_le_bytes(data[0..4].try_into().unwrap()); - let data2 = u16::from_le_bytes(data[4..6].try_into().unwrap()); - let data3 = u16::from_le_bytes(data[6..8].try_into().unwrap()); - let data4 = data[8..16].try_into().unwrap(); - Guid { - data1, - data2, - data3, - data4, - } - } -} - -/// KSDATAFORMAT_SUBTYPE_IEEE_FLOAT -const SUBTYPE_FLOAT: Guid = Guid { - data1: 3, - data2: 0, - data3: 16, - data4: [128, 0, 0, 170, 0, 56, 155, 113], -}; - -/// KSDATAFORMAT_SUBTYPE_PCM -const SUBTYPE_PCM: Guid = Guid { - data1: 1, - data2: 0, - data3: 16, - data4: [128, 0, 0, 170, 0, 56, 155, 113], -}; - -#[derive(Debug)] -pub struct WavParams { - sample_format: config::FileFormat, - sample_rate: usize, - data_offset: usize, - data_length: usize, - channels: usize, -} +use crate::wavtools::find_data_in_wav; pub trait Filter { // Filter a Vec @@ -116,7 +67,7 @@ pub fn read_coeff_file( let f = match File::open(filename) { Ok(f) => f, Err(err) => { - let msg = format!("Could not open coefficient file '{filename}'. Error: {err}"); + let msg = format!("Could not open coefficient file '{filename}'. Reason: {err}"); return Err(config::ConfigError::new(&msg).into()); } }; @@ -139,7 +90,7 @@ pub fn read_coeff_file( match line { Err(err) => { let msg = format!( - "Can't read line {} of file '{}'. Error: {}", + "Can't read line {} of file '{}'. Reason: {}", nbr + 1 + skip_bytes_lines, filename, err @@ -150,7 +101,7 @@ pub fn read_coeff_file( Ok(val) => coefficients.push(val), Err(err) => { let msg = format!( - "Can't parse value on line {} of file '{}'. Error: {}", + "Can't parse value on line {} of file '{}'. Reason: {}", nbr + 1 + skip_bytes_lines, filename, err @@ -184,133 +135,6 @@ pub fn read_coeff_file( Ok(coefficients) } -pub fn find_data_in_wav(filename: &str) -> Res { - let f = File::open(filename)?; - let filesize = f.metadata()?.len(); - let mut file = BufReader::new(&f); - let mut header = [0; 12]; - let _ = file.read(&mut header)?; - - let riff_b = "RIFF".as_bytes(); - let wave_b = "WAVE".as_bytes(); - let data_b = "data".as_bytes(); - let fmt_b = "fmt ".as_bytes(); - let riff_err = header.iter().take(4).zip(riff_b).any(|(a, b)| *a != *b); - let wave_err = header - .iter() - .skip(8) - .take(4) - .zip(wave_b) - .any(|(a, b)| *a != *b); - if riff_err || wave_err { - let msg = format!("Invalid wav header in file '{filename}'"); - return Err(config::ConfigError::new(&msg).into()); - } - let mut next_chunk_location = 12; - let mut found_fmt = false; - let mut found_data = false; - let mut buffer = [0; 8]; - - let mut sample_format = config::FileFormat::S16LE; - let mut sample_rate = 0; - let mut channels = 0; - let mut data_offset = 0; - let mut data_length = 0; - - while (!found_fmt || !found_data) && next_chunk_location < filesize { - file.seek(SeekFrom::Start(next_chunk_location))?; - let _ = file.read(&mut buffer)?; - let chunk_length = u32::from_le_bytes(buffer[4..8].try_into().unwrap()); - trace!("Analyzing wav chunk of length: {}", chunk_length); - let is_data = buffer.iter().take(4).zip(data_b).all(|(a, b)| *a == *b); - let is_fmt = buffer.iter().take(4).zip(fmt_b).all(|(a, b)| *a == *b); - if is_fmt && (chunk_length == 16 || chunk_length == 18 || chunk_length == 40) { - found_fmt = true; - let mut data = vec![0; chunk_length as usize]; - let _ = file.read(&mut data).unwrap(); - let formatcode = u16::from_le_bytes(data[0..2].try_into().unwrap()); - channels = u16::from_le_bytes(data[2..4].try_into().unwrap()); - sample_rate = u32::from_le_bytes(data[4..8].try_into().unwrap()); - let bytes_per_frame = u16::from_le_bytes(data[12..14].try_into().unwrap()); - let bits = u16::from_le_bytes(data[14..16].try_into().unwrap()); - let bytes_per_sample = bytes_per_frame / channels; - sample_format = match (formatcode, bits, bytes_per_sample) { - (1, 16, 2) => config::FileFormat::S16LE, - (1, 24, 3) => config::FileFormat::S24LE3, - (1, 24, 4) => config::FileFormat::S24LE, - (1, 32, 4) => config::FileFormat::S32LE, - (3, 32, 4) => config::FileFormat::FLOAT32LE, - (3, 64, 8) => config::FileFormat::FLOAT64LE, - (0xFFFE, _, _) => { - // waveformatex - if chunk_length != 40 { - let msg = format!("Invalid extended header of wav file '{filename}'"); - return Err(config::ConfigError::new(&msg).into()); - } - let cb_size = u16::from_le_bytes(data[16..18].try_into().unwrap()); - let valid_bits_per_sample = - u16::from_le_bytes(data[18..20].try_into().unwrap()); - let channel_mask = u32::from_le_bytes(data[20..24].try_into().unwrap()); - let subformat = &data[24..40]; - let subformat_guid = Guid::from_slice(subformat.try_into().unwrap()); - trace!( - "Found extended wav fmt chunk: subformatcode: {:?}, cb_size: {}, channel_mask: {}, valid bits per sample: {}", - subformat_guid, cb_size, channel_mask, valid_bits_per_sample - ); - match ( - subformat_guid, - bits, - bytes_per_sample, - valid_bits_per_sample, - ) { - (SUBTYPE_PCM, 16, 2, 16) => config::FileFormat::S16LE, - (SUBTYPE_PCM, 24, 3, 24) => config::FileFormat::S24LE3, - (SUBTYPE_PCM, 24, 4, 24) => config::FileFormat::S24LE, - (SUBTYPE_PCM, 32, 4, 32) => config::FileFormat::S32LE, - (SUBTYPE_FLOAT, 32, 4, 32) => config::FileFormat::FLOAT32LE, - (SUBTYPE_FLOAT, 64, 8, 64) => config::FileFormat::FLOAT64LE, - (_, _, _, _) => { - let msg = - format!("Unsupported extended wav format of file '{filename}'"); - return Err(config::ConfigError::new(&msg).into()); - } - } - } - (_, _, _) => { - let msg = format!("Unsupported wav format of file '{filename}'"); - return Err(config::ConfigError::new(&msg).into()); - } - }; - trace!( - "Found wav fmt chunk: formatcode: {}, channels: {}, samplerate: {}, bits: {}, bytes_per_frame: {}", - formatcode, channels, sample_rate, bits, bytes_per_frame - ); - } else if is_data { - found_data = true; - data_offset = next_chunk_location + 8; - data_length = chunk_length; - trace!( - "Found wav data chunk, start: {}, length: {}", - data_offset, - data_length - ) - } - next_chunk_location += 8 + chunk_length as u64; - } - if found_data && found_fmt { - trace!("Wav file with parameters: format: {:?}, samplerate: {}, channels: {}, data_length: {}, data_offset: {}", sample_format, sample_rate, channels, data_length, data_offset); - return Ok(WavParams { - sample_format, - sample_rate: sample_rate as usize, - channels: channels as usize, - data_length: data_length as usize, - data_offset: data_offset as usize, - }); - } - let msg = format!("Unable to parse wav file '{filename}'"); - Err(config::ConfigError::new(&msg).into()) -} - pub fn read_wav(filename: &str, channel: usize) -> Res> { let params = find_data_in_wav(filename)?; if channel >= params.channels { @@ -323,7 +147,7 @@ pub fn read_wav(filename: &str, channel: usize) -> Res> { let alldata = read_coeff_file( filename, - ¶ms.sample_format, + &config::FileFormat::from_sample_format(¶ms.sample_format), params.data_length, params.data_offset, )?; @@ -348,7 +172,7 @@ pub fn read_wav(filename: &str, channel: usize) -> Res> { pub struct FilterGroup { channel: usize, - filters: Vec>, + filters: Vec>, } impl FilterGroup { @@ -362,11 +186,11 @@ impl FilterGroup { processing_params: Arc, ) -> Self { debug!("Build filter group from config"); - let mut filters = Vec::>::new(); + let mut filters = Vec::>::new(); for name in names { let filter_cfg = filter_configs[name].clone(); trace!("Create filter {} with config {:?}", name, filter_cfg); - let filter: Box = + let filter: Box = match filter_cfg { config::Filter::Conv { parameters, .. } => Box::new( fftconv::FftConv::from_config(name, waveform_length, parameters), @@ -464,11 +288,46 @@ impl FilterGroup { } } +pub struct ParallelFilters { + filters: Vec>>, +} + +impl ParallelFilters { + pub fn update_parameters( + &mut self, + filterconfigs: HashMap, + changed: &[String], + ) { + for channel_filters in &mut self.filters { + for filter in channel_filters { + if changed.iter().any(|n| n == filter.name()) { + filter.update_parameters(filterconfigs[filter.name()].clone()); + } + } + } + } + + /// Apply all the filters to an AudioChunk. + fn process_chunk(&mut self, input: &mut AudioChunk) -> Res<()> { + self.filters + .par_iter_mut() + .zip(input.waveforms.par_iter_mut()) + .filter(|(f, w)| !f.is_empty() && !w.is_empty()) + .for_each(|(f, w)| { + for filt in f { + let _ = filt.process_waveform(w); + } + }); + Ok(()) + } +} + /// A Pipeline is made up of a series of PipelineSteps, -/// each one can be a single Mixer of a group of Filters +/// each one can be a single Mixer or a group of Filters pub enum PipelineStep { MixerStep(mixer::Mixer), FilterStep(FilterGroup), + ParallelFiltersStep(ParallelFilters), ProcessorStep(Box), } @@ -488,30 +347,54 @@ impl Pipeline { debug!("Build new pipeline"); trace!("Pipeline config {:?}", conf.pipeline); let mut steps = Vec::::new(); + let mut num_channels = conf.devices.capture.channels(); for step in conf.pipeline.unwrap_or_default() { match step { config::PipelineStep::Mixer(step) => { if !step.is_bypassed() { let mixconf = conf.mixers.as_ref().unwrap()[&step.name].clone(); + num_channels = mixconf.channels.out; + debug!( + "Add Mixer step with mixer {}, pipeline becomes {} channels wide", + step.name, mixconf.channels.out + ); let mixer = mixer::Mixer::from_config(step.name, mixconf); steps.push(PipelineStep::MixerStep(mixer)); } } config::PipelineStep::Filter(step) => { if !step.is_bypassed() { - let fltgrp = FilterGroup::from_config( - step.channel, - &step.names, - conf.filters.as_ref().unwrap().clone(), - conf.devices.chunksize, - conf.devices.samplerate, - processing_params.clone(), - ); - steps.push(PipelineStep::FilterStep(fltgrp)); + let channels_iter: Box> = if let Some(channels) = + &step.channels + { + debug!( + "Add Filter step with filters {:?} to channels {:?}", + step.names, channels + ); + Box::new(channels.iter().copied()) as Box> + } else { + debug!( + "Add Filter step with filters {:?} to all {} channels", + step.names, num_channels + ); + Box::new(0..num_channels) as Box> + }; + for channel in channels_iter { + let fltgrp = FilterGroup::from_config( + channel, + &step.names, + conf.filters.as_ref().unwrap().clone(), + conf.devices.chunksize, + conf.devices.samplerate, + processing_params.clone(), + ); + steps.push(PipelineStep::FilterStep(fltgrp)); + } } } config::PipelineStep::Processor(step) => { if !step.is_bypassed() { + debug!("Add Processor step with processor {}", step.name); let procconf = conf.processors.as_ref().unwrap()[&step.name].clone(); let proc = match procconf { config::Processor::Compressor { parameters, .. } => { @@ -521,7 +404,16 @@ impl Pipeline { conf.devices.samplerate, conf.devices.chunksize, ); - Box::new(comp) + Box::new(comp) as Box + } + config::Processor::NoiseGate { parameters, .. } => { + let gate = noisegate::NoiseGate::from_config( + &step.name, + parameters, + conf.devices.samplerate, + conf.devices.chunksize, + ); + Box::new(gate) as Box } }; steps.push(PipelineStep::ProcessorStep(proc)); @@ -534,6 +426,7 @@ impl Pipeline { let volume = basicfilters::Volume::new( "default", conf.devices.ramp_time(), + conf.devices.volume_limit(), current_volume, mute, conf.devices.chunksize, @@ -542,6 +435,9 @@ impl Pipeline { 0, ); let secs_per_chunk = conf.devices.chunksize as f32 / conf.devices.samplerate as f32; + if conf.devices.multithreaded() { + steps = parallelize_filters(&mut steps, conf.devices.capture.channels()); + } Pipeline { steps, volume, @@ -568,6 +464,9 @@ impl Pipeline { PipelineStep::FilterStep(flt) => { flt.update_parameters(conf.filters.as_ref().unwrap().clone(), filters); } + PipelineStep::ParallelFiltersStep(flt) => { + flt.update_parameters(conf.filters.as_ref().unwrap().clone(), filters); + } PipelineStep::ProcessorStep(proc) => { if processors.iter().any(|n| n == proc.name()) { proc.update_parameters( @@ -591,6 +490,9 @@ impl Pipeline { PipelineStep::FilterStep(flt) => { flt.process_chunk(&mut chunk).unwrap(); } + PipelineStep::ParallelFiltersStep(flt) => { + flt.process_chunk(&mut chunk).unwrap(); + } PipelineStep::ProcessorStep(comp) => { comp.process_chunk(&mut chunk).unwrap(); } @@ -604,6 +506,67 @@ impl Pipeline { } } +// Loop trough the pipeline to merge individual filter steps, +// in order use rayon to apply them in parallel. +fn parallelize_filters(steps: &mut Vec, nbr_channels: usize) -> Vec { + debug!("Merging filter steps to enable parallel processing"); + let mut new_steps: Vec = Vec::new(); + let mut parfilt = None; + let mut active_channels = nbr_channels; + for step in steps.drain(..) { + match step { + PipelineStep::MixerStep(ref mix) => { + if parfilt.is_some() { + debug!("Append parallel filter step to pipeline"); + new_steps.push(PipelineStep::ParallelFiltersStep(parfilt.take().unwrap())); + } + active_channels = mix.channels_out; + debug!("Append mixer step to pipeline"); + new_steps.push(step); + } + PipelineStep::ProcessorStep(_) => { + if parfilt.is_some() { + debug!("Append parallel filter step to pipeline"); + new_steps.push(PipelineStep::ParallelFiltersStep(parfilt.take().unwrap())); + } + debug!("Append processor step to pipeline"); + new_steps.push(step); + } + PipelineStep::ParallelFiltersStep(_) => { + if parfilt.is_some() { + debug!("Append parallel filter step to pipeline"); + new_steps.push(PipelineStep::ParallelFiltersStep(parfilt.take().unwrap())); + } + debug!("Append existing parallel filter step to pipeline"); + new_steps.push(step); + } + PipelineStep::FilterStep(mut flt) => { + if parfilt.is_none() { + debug!("Start new parallel filter step"); + let mut filters = Vec::with_capacity(active_channels); + for _ in 0..active_channels { + filters.push(Vec::new()); + } + parfilt = Some(ParallelFilters { filters }); + } + if let Some(ref mut f) = parfilt { + debug!( + "Adding {} filters to channel {} of parallel filter step", + flt.filters.len(), + flt.channel + ); + f.filters[flt.channel].append(&mut flt.filters); + } + } + } + } + if parfilt.is_some() { + debug!("Append parallel filter step to pipeline"); + new_steps.push(PipelineStep::ParallelFiltersStep(parfilt.take().unwrap())); + } + new_steps +} + /// Validate the filter config, to give a helpful message intead of a panic. pub fn validate_filter(fs: usize, filter_config: &config::Filter) -> Res<()> { match filter_config { @@ -627,7 +590,7 @@ pub fn validate_filter(fs: usize, filter_config: &config::Filter) -> Res<()> { #[cfg(test)] mod tests { use crate::config::FileFormat; - use crate::filters::{find_data_in_wav, read_wav}; + use crate::filters::read_wav; use crate::filters::{pad_vector, read_coeff_file}; use crate::PrcFmt; @@ -772,16 +735,6 @@ mod tests { assert!(compare_waveforms(&values_padded, &values_5, 1e-15)); } - #[test] - pub fn test_analyze_wav() { - let info = find_data_in_wav("testdata/int32.wav").unwrap(); - println!("{info:?}"); - assert_eq!(info.sample_format, FileFormat::S32LE); - assert_eq!(info.data_offset, 44); - assert_eq!(info.data_length, 20); - assert_eq!(info.channels, 1); - } - #[test] pub fn test_read_wav() { let values = read_wav("testdata/int32.wav", 0).unwrap(); diff --git a/src/generatordevice.rs b/src/generatordevice.rs new file mode 100644 index 00000000..a2b3cd93 --- /dev/null +++ b/src/generatordevice.rs @@ -0,0 +1,240 @@ +use crate::audiodevice::*; +use crate::config; + +use std::f64::consts::PI; +use std::sync::mpsc; +use std::sync::{Arc, Barrier}; +use std::thread; + +use parking_lot::RwLock; + +use rand::{rngs::SmallRng, SeedableRng}; +use rand_distr::{Distribution, Uniform}; + +use crate::CaptureStatus; +use crate::CommandMessage; +use crate::PrcFmt; +use crate::ProcessingParameters; +use crate::ProcessingState; +use crate::Res; +use crate::StatusMessage; + +struct SineGenerator { + time: f64, + freq: f64, + delta_t: f64, + amplitude: PrcFmt, +} + +impl SineGenerator { + fn new(freq: f64, fs: usize, amplitude: PrcFmt) -> Self { + SineGenerator { + time: 0.0, + freq, + delta_t: 1.0 / fs as f64, + amplitude, + } + } +} + +impl Iterator for SineGenerator { + type Item = PrcFmt; + fn next(&mut self) -> Option { + let output = (self.freq * self.time * PI * 2.).sin() as PrcFmt * self.amplitude; + self.time += self.delta_t; + Some(output) + } +} + +struct SquareGenerator { + time: f64, + freq: f64, + delta_t: f64, + amplitude: PrcFmt, +} + +impl SquareGenerator { + fn new(freq: f64, fs: usize, amplitude: PrcFmt) -> Self { + SquareGenerator { + time: 0.0, + freq, + delta_t: 1.0 / fs as f64, + amplitude, + } + } +} + +impl Iterator for SquareGenerator { + type Item = PrcFmt; + fn next(&mut self) -> Option { + let output = (self.freq * self.time * PI * 2.).sin().signum() as PrcFmt * self.amplitude; + self.time += self.delta_t; + Some(output) + } +} + +struct NoiseGenerator { + rng: SmallRng, + distribution: Uniform, +} + +impl NoiseGenerator { + fn new(amplitude: PrcFmt) -> Self { + let rng = SmallRng::from_entropy(); + let distribution = Uniform::new_inclusive(-amplitude, amplitude); + NoiseGenerator { rng, distribution } + } +} + +impl Iterator for NoiseGenerator { + type Item = PrcFmt; + fn next(&mut self) -> Option { + Some(self.distribution.sample(&mut self.rng)) + } +} + +pub struct GeneratorDevice { + pub chunksize: usize, + pub samplerate: usize, + pub channels: usize, + pub signal: config::Signal, +} + +struct CaptureChannels { + audio: mpsc::SyncSender, + status: crossbeam_channel::Sender, + command: mpsc::Receiver, +} + +struct GeneratorParams { + channels: usize, + chunksize: usize, + capture_status: Arc>, + signal: config::Signal, + samplerate: usize, +} + +fn decibel_to_amplitude(level: PrcFmt) -> PrcFmt { + (10.0 as PrcFmt).powf(level / 20.0) +} + +fn capture_loop(params: GeneratorParams, msg_channels: CaptureChannels) { + debug!("starting generator loop"); + let mut chunk_stats = ChunkStats { + rms: vec![0.0; params.channels], + peak: vec![0.0; params.channels], + }; + let mut sine_gen; + let mut square_gen; + let mut noise_gen; + + let mut generator: &mut dyn Iterator = match params.signal { + config::Signal::Sine { freq, level } => { + sine_gen = SineGenerator::new(freq, params.samplerate, decibel_to_amplitude(level)); + &mut sine_gen as &mut dyn Iterator + } + config::Signal::Square { freq, level } => { + square_gen = SquareGenerator::new(freq, params.samplerate, decibel_to_amplitude(level)); + &mut square_gen as &mut dyn Iterator + } + config::Signal::WhiteNoise { level } => { + noise_gen = NoiseGenerator::new(decibel_to_amplitude(level)); + &mut noise_gen as &mut dyn Iterator + } + }; + + loop { + match msg_channels.command.try_recv() { + Ok(CommandMessage::Exit) => { + debug!("Exit message received, sending EndOfStream"); + let msg = AudioMessage::EndOfStream; + msg_channels.audio.send(msg).unwrap_or(()); + msg_channels + .status + .send(StatusMessage::CaptureDone) + .unwrap_or(()); + break; + } + Ok(CommandMessage::SetSpeed { .. }) => { + warn!("Signal generator does not support rate adjust. Ignoring request."); + } + Err(mpsc::TryRecvError::Empty) => {} + Err(mpsc::TryRecvError::Disconnected) => { + error!("Command channel was closed"); + break; + } + }; + let mut waveform = vec![0.0; params.chunksize]; + for (sample, value) in waveform.iter_mut().zip(&mut generator) { + *sample = value; + } + let mut waveforms = Vec::with_capacity(params.channels); + waveforms.push(waveform); + for _ in 1..params.channels { + waveforms.push(waveforms[0].clone()); + } + + let chunk = AudioChunk::new(waveforms, 1.0, -1.0, params.chunksize, params.chunksize); + + chunk.update_stats(&mut chunk_stats); + { + let mut capture_status = params.capture_status.write(); + capture_status + .signal_rms + .add_record_squared(chunk_stats.rms_linear()); + capture_status + .signal_peak + .add_record(chunk_stats.peak_linear()); + } + let msg = AudioMessage::Audio(chunk); + if msg_channels.audio.send(msg).is_err() { + info!("Processing thread has already stopped."); + break; + } + } + params.capture_status.write().state = ProcessingState::Inactive; +} + +/// Start a capture thread providing AudioMessages via a channel +impl CaptureDevice for GeneratorDevice { + fn start( + &mut self, + channel: mpsc::SyncSender, + barrier: Arc, + status_channel: crossbeam_channel::Sender, + command_channel: mpsc::Receiver, + capture_status: Arc>, + _processing_params: Arc, + ) -> Res>> { + let samplerate = self.samplerate; + let chunksize = self.chunksize; + let channels = self.channels; + let signal = self.signal; + + let handle = thread::Builder::new() + .name("SignalGenerator".to_string()) + .spawn(move || { + let params = GeneratorParams { + signal, + samplerate, + channels, + chunksize, + capture_status, + }; + match status_channel.send(StatusMessage::CaptureReady) { + Ok(()) => {} + Err(_err) => {} + } + barrier.wait(); + let msg_channels = CaptureChannels { + audio: channel, + status: status_channel, + command: command_channel, + }; + debug!("starting captureloop"); + capture_loop(params, msg_channels); + }) + .unwrap(); + Ok(Box::new(handle)) + } +} diff --git a/src/helpers.rs b/src/helpers.rs index 9932b60b..7a8863ed 100644 --- a/src/helpers.rs +++ b/src/helpers.rs @@ -79,3 +79,108 @@ pub fn linear_to_db(values: &mut [f32]) { } }); } + +// A simple PI controller for rate adjustments +pub struct PIRateController { + target_level: f64, + interval: f64, + k_p: f64, + k_i: f64, + frames_per_interval: f64, + accumulated: f64, + ramp_steps: usize, + ramp_trigger_limit: f64, + ramp_start: f64, + ramp_step: usize, +} + +impl PIRateController { + /// Create a new controller with default gains + pub fn new_with_default_gains(fs: usize, interval: f64, target_level: usize) -> Self { + let k_p = 0.2; + let k_i = 0.004; + let ramp_steps = 20; + let ramp_trigger_limit = 0.33; + Self::new( + fs, + interval, + target_level, + k_p, + k_i, + ramp_steps, + ramp_trigger_limit, + ) + } + + pub fn new( + fs: usize, + interval: f64, + target_level: usize, + k_p: f64, + k_i: f64, + ramp_steps: usize, + ramp_trigger_limit: f64, + ) -> Self { + let frames_per_interval = interval * fs as f64; + Self { + target_level: target_level as f64, + interval, + k_p, + k_i, + frames_per_interval, + accumulated: 0.0, + ramp_steps, + ramp_trigger_limit, + ramp_start: target_level as f64, + ramp_step: 0, + } + } + + /// Calculate the control output for the next measured value + pub fn next(&mut self, level: f64) -> f64 { + if self.ramp_step >= self.ramp_steps + && ((self.target_level - level) / self.target_level).abs() > self.ramp_trigger_limit + { + self.ramp_start = level; + self.ramp_step = 0; + debug!( + "Rate controller, buffer level is {}, starting to adjust back towards target of {}", + level, self.target_level + ); + } + if self.ramp_step == 0 { + self.ramp_start = level; + } + let current_target = if self.ramp_step < self.ramp_steps { + self.ramp_step += 1; + let tgt = self.ramp_start + + (self.target_level - self.ramp_start) + * (1.0 + - ((self.ramp_steps as f64 - self.ramp_step as f64) + / self.ramp_steps as f64) + .powi(4)); + debug!( + "Rate controller, ramp step {}/{}, current target {}", + self.ramp_step, self.ramp_steps, tgt + ); + tgt + } else { + self.target_level + }; + let err = level - current_target; + let rel_err = err / self.frames_per_interval; + self.accumulated += rel_err * self.interval; + let proportional = self.k_p * rel_err; + let integral = self.k_i * self.accumulated; + let mut output = proportional + integral; + trace!( + "Rate controller, error: {}, output: {}, P: {}, I: {}", + err, + output, + proportional, + integral + ); + output = output.clamp(-0.005, 0.005); + 1.0 - output + } +} diff --git a/src/lib.rs b/src/lib.rs index f297cb92..5ec12c83 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,8 +5,6 @@ extern crate alsa_sys; extern crate clap; #[cfg(feature = "cpal-backend")] extern crate cpal; -#[cfg(feature = "FFTW")] -extern crate fftw; #[macro_use] extern crate lazy_static; #[cfg(target_os = "macos")] @@ -27,7 +25,6 @@ extern crate num_traits; extern crate rand; extern crate rand_distr; extern crate rawsample; -#[cfg(not(feature = "FFTW"))] extern crate realfft; extern crate rubato; extern crate serde; @@ -52,6 +49,39 @@ use std::sync::{ Arc, }; +// Logging macros to give extra logs +// when the "debug" feature is enabled. +#[allow(unused)] +macro_rules! xtrace { ($($x:tt)*) => ( + #[cfg(feature = "debug")] { + log::trace!($($x)*) + } +) } +#[allow(unused)] +macro_rules! xdebug { ($($x:tt)*) => ( + #[cfg(feature = "debug")] { + log::debug!($($x)*) + } +) } +#[allow(unused)] +macro_rules! xinfo { ($($x:tt)*) => ( + #[cfg(feature = "debug")] { + log::info!($($x)*) + } +) } +#[allow(unused)] +macro_rules! xwarn { ($($x:tt)*) => ( + #[cfg(feature = "debug")] { + log::warn!($($x)*) + } +) } +#[allow(unused)] +macro_rules! xerror { ($($x:tt)*) => ( + #[cfg(feature = "debug")] { + log::error!($($x)*) + } +) } + // Sample format #[cfg(feature = "32bit")] pub type PrcFmt = f32; @@ -90,10 +120,7 @@ pub mod countertimer; pub mod cpaldevice; pub mod diffeq; pub mod dither; -#[cfg(not(feature = "FFTW"))] pub mod fftconv; -#[cfg(feature = "FFTW")] -pub mod fftconv_fftw; pub mod filedevice; #[cfg(all(target_os = "linux", feature = "bluez-backend"))] pub mod filedevice_bluez; @@ -102,10 +129,12 @@ pub mod filereader; #[cfg(target_os = "linux")] pub mod filereader_nonblock; pub mod filters; +pub mod generatordevice; pub mod helpers; pub mod limiter; pub mod loudness; pub mod mixer; +pub mod noisegate; pub mod processing; #[cfg(feature = "pulse-backend")] pub mod pulsedevice; @@ -114,6 +143,7 @@ pub mod socketserver; pub mod statefile; #[cfg(target_os = "windows")] pub mod wasapidevice; +pub mod wavtools; pub enum StatusMessage { PlaybackReady, @@ -125,6 +155,8 @@ pub enum StatusMessage { PlaybackDone, CaptureDone, SetSpeed(f64), + SetVolume(f32), + SetMute(bool), } pub enum CommandMessage { @@ -347,7 +379,12 @@ impl fmt::Display for ProcessingState { pub fn list_supported_devices() -> (Vec, Vec) { let mut playbacktypes = vec!["File".to_owned(), "Stdout".to_owned()]; - let mut capturetypes = vec!["File".to_owned(), "Stdin".to_owned()]; + let mut capturetypes = vec![ + "RawFile".to_owned(), + "WavFile".to_owned(), + "Stdin".to_owned(), + "SignalGenerator".to_owned(), + ]; if cfg!(target_os = "linux") { playbacktypes.push("Alsa".to_owned()); diff --git a/src/loudness.rs b/src/loudness.rs index 6be84415..e1835188 100644 --- a/src/loudness.rs +++ b/src/loudness.rs @@ -100,7 +100,7 @@ impl Filter for Loudness { let high_boost = (relboost * self.high_boost) as PrcFmt; let low_boost = (relboost * self.low_boost) as PrcFmt; self.active = relboost > 0.001; - info!( + debug!( "Updating loudness biquads, relative boost {}%", 100.0 * relboost ); diff --git a/src/mixer.rs b/src/mixer.rs index 16fd2e58..d7525ce9 100644 --- a/src/mixer.rs +++ b/src/mixer.rs @@ -208,6 +208,7 @@ mod tests { description: None, channels: chans, mapping: vec![map0, map1, map2, map3], + labels: None, }; let used = used_input_channels(&conf); assert_eq!(used, vec![true, true]); @@ -268,6 +269,7 @@ mod tests { description: None, channels: chans, mapping: vec![map0, map1, map2, map3], + labels: None, }; let used = used_input_channels(&conf); assert_eq!(used, vec![false, true]); @@ -328,6 +330,7 @@ mod tests { description: None, channels: chans, mapping: vec![map0, map1, map2, map3], + labels: None, }; let used = used_input_channels(&conf); assert_eq!(used, vec![false, true]); @@ -388,6 +391,7 @@ mod tests { description: None, channels: chans, mapping: vec![map0, map1, map2, map3], + labels: None, }; let used = used_input_channels(&conf); assert_eq!(used, vec![false, true]); @@ -448,6 +452,7 @@ mod tests { description: None, channels: chans, mapping: vec![map0, map1, map2, map3], + labels: None, }; let mix = mixer::Mixer::from_config("dummy".to_string(), conf); assert_eq!(mix.channels_in, 2); @@ -535,6 +540,7 @@ mod tests { description: None, channels: chans, mapping: vec![map0, map1, map2, map3], + labels: None, }; let mix = mixer::Mixer::from_config("dummy".to_string(), conf); assert_eq!(mix.channels_in, 2); diff --git a/src/noisegate.rs b/src/noisegate.rs new file mode 100644 index 00000000..32b80f4d --- /dev/null +++ b/src/noisegate.rs @@ -0,0 +1,198 @@ +use crate::audiodevice::AudioChunk; +use crate::config; +use crate::filters::Processor; +use crate::PrcFmt; +use crate::Res; + +#[derive(Clone, Debug)] +pub struct NoiseGate { + pub name: String, + pub channels: usize, + pub monitor_channels: Vec, + pub process_channels: Vec, + pub attack: PrcFmt, + pub release: PrcFmt, + pub threshold: PrcFmt, + pub factor: PrcFmt, + pub samplerate: usize, + pub scratch: Vec, + pub prev_loudness: PrcFmt, +} + +impl NoiseGate { + /// Creates a NoiseGate from a config struct + pub fn from_config( + name: &str, + config: config::NoiseGateParameters, + samplerate: usize, + chunksize: usize, + ) -> Self { + let name = name.to_string(); + let channels = config.channels; + let srate = samplerate as PrcFmt; + let mut monitor_channels = config.monitor_channels(); + if monitor_channels.is_empty() { + for n in 0..channels { + monitor_channels.push(n); + } + } + let mut process_channels = config.process_channels(); + if process_channels.is_empty() { + for n in 0..channels { + process_channels.push(n); + } + } + let attack = (-1.0 / srate / config.attack).exp(); + let release = (-1.0 / srate / config.release).exp(); + let scratch = vec![0.0; chunksize]; + + debug!("Creating noisegate '{}', channels: {}, monitor_channels: {:?}, process_channels: {:?}, attack: {}, release: {}, threshold: {}, attenuation: {}", + name, channels, process_channels, monitor_channels, attack, release, config.threshold, config.attenuation); + + let factor = (10.0 as PrcFmt).powf(-config.attenuation / 20.0); + + NoiseGate { + name, + channels, + monitor_channels, + process_channels, + attack, + release, + threshold: config.threshold, + factor, + samplerate, + scratch, + prev_loudness: 0.0, + } + } + + /// Sum all channels that are included in loudness monitoring, store result in self.scratch + fn sum_monitor_channels(&mut self, input: &AudioChunk) { + let ch = self.monitor_channels[0]; + self.scratch.copy_from_slice(&input.waveforms[ch]); + for ch in self.monitor_channels.iter().skip(1) { + for (acc, val) in self.scratch.iter_mut().zip(input.waveforms[*ch].iter()) { + *acc += *val; + } + } + } + + /// Estimate loudness, store result in self.scratch + fn estimate_loudness(&mut self) { + for val in self.scratch.iter_mut() { + // convert to dB + *val = 20.0 * (val.abs() + 1.0e-9).log10(); + if *val >= self.prev_loudness { + *val = self.attack * self.prev_loudness + (1.0 - self.attack) * *val; + } else { + *val = self.release * self.prev_loudness + (1.0 - self.release) * *val; + } + self.prev_loudness = *val; + } + } + + /// Calculate linear gain, store result in self.scratch + fn calculate_linear_gain(&mut self) { + for val in self.scratch.iter_mut() { + if *val < self.threshold { + *val = self.factor; + } else { + *val = 1.0; + } + } + } + + fn apply_gain(&self, input: &mut [PrcFmt]) { + for (val, gain) in input.iter_mut().zip(self.scratch.iter()) { + *val *= gain; + } + } +} + +impl Processor for NoiseGate { + fn name(&self) -> &str { + &self.name + } + + /// Apply a NoiseGate to an AudioChunk, modifying it in-place. + fn process_chunk(&mut self, input: &mut AudioChunk) -> Res<()> { + self.sum_monitor_channels(input); + self.estimate_loudness(); + self.calculate_linear_gain(); + for ch in self.process_channels.iter() { + self.apply_gain(&mut input.waveforms[*ch]); + } + Ok(()) + } + + fn update_parameters(&mut self, config: config::Processor) { + if let config::Processor::NoiseGate { + parameters: config, .. + } = config + { + let channels = config.channels; + let srate = self.samplerate as PrcFmt; + let mut monitor_channels = config.monitor_channels(); + if monitor_channels.is_empty() { + for n in 0..channels { + monitor_channels.push(n); + } + } + let mut process_channels = config.process_channels(); + if process_channels.is_empty() { + for n in 0..channels { + process_channels.push(n); + } + } + let attack = (-1.0 / srate / config.attack).exp(); + let release = (-1.0 / srate / config.release).exp(); + + self.monitor_channels = monitor_channels; + self.process_channels = process_channels; + self.attack = attack; + self.release = release; + self.threshold = config.threshold; + self.factor = (10.0 as PrcFmt).powf(-config.attenuation / 20.0); + + debug!("Updated noise gate '{}', monitor_channels: {:?}, process_channels: {:?}, attack: {}, release: {}, threshold: {}, attenuation: {}", + self.name, self.process_channels, self.monitor_channels, attack, release, config.threshold, config.attenuation); + } else { + // This should never happen unless there is a bug somewhere else + panic!("Invalid config change!"); + } + } +} + +/// Validate the noise gate config, to give a helpful message intead of a panic. +pub fn validate_noise_gate(config: &config::NoiseGateParameters) -> Res<()> { + let channels = config.channels; + if config.attack <= 0.0 { + let msg = "Attack value must be larger than zero."; + return Err(config::ConfigError::new(msg).into()); + } + if config.release <= 0.0 { + let msg = "Release value must be larger than zero."; + return Err(config::ConfigError::new(msg).into()); + } + for ch in config.monitor_channels().iter() { + if *ch >= channels { + let msg = format!( + "Invalid monitor channel: {}, max is: {}.", + *ch, + channels - 1 + ); + return Err(config::ConfigError::new(&msg).into()); + } + } + for ch in config.process_channels().iter() { + if *ch >= channels { + let msg = format!( + "Invalid channel to process: {}, max is: {}.", + *ch, + channels - 1 + ); + return Err(config::ConfigError::new(&msg).into()); + } + } + Ok(()) +} diff --git a/src/processing.rs b/src/processing.rs index 4aaa6a4c..9d5a1e9c 100644 --- a/src/processing.rs +++ b/src/processing.rs @@ -2,6 +2,9 @@ use crate::audiodevice::*; use crate::config; use crate::filters; use crate::ProcessingParameters; +use audio_thread_priority::{ + demote_current_thread_from_real_time, promote_current_thread_to_real_time, +}; use std::sync::mpsc; use std::sync::{Arc, Barrier}; use std::thread; @@ -15,8 +18,87 @@ pub fn run_processing( processing_params: Arc, ) -> thread::JoinHandle<()> { thread::spawn(move || { + let chunksize = conf_proc.devices.chunksize; + let samplerate = conf_proc.devices.samplerate; + let multithreaded = conf_proc.devices.multithreaded(); + let nbr_threads = conf_proc.devices.worker_threads(); + let hw_threads = std::thread::available_parallelism() + .map(|p| p.get()) + .unwrap_or_default(); + if nbr_threads > hw_threads && multithreaded { + warn!( + "Requested {} worker threads. For optimal performance, this number should not \ + exceed the available CPU cores, which is {}.", + nbr_threads, hw_threads + ); + } + if hw_threads == 1 && multithreaded { + warn!( + "This system only has one CPU core, multithreaded processing is not recommended." + ); + } + if nbr_threads == 1 && multithreaded { + warn!( + "Requested multithreaded processing with one worker thread. \ + Performance can improve by adding more threads or disabling multithreading." + ); + } let mut pipeline = filters::Pipeline::from_config(conf_proc, processing_params.clone()); debug!("build filters, waiting to start processing loop"); + + let thread_handle = + match promote_current_thread_to_real_time(chunksize as u32, samplerate as u32) { + Ok(h) => { + debug!("Processing thread has real-time priority."); + Some(h) + } + Err(err) => { + warn!( + "Processing thread could not get real time priority, error: {}", + err + ); + None + } + }; + + // Initialize rayon thread pool + if multithreaded { + match rayon::ThreadPoolBuilder::new() + .num_threads(nbr_threads) + .build_global() + { + Ok(_) => { + debug!( + "Initialized global thread pool with {} workers", + rayon::current_num_threads() + ); + rayon::broadcast(|_| { + match promote_current_thread_to_real_time( + chunksize as u32, + samplerate as u32, + ) { + Ok(_) => { + debug!( + "Worker thread {} has real-time priority.", + rayon::current_thread_index().unwrap_or_default() + ); + } + Err(err) => { + warn!( + "Worker thread {} could not get real time priority, error: {}", + rayon::current_thread_index().unwrap_or_default(), + err + ); + } + }; + }); + } + Err(err) => { + warn!("Failed to build thread pool, error: {}", err); + } + }; + } + barrier_proc.wait(); debug!("Processing loop starts now!"); loop { @@ -85,5 +167,15 @@ pub fn run_processing( }; } processing_params.set_processing_load(0.0); + if let Some(h) = thread_handle { + match demote_current_thread_from_real_time(h) { + Ok(_) => { + debug!("Processing thread returned to normal priority.") + } + Err(_) => { + warn!("Could not bring the processing thread back to normal priority.") + } + }; + } }) } diff --git a/src/pulsedevice.rs b/src/pulsedevice.rs index fb360772..ab06f805 100644 --- a/src/pulsedevice.rs +++ b/src/pulsedevice.rs @@ -17,6 +17,7 @@ use std::time::{Duration, Instant}; use crate::CommandMessage; use crate::PrcFmt; +use crate::ProcessingParameters; use crate::ProcessingState; use crate::Res; use crate::StatusMessage; @@ -208,11 +209,6 @@ impl PlaybackDevice for PulsePlaybackDevice { .signal_peak .add_record(chunk_stats.peak_linear()); } - //trace!( - // "Playback signal RMS: {:?}, peak: {:?}", - // chunk_stats.rms_db(), - // chunk_stats.peak_db() - //); } Ok(AudioMessage::Pause) => { trace!("Pause message received"); @@ -251,13 +247,6 @@ fn nbr_capture_bytes( store_bytes_per_sample: usize, ) -> usize { if let Some(resampl) = &resampler { - //let new_capture_bytes = resampl.input_frames_next() * channels * store_bytes_per_sample; - //trace!( - // "Resampler needs {} frames, will read {} bytes", - // resampl.input_frames_next(), - // new_capture_bytes - //); - //new_capture_bytes resampl.input_frames_next() * channels * store_bytes_per_sample } else { capture_bytes @@ -273,6 +262,7 @@ impl CaptureDevice for PulseCaptureDevice { status_channel: crossbeam_channel::Sender, command_channel: mpsc::Receiver, capture_status: Arc>, + _processing_params: Arc, ) -> Res>> { let devname = self.devname.clone(); let samplerate = self.samplerate; @@ -381,7 +371,7 @@ impl CaptureDevice for PulseCaptureDevice { trace!( "Measured sample rate is {:.1} Hz, signal RMS is {:?}", measured_rate_f, - capture_status.signal_rms.last(), + capture_status.signal_rms.last_sqrt(), ); let mut capture_status = RwLockUpgradableReadGuard::upgrade(capture_status); // to write lock capture_status.measured_samplerate = measured_rate_f as usize; @@ -403,7 +393,6 @@ impl CaptureDevice for PulseCaptureDevice { capture_status.signal_rms.add_record_squared(chunk_stats.rms_linear()); capture_status.signal_peak.add_record(chunk_stats.peak_linear()); } - //trace!("Capture signal rms {:?}, peak {:?}", chunk_stats.rms_db(), chunk_stats.peak_db()); value_range = chunk.maxval - chunk.minval; state = silence_counter.update(value_range); if state == ProcessingState::Running { diff --git a/src/socketserver.rs b/src/socketserver.rs index 145661d5..0db3419b 100644 --- a/src/socketserver.rs +++ b/src/socketserver.rs @@ -60,6 +60,13 @@ pub struct ServerParameters<'a> { pub cert_pass: Option<&'a str>, } +#[derive(Debug, PartialEq, Deserialize)] +#[serde(untagged)] +enum ValueWithOptionalLimits { + Plain(f32), + Limited(f32, f32, f32), +} + #[derive(Debug, PartialEq, Deserialize)] enum WsCommand { SetConfigFilePath(String), @@ -100,14 +107,15 @@ enum WsCommand { SetUpdateInterval(usize), GetVolume, SetVolume(f32), - AdjustVolume(f32), + AdjustVolume(ValueWithOptionalLimits), GetMute, SetMute(bool), ToggleMute, + GetFaders, GetFaderVolume(usize), SetFaderVolume(usize, f32), SetFaderExternalVolume(usize, f32), - AdjustFaderVolume(usize, f32), + AdjustFaderVolume(usize, ValueWithOptionalLimits), GetFaderMute(usize), SetFaderMute(usize, bool), ToggleFaderMute(usize), @@ -147,6 +155,12 @@ struct PbCapLevels { capture: Vec, } +#[derive(Debug, PartialEq, Serialize)] +struct Fader { + volume: f32, + mute: bool, +} + #[derive(Debug, PartialEq, Serialize)] enum WsReply { SetConfigFilePath { @@ -315,6 +329,10 @@ enum WsReply { SetFaderExternalVolume { result: WsResult, }, + GetFaders { + result: WsResult, + value: Vec, + }, GetFaderVolume { result: WsResult, value: (usize, f32), @@ -840,10 +858,28 @@ fn handle_command( result: WsResult::Ok, }) } - WsCommand::AdjustVolume(nbr) => { + WsCommand::AdjustVolume(value) => { let mut tempvol = shared_data_inst.processing_params.target_volume(0); - tempvol += nbr; - tempvol = clamped_volume(tempvol); + let (volchange, minvol, maxvol) = match value { + ValueWithOptionalLimits::Plain(vol) => (vol, -150.0, 50.0), + ValueWithOptionalLimits::Limited(vol, min, max) => (vol, min, max), + }; + if maxvol < minvol { + return Some(WsReply::AdjustVolume { + result: WsResult::Error, + value: tempvol, + }); + } + tempvol += volchange; + if tempvol < minvol { + tempvol = minvol; + warn!("Clamped volume at {} dB", minvol) + } + if tempvol > maxvol { + tempvol = maxvol; + warn!("Clamped volume at {} dB", maxvol) + } + shared_data_inst .processing_params .set_target_volume(0, tempvol); @@ -890,6 +926,22 @@ fn handle_command( value: !tempmute, }) } + WsCommand::GetFaders => { + let volumes = shared_data_inst.processing_params.volumes(); + let mutes = shared_data_inst.processing_params.mutes(); + let faders = volumes + .iter() + .zip(mutes) + .map(|(v, m)| Fader { + volume: *v, + mute: m, + }) + .collect(); + Some(WsReply::GetFaders { + result: WsResult::Ok, + value: faders, + }) + } WsCommand::GetFaderVolume(ctrl) => { if ctrl > ProcessingParameters::NUM_FADERS - 1 { return Some(WsReply::GetFaderVolume { @@ -947,16 +999,33 @@ fn handle_command( result: WsResult::Ok, }) } - WsCommand::AdjustFaderVolume(ctrl, nbr) => { + WsCommand::AdjustFaderVolume(ctrl, value) => { + let (volchange, minvol, maxvol) = match value { + ValueWithOptionalLimits::Plain(vol) => (vol, -150.0, 50.0), + ValueWithOptionalLimits::Limited(vol, min, max) => (vol, min, max), + }; if ctrl > ProcessingParameters::NUM_FADERS - 1 { return Some(WsReply::AdjustFaderVolume { result: WsResult::Error, - value: (ctrl, nbr), + value: (ctrl, volchange), }); } let mut tempvol = shared_data_inst.processing_params.target_volume(ctrl); - tempvol += nbr; - tempvol = clamped_volume(tempvol); + if maxvol < minvol { + return Some(WsReply::AdjustFaderVolume { + result: WsResult::Error, + value: (ctrl, tempvol), + }); + } + tempvol += volchange; + if tempvol < minvol { + tempvol = minvol; + warn!("Clamped volume at {} dB", minvol) + } + if tempvol > maxvol { + tempvol = maxvol; + warn!("Clamped volume at {} dB", maxvol) + } shared_data_inst .processing_params .set_target_volume(ctrl, tempvol); diff --git a/src/wasapidevice.rs b/src/wasapidevice.rs index f661fc56..e27387a8 100644 --- a/src/wasapidevice.rs +++ b/src/wasapidevice.rs @@ -3,23 +3,27 @@ use crate::config; use crate::config::{ConfigError, SampleFormat}; use crate::conversions::{buffer_to_chunk_rawbytes, chunk_to_buffer_rawbytes}; use crate::countertimer; +use crate::helpers::PIRateController; use crossbeam_channel::{bounded, unbounded, Receiver, Sender, TryRecvError, TrySendError}; use parking_lot::{RwLock, RwLockUpgradableReadGuard}; use rubato::VecResampler; use std::collections::VecDeque; use std::rc::Rc; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc; -use std::sync::{Arc, Barrier}; +use std::sync::{Arc, Barrier, Mutex}; use std::thread; use std::time::Duration; use wasapi; use wasapi::DeviceCollection; -use windows::w; -use windows::Win32::System::Threading::AvSetMmThreadCharacteristicsW; + +use audio_thread_priority::{ + demote_current_thread_from_real_time, promote_current_thread_to_real_time, +}; use crate::CommandMessage; use crate::PrcFmt; +use crate::ProcessingParameters; use crate::ProcessingState; use crate::Res; use crate::StatusMessage; @@ -120,6 +124,41 @@ fn wave_format( } } +fn get_supported_wave_format( + audio_client: &wasapi::AudioClient, + sample_format: &SampleFormat, + samplerate: usize, + channels: usize, + sharemode: &wasapi::ShareMode, +) -> Res { + let wave_format = wave_format(sample_format, samplerate, channels); + match sharemode { + wasapi::ShareMode::Exclusive => { + audio_client.is_supported_exclusive_with_quirks(&wave_format) + } + wasapi::ShareMode::Shared => match audio_client.is_supported(&wave_format, sharemode) { + Ok(None) => { + debug!("Device supports format {:?}", wave_format); + Ok(wave_format) + } + Ok(Some(modified)) => { + let msg = format!( + "Device doesn't support format:\n{:#?}\nClosest match is:\n{:#?}", + wave_format, modified + ); + Err(ConfigError::new(&msg).into()) + } + Err(err) => { + let msg = format!( + "Device doesn't support format:\n{:#?}\nError: {}", + wave_format, err + ); + Err(ConfigError::new(&msg).into()) + } + }, + } +} + fn open_playback( devname: &Option, samplerate: usize, @@ -150,39 +189,28 @@ fn open_playback( trace!("Found playback device {:?}", devname); let mut audio_client = device.get_iaudioclient()?; trace!("Got playback iaudioclient"); - let wave_format = wave_format(sample_format, samplerate, channels); - match audio_client.is_supported(&wave_format, &sharemode) { - Ok(None) => { - debug!("Playback device supports format {:?}", wave_format) - } - Ok(Some(modified)) => { - let msg = format!( - "Playback device doesn't support format:\n{:#?}\nClosest match is:\n{:#?}", - wave_format, modified - ); - return Err(ConfigError::new(&msg).into()); - } - Err(err) => { - let msg = format!( - "Playback device doesn't support format:\n{:#?}\nError: {}", - wave_format, err - ); - return Err(ConfigError::new(&msg).into()); - } - }; + let wave_format = get_supported_wave_format( + &audio_client, + sample_format, + samplerate, + channels, + &sharemode, + )?; let (def_time, min_time) = audio_client.get_periods()?; - debug!( - "playback default period {}, min period {}", - def_time, min_time - ); + let aligned_time = + audio_client.calculate_aligned_period_near(def_time, Some(128), &wave_format)?; audio_client.initialize_client( &wave_format, - def_time, + aligned_time, &wasapi::Direction::Render, &sharemode, false, )?; - debug!("initialized capture"); + debug!( + "playback default period {}, min period {}, aligned period {}", + def_time, min_time, aligned_time + ); + debug!("initialized playback audio client"); let handle = audio_client.set_get_eventhandle()?; let render_client = audio_client.get_audiorenderclient()?; debug!("Opened Wasapi playback device {:?}", devname); @@ -232,26 +260,13 @@ fn open_capture( trace!("Found capture device {:?}", devname); let mut audio_client = device.get_iaudioclient()?; trace!("Got capture iaudioclient"); - let wave_format = wave_format(sample_format, samplerate, channels); - match audio_client.is_supported(&wave_format, &sharemode) { - Ok(None) => { - debug!("Capture device supports format {:?}", wave_format) - } - Ok(Some(modified)) => { - let msg = format!( - "Capture device doesn't support format:\n{:#?}\nClosest match is:\n{:#?}", - wave_format, modified - ); - return Err(ConfigError::new(&msg).into()); - } - Err(err) => { - let msg = format!( - "Capture device doesn't support format:\n{:#?}\nError: {}", - wave_format, err - ); - return Err(ConfigError::new(&msg).into()); - } - }; + let wave_format = get_supported_wave_format( + &audio_client, + sample_format, + samplerate, + channels, + &sharemode, + )?; let (def_time, min_time) = audio_client.get_periods()?; debug!( "capture default period {}, min period {}", @@ -264,7 +279,7 @@ fn open_capture( &sharemode, loopback, )?; - debug!("initialized capture"); + debug!("initialized capture audio client"); let handle = audio_client.set_get_eventhandle()?; trace!("capture got event handle"); let capture_client = audio_client.get_audiocaptureclient()?; @@ -275,7 +290,7 @@ fn open_capture( struct PlaybackSync { rx_play: Receiver, tx_cb: Sender, - bufferfill: Arc, + bufferfill: Arc>, } enum PlaybackDeviceMessage { @@ -323,15 +338,19 @@ fn playback_loop( debug!("Waited for data for {} ms", waited_millis); // Raise priority - let mut task_idx = 0; - unsafe { - let _ = AvSetMmThreadCharacteristicsW(w!("Pro Audio"), &mut task_idx); - } - if task_idx > 0 { - debug!("Playback thread raised priority, task index: {}", task_idx); - } else { - warn!("Failed to raise playback thread priority"); - } + let _thread_handle = match promote_current_thread_to_real_time(0, 1) { + Ok(h) => { + debug!("Playback inner thread has real-time priority."); + Some(h) + } + Err(err) => { + warn!( + "Playback inner thread could not get real time priority, error: {}", + err + ); + None + } + }; audio_client.start_stream()?; let mut running = true; @@ -351,7 +370,7 @@ fn playback_loop( device_time = device_prevtime + buffer_free_frame_count as f64 / samplerate; } trace!( - "Device time counted up by {} s", + "Device time counted up by {:.4} s", device_time - device_prevtime ); if buffer_free_frame_count > 0 @@ -359,7 +378,7 @@ fn playback_loop( > 1.75 * (buffer_free_frame_count as f64 / samplerate) { warn!( - "Missing event! Resetting stream. Interval {} s, expected {} s", + "Missing event! Resetting stream. Interval {:.4} s, expected {:.4} s", device_time - device_prevtime, buffer_free_frame_count as f64 / samplerate ); @@ -406,12 +425,13 @@ fn playback_loop( } render_client.write_to_device_from_deque( buffer_free_frame_count as usize, - blockalign, &mut sample_queue, None, )?; let curr_buffer_fill = sample_queue.len() / blockalign + sync.rx_play.len() * chunksize; - sync.bufferfill.store(curr_buffer_fill, Ordering::Relaxed); + if let Ok(mut estimator) = sync.bufferfill.try_lock() { + estimator.add(curr_buffer_fill) + } trace!("write ok"); //println!("{} bef",prev_inst.elapsed().as_micros()); if handle.wait_for_event(1000).is_err() { @@ -462,15 +482,19 @@ fn capture_loop( let mut saved_buffer: Option> = None; // Raise priority - let mut task_idx = 0; - unsafe { - let _ = AvSetMmThreadCharacteristicsW(w!("Pro Audio"), &mut task_idx); - } - if task_idx > 0 { - debug!("Capture thread raised priority, task index: {}", task_idx); - } else { - warn!("Failed to raise capture thread priority"); - } + let _thread_handle = match promote_current_thread_to_real_time(0, 1) { + Ok(h) => { + debug!("Capture inner thread has real-time priority."); + Some(h) + } + Err(err) => { + warn!( + "Capture inner thread could not get real time priority, error: {}", + err + ); + None + } + }; trace!("Starting capture stream"); audio_client.start_stream()?; trace!("Started capture stream"); @@ -527,7 +551,7 @@ fn capture_loop( data.resize(nbr_bytes, 0); } let (nbr_frames_read, flags) = - capture_client.read_from_device(blockalign, &mut data[0..nbr_bytes])?; + capture_client.read_from_device(&mut data[0..nbr_bytes])?; if nbr_frames_read != available_frames { warn!( "Expected {} frames, got {}", @@ -555,10 +579,8 @@ fn capture_loop( if data.len() < (nbr_bytes + nbr_bytes_extra) { data.resize(nbr_bytes + nbr_bytes_extra, 0); } - let (nbr_frames_read, flags) = capture_client.read_from_device( - blockalign, - &mut data[nbr_bytes..(nbr_bytes + nbr_bytes_extra)], - )?; + let (nbr_frames_read, flags) = capture_client + .read_from_device(&mut data[nbr_bytes..(nbr_bytes + nbr_bytes_extra)])?; if nbr_frames_read != extra_frames { warn!("Expected {} frames, got {}", extra_frames, nbr_frames_read); } @@ -620,7 +642,7 @@ impl PlaybackDevice for WasapiPlaybackDevice { .name("WasapiPlayback".to_string()) .spawn(move || { // Devices typically request around 1000 frames per buffer, set a reasonable capacity for the channel - let channel_capacity = 8 * 1024 / chunksize + 1; + let channel_capacity = 8 * 1024 / chunksize + 3; debug!( "Using a playback channel capacity of {} chunks.", channel_capacity @@ -628,7 +650,7 @@ impl PlaybackDevice for WasapiPlaybackDevice { let (tx_dev, rx_dev) = bounded(channel_capacity); let (tx_state_dev, rx_state_dev) = bounded(0); let (tx_disconnectreason, rx_disconnectreason) = unbounded(); - let buffer_fill = Arc::new(AtomicUsize::new(0)); + let buffer_fill = Arc::new(Mutex::new(countertimer::DeviceBufferEstimator::new(samplerate))); let buffer_fill_clone = buffer_fill.clone(); let mut buffer_avg = countertimer::Averager::new(); let mut timer = countertimer::Stopwatch::new(); @@ -637,6 +659,8 @@ impl PlaybackDevice for WasapiPlaybackDevice { peak: vec![0.0; channels], }; + let mut rate_controller = PIRateController::new_with_default_gains(samplerate, adjust_period as f64, target_level); + trace!("Build output stream"); let mut conversion_result; @@ -707,6 +731,19 @@ impl PlaybackDevice for WasapiPlaybackDevice { } debug!("Playback device ready and waiting"); barrier.wait(); + let thread_handle = match promote_current_thread_to_real_time(0, 1) { + Ok(h) => { + debug!("Playback outer thread has real-time priority."); + Some(h) + } + Err(err) => { + warn!( + "Playback outer thread could not get real time priority, error: {}", + err + ); + None + } + }; debug!("Playback device starts now!"); loop { match rx_state_dev.try_recv() { @@ -736,46 +773,42 @@ impl PlaybackDevice for WasapiPlaybackDevice { 0u8; channels * chunk.frames * sample_format.bytes_per_sample() ]; - buffer_avg.add_value(buffer_fill.load(Ordering::Relaxed) as f64); - { - if adjust && timer.larger_than_millis((1000.0 * adjust_period) as u64) { - if let Some(av_delay) = buffer_avg.average() { - let speed = calculate_speed( - av_delay, - target_level, - adjust_period, - samplerate as u32, - ); - timer.restart(); - buffer_avg.restart(); - debug!( - "Current buffer level {:.1}, set capture rate to {:.4}%", - av_delay, - 100.0 * speed - ); - status_channel - .send(StatusMessage::SetSpeed(speed)) - .unwrap_or(()); - playback_status.write().buffer_level = - av_delay as usize; - } + buffer_avg.add_value(buffer_fill.try_lock().map(|b| b.estimate() as f64).unwrap_or_default()); + + if adjust && timer.larger_than_millis((1000.0 * adjust_period) as u64) { + if let Some(av_delay) = buffer_avg.average() { + let speed = rate_controller.next(av_delay); + timer.restart(); + buffer_avg.restart(); + debug!( + "Current buffer level {:.1}, set capture rate to {:.4}%", + av_delay, + 100.0 * speed + ); + status_channel + .send(StatusMessage::SetSpeed(speed)) + .unwrap_or(()); + playback_status.write().buffer_level = + av_delay as usize; } - conversion_result = - chunk_to_buffer_rawbytes(&chunk, &mut buf, &sample_format); - chunk.update_stats(&mut chunk_stats); - { - let mut playback_status = playback_status.write(); - if conversion_result.1 > 0 { - playback_status.clipped_samples += - conversion_result.1; - } - playback_status - .signal_rms - .add_record_squared(chunk_stats.rms_linear()); - playback_status - .signal_peak - .add_record(chunk_stats.peak_linear()); + } + conversion_result = + chunk_to_buffer_rawbytes(&chunk, &mut buf, &sample_format); + chunk.update_stats(&mut chunk_stats); + if let Some(mut playback_status) = playback_status.try_write() { + if conversion_result.1 > 0 { + playback_status.clipped_samples += + conversion_result.1; } + playback_status + .signal_rms + .add_record_squared(chunk_stats.rms_linear()); + playback_status + .signal_peak + .add_record(chunk_stats.peak_linear()); + } + else { + xtrace!("playback status blocked, skip rms update"); } match tx_dev.send(PlaybackDeviceMessage::Data(buf)) { Ok(_) => {} @@ -810,6 +843,16 @@ impl PlaybackDevice for WasapiPlaybackDevice { } } } + if let Some(h) = thread_handle { + match demote_current_thread_from_real_time(h) { + Ok(_) => { + debug!("Playback outer thread returned to normal priority.") + } + Err(_) => { + warn!("Could not bring the outer playback thread back to normal priority.") + } + }; + } match tx_dev.send(PlaybackDeviceMessage::Stop) { Ok(_) => { debug!("Wait for inner playback thread to exit"); @@ -892,6 +935,7 @@ impl CaptureDevice for WasapiCaptureDevice { status_channel: crossbeam_channel::Sender, command_channel: mpsc::Receiver, capture_status: Arc>, + _processing_params: Arc, ) -> Res>> { let exclusive = self.exclusive; let loopback = self.loopback; @@ -1001,6 +1045,19 @@ impl CaptureDevice for WasapiCaptureDevice { Err(_err) => {} } barrier.wait(); + let thread_handle = match promote_current_thread_to_real_time(0, 1) { + Ok(h) => { + debug!("Capture outer thread has real-time priority."); + Some(h) + } + Err(err) => { + warn!( + "Capture outer thread could not get real time priority, error: {}", + err + ); + None + } + }; debug!("Capture device starts now!"); loop { match command_channel.try_recv() { @@ -1092,24 +1149,26 @@ impl CaptureDevice for WasapiCaptureDevice { *element = data_queue.pop_front().unwrap(); } averager.add_value(capture_frames); - { - let capture_status = capture_status.upgradable_read(); + if let Some(capture_status) = capture_status.try_upgradable_read() { if averager.larger_than_millis(capture_status.update_interval as u64) { let samples_per_sec = averager.average(); averager.restart(); let measured_rate_f = samples_per_sec; - debug!( - "Measured sample rate is {:.1} Hz", - measured_rate_f - ); - let mut capture_status = RwLockUpgradableReadGuard::upgrade(capture_status); // to write lock - capture_status.measured_samplerate = measured_rate_f as usize; - capture_status.signal_range = value_range as f32; - capture_status.rate_adjust = rate_adjust as f32; - capture_status.state = state; + if let Ok(mut capture_status) = RwLockUpgradableReadGuard::try_upgrade(capture_status) { + capture_status.measured_samplerate = measured_rate_f as usize; + capture_status.signal_range = value_range as f32; + capture_status.rate_adjust = rate_adjust as f32; + capture_status.state = state; + } + else { + xtrace!("capture status upgrade blocked, skip update"); + } } } + else { + xtrace!("capture status blocked, skip update"); + } watcher_averager.add_value(capture_frames); if watcher_averager.larger_than_millis(rate_measure_interval) { @@ -1139,12 +1198,13 @@ impl CaptureDevice for WasapiCaptureDevice { &capture_status.read().used_channels, ); chunk.update_stats(&mut chunk_stats); - //trace!("Capture rms {:?}, peak {:?}", chunk_stats.rms_db(), chunk_stats.peak_db()); - { - let mut capture_status = capture_status.write(); + if let Some(mut capture_status) = capture_status.try_write() { capture_status.signal_rms.add_record_squared(chunk_stats.rms_linear()); capture_status.signal_peak.add_record(chunk_stats.peak_linear()); } + else { + xtrace!("capture status blocked, skip rms update"); + } value_range = chunk.maxval - chunk.minval; state = silence_counter.update(value_range); if state == ProcessingState::Running { @@ -1174,6 +1234,16 @@ impl CaptureDevice for WasapiCaptureDevice { } } } + if let Some(h) = thread_handle { + match demote_current_thread_from_real_time(h) { + Ok(_) => { + debug!("Capture outer thread returned to normal priority.") + } + Err(_) => { + warn!("Could not bring the outer capture thread back to normal priority.") + } + }; + } stop_signal.store(true, Ordering::Relaxed); debug!("Wait for inner capture thread to exit"); innerhandle.join().unwrap_or(()); diff --git a/src/wavtools.rs b/src/wavtools.rs new file mode 100644 index 00000000..60e7fbe8 --- /dev/null +++ b/src/wavtools.rs @@ -0,0 +1,316 @@ +use std::convert::TryInto; +use std::fs::File; +use std::io::BufReader; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::mem; + +use crate::config::{ConfigError, SampleFormat}; +use crate::Res; + +const RIFF: &[u8] = "RIFF".as_bytes(); +const WAVE: &[u8] = "WAVE".as_bytes(); +const DATA: &[u8] = "data".as_bytes(); +const FMT: &[u8] = "fmt ".as_bytes(); + +/// Windows Guid +/// Used to give sample format in the extended WAVEFORMATEXTENSIBLE wav header +#[derive(Debug, PartialEq, Eq)] +struct Guid { + data1: u32, + data2: u16, + data3: u16, + data4: [u8; 8], +} + +impl Guid { + fn from_slice(data: &[u8; 16]) -> Guid { + let data1 = read_u32(data, 0); + let data2 = read_u16(data, 4); + let data3 = read_u16(data, 6); + let data4 = data[8..16].try_into().unwrap_or([0; 8]); + Guid { + data1, + data2, + data3, + data4, + } + } +} + +/// KSDATAFORMAT_SUBTYPE_IEEE_FLOAT +const SUBTYPE_FLOAT: Guid = Guid { + data1: 3, + data2: 0, + data3: 16, + data4: [128, 0, 0, 170, 0, 56, 155, 113], +}; + +/// KSDATAFORMAT_SUBTYPE_PCM +const SUBTYPE_PCM: Guid = Guid { + data1: 1, + data2: 0, + data3: 16, + data4: [128, 0, 0, 170, 0, 56, 155, 113], +}; + +#[derive(Debug)] +pub struct WavParams { + pub sample_format: SampleFormat, + pub sample_rate: usize, + pub data_offset: usize, + pub data_length: usize, + pub channels: usize, +} + +fn read_u32(buffer: &[u8], start_index: usize) -> u32 { + u32::from_le_bytes( + buffer[start_index..start_index + mem::size_of::()] + .try_into() + .unwrap_or_default(), + ) +} + +fn read_u16(buffer: &[u8], start_index: usize) -> u16 { + u16::from_le_bytes( + buffer[start_index..start_index + mem::size_of::()] + .try_into() + .unwrap_or_default(), + ) +} + +fn compare_4cc(buffer: &[u8], bytes: &[u8]) -> bool { + buffer.iter().take(4).zip(bytes).all(|(a, b)| *a == *b) +} + +fn look_up_format( + data: &[u8], + formatcode: u16, + bits: u16, + bytes_per_sample: u16, + chunk_length: u32, +) -> Res { + match (formatcode, bits, bytes_per_sample) { + (1, 16, 2) => Ok(SampleFormat::S16LE), + (1, 24, 3) => Ok(SampleFormat::S24LE3), + (1, 24, 4) => Ok(SampleFormat::S24LE), + (1, 32, 4) => Ok(SampleFormat::S32LE), + (3, 32, 4) => Ok(SampleFormat::FLOAT32LE), + (3, 64, 8) => Ok(SampleFormat::FLOAT64LE), + (0xFFFE, _, _) => look_up_extended_format(data, bits, bytes_per_sample, chunk_length), + (_, _, _) => Err(ConfigError::new("Unsupported wav format").into()), + } +} + +fn look_up_extended_format( + data: &[u8], + bits: u16, + bytes_per_sample: u16, + chunk_length: u32, +) -> Res { + if chunk_length != 40 { + return Err(ConfigError::new("Invalid extended header").into()); + } + let cb_size = read_u16(data, 16); + let valid_bits_per_sample = read_u16(data, 18); + let channel_mask = read_u32(data, 20); + let subformat = &data[24..40]; + let subformat_guid = Guid::from_slice(subformat.try_into().unwrap()); + trace!( + "Found extended wav fmt chunk: subformatcode: {:?}, cb_size: {}, channel_mask: {}, valid bits per sample: {}", + subformat_guid, cb_size, channel_mask, valid_bits_per_sample + ); + match ( + subformat_guid, + bits, + bytes_per_sample, + valid_bits_per_sample, + ) { + (SUBTYPE_PCM, 16, 2, 16) => Ok(SampleFormat::S16LE), + (SUBTYPE_PCM, 24, 3, 24) => Ok(SampleFormat::S24LE3), + (SUBTYPE_PCM, 24, 4, 24) => Ok(SampleFormat::S24LE), + (SUBTYPE_PCM, 32, 4, 32) => Ok(SampleFormat::S32LE), + (SUBTYPE_FLOAT, 32, 4, 32) => Ok(SampleFormat::FLOAT32LE), + (SUBTYPE_FLOAT, 64, 8, 64) => Ok(SampleFormat::FLOAT64LE), + (_, _, _, _) => Err(ConfigError::new("Unsupported extended wav format").into()), + } +} + +pub fn find_data_in_wav(filename: &str) -> Res { + let f = File::open(filename)?; + find_data_in_wav_stream(f).map_err(|err| { + ConfigError::new(&format!( + "Unable to parse wav file '{}', error: {}", + filename, err + )) + .into() + }) +} + +pub fn find_data_in_wav_stream(mut f: impl Read + Seek) -> Res { + let filesize = f.seek(SeekFrom::End(0))?; + f.seek(SeekFrom::Start(0))?; + let mut file = BufReader::new(f); + let mut header = [0; 12]; + file.read_exact(&mut header)?; + + // The file must start with RIFF + let riff_err = !compare_4cc(&header, RIFF); + // Bytes 8 to 12 must be WAVE + let wave_err = !compare_4cc(&header[8..], WAVE); + if riff_err || wave_err { + return Err(ConfigError::new("Invalid wav header").into()); + } + + let mut next_chunk_location = 12; + let mut found_fmt = false; + let mut found_data = false; + let mut buffer = [0; 8]; + + // Dummy values until we have found the real ones + let mut sample_format = SampleFormat::S16LE; + let mut sample_rate = 0; + let mut channels = 0; + let mut data_offset = 0; + let mut data_length = 0; + + // Analyze each chunk to find format and data + while (!found_fmt || !found_data) && next_chunk_location < filesize { + file.seek(SeekFrom::Start(next_chunk_location))?; + file.read_exact(&mut buffer)?; + let chunk_length = read_u32(&buffer, 4); + trace!("Analyzing wav chunk of length: {}", chunk_length); + let is_data = compare_4cc(&buffer, DATA); + let is_fmt = compare_4cc(&buffer, FMT); + if is_fmt && (chunk_length == 16 || chunk_length == 18 || chunk_length == 40) { + found_fmt = true; + let mut data = vec![0; chunk_length as usize]; + file.read_exact(&mut data).unwrap(); + let formatcode: u16 = read_u16(&data, 0); + channels = read_u16(&data, 2); + sample_rate = read_u32(&data, 4); + let bytes_per_frame = read_u16(&data, 12); + let bits = read_u16(&data, 14); + let bytes_per_sample = bytes_per_frame / channels; + sample_format = + look_up_format(&data, formatcode, bits, bytes_per_sample, chunk_length)?; + trace!( + "Found wav fmt chunk: formatcode: {}, channels: {}, samplerate: {}, bits: {}, bytes_per_frame: {}", + formatcode, channels, sample_rate, bits, bytes_per_frame + ); + } else if is_data { + found_data = true; + data_offset = next_chunk_location + 8; + data_length = chunk_length; + trace!( + "Found wav data chunk, start: {}, length: {}", + data_offset, + data_length + ) + } + next_chunk_location += 8 + chunk_length as u64; + } + if found_data && found_fmt { + trace!("Wav file with parameters: format: {:?}, samplerate: {}, channels: {}, data_length: {}, data_offset: {}", sample_format, sample_rate, channels, data_length, data_offset); + return Ok(WavParams { + sample_format, + sample_rate: sample_rate as usize, + channels: channels as usize, + data_length: data_length as usize, + data_offset: data_offset as usize, + }); + } + Err(ConfigError::new("Unable to parse as wav").into()) +} + +// Write a wav header. +// We don't know the final length so we set the file size and data length to u32::MAX. +pub fn write_wav_header( + dest: &mut impl Write, + channels: usize, + sample_format: SampleFormat, + samplerate: usize, +) -> std::io::Result<()> { + // Header + dest.write_all(RIFF)?; + // file size, 4 bytes, unknown so set to max + dest.write_all(&u32::MAX.to_le_bytes())?; + dest.write_all(WAVE)?; + + let (formatcode, bits_per_sample, bytes_per_sample) = match sample_format { + SampleFormat::S16LE => (1, 16, 2), + SampleFormat::S24LE3 => (1, 24, 3), + SampleFormat::S24LE => (1, 24, 4), + SampleFormat::S32LE => (1, 32, 4), + SampleFormat::FLOAT32LE => (3, 32, 4), + SampleFormat::FLOAT64LE => (3, 64, 8), + }; + + // format block + dest.write_all(FMT)?; + // size of fmt block, 4 bytes + dest.write_all(&16_u32.to_le_bytes())?; + // format code, 2 bytes + dest.write_all(&(formatcode as u16).to_le_bytes())?; + // number of channels, 2 bytes + dest.write_all(&(channels as u16).to_le_bytes())?; + // samplerate, 4 bytes + dest.write_all(&(samplerate as u32).to_le_bytes())?; + // bytes per second, 4 bytes + dest.write_all(&((channels * samplerate * bytes_per_sample) as u32).to_le_bytes())?; + // block alignment, 2 bytes + dest.write_all(&((channels * bytes_per_sample) as u16).to_le_bytes())?; + // bits per sample, 2 bytes + dest.write_all(&(bits_per_sample as u16).to_le_bytes())?; + + // data block + dest.write_all(DATA)?; + // data length, 4 bytes, unknown so set to max + dest.write_all(&u32::MAX.to_le_bytes())?; + + // audio data starts from here + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::find_data_in_wav; + use super::find_data_in_wav_stream; + use super::write_wav_header; + use crate::config::SampleFormat; + use std::io::Cursor; + + #[test] + pub fn test_analyze_wav() { + let info = find_data_in_wav("testdata/int32.wav").unwrap(); + println!("{info:?}"); + assert_eq!(info.sample_format, SampleFormat::S32LE); + assert_eq!(info.data_offset, 44); + assert_eq!(info.data_length, 20); + assert_eq!(info.channels, 1); + assert_eq!(info.sample_rate, 44100); + } + + #[test] + pub fn test_analyze_wavex() { + let info = find_data_in_wav("testdata/f32_ex.wav").unwrap(); + println!("{info:?}"); + assert_eq!(info.sample_format, SampleFormat::FLOAT32LE); + assert_eq!(info.data_offset, 104); + assert_eq!(info.data_length, 20); + assert_eq!(info.channels, 1); + assert_eq!(info.sample_rate, 44100); + } + + #[test] + fn write_and_read_wav() { + let bytes = vec![0_u8; 1000]; + let mut buffer = Cursor::new(bytes); + write_wav_header(&mut buffer, 2, SampleFormat::S32LE, 44100).unwrap(); + let info = find_data_in_wav_stream(buffer).unwrap(); + assert_eq!(info.sample_format, SampleFormat::S32LE); + assert_eq!(info.data_offset, 44); + assert_eq!(info.channels, 2); + assert_eq!(info.sample_rate, 44100); + assert_eq!(info.data_length, u32::MAX as usize); + } +} diff --git a/stepbystep.md b/stepbystep.md index fa41b4de..ec16dad1 100644 --- a/stepbystep.md +++ b/stepbystep.md @@ -5,9 +5,13 @@ This will be a simple 2-way crossover with 2 channels in and 4 out. ## Devices -First we need to define the input and output devices. Here let's assume +First we need to define the input and output devices. Here let's assume we already figured out all the Loopbacks etc and already know the devices to use. -We need to decide a sample rate, let's go with 44100. For chunksize 1024 is a good values to start at with not too much delay, and low risk of buffer underruns. The best sample format this playback device supports is 32 bit integer so let's put that. The Loopback capture device supports all sample formats so let's just pick a good one. +We need to decide a sample rate, let's go with 44100. +For chunksize, 1024 is a good starting point. +This gives a fairly short delay, and low risk of buffer underruns. +The best sample format this playback device supports is 32 bit integer so let's put that. +The Loopback capture device supports all sample formats so let's just pick a good one. ```yaml --- title: "Example crossover" @@ -29,7 +33,10 @@ devices: ``` ## Mixer -We have 2 channels coming in but we need to have 4 going out. For this to work we have to add two more channels. Thus a mixer is needed. Lets name it "to4chan" and use output channels 0 & 1 for the woofers, and 2 & 3 for tweeters. Then we want to leave channels 0 & 1 as they are, and copy 0 -> 2 and 1 -> 3. +We have 2 channels coming in but we need to have 4 going out. +For this to work we have to add two more channels. Thus a mixer is needed. +Lets name it "to4chan" and use output channels 0 & 1 for the woofers, and 2 & 3 for tweeters. +Then we want to leave channels 0 & 1 as they are, and copy 0 -> 2 and 1 -> 3. Lets start with channels 0 and 1, that should just pass through. For each output channel we define a list of sources. Here it's a list of one. So for each output channel X we add a section under "mapping": @@ -42,7 +49,8 @@ So for each output channel X we add a section under "mapping": inverted: false ``` -To copy we just need to say that output channel 0 should have channel 0 as source, with gain 0. This part becomes: +To copy we just need to say that output channel 0 should have channel 0 as source, with gain 0. +This part becomes: ```yaml mixers: to4chan: @@ -63,7 +71,7 @@ mixers: inverted: false ``` -Then we add the two new channels, by copying from channels 0 and 1: +Then we add the two new channels, by copying from channels 0 and 1: ```yaml mixers: to4chan: @@ -95,7 +103,8 @@ mixers: ``` ## Pipeline -We now have all we need to build a working pipeline. It won't do any filtering yet so this is only for a quick test. +We now have all we need to build a working pipeline. +It won't do any filtering yet so this is only for a quick test. We only need a single step in the pipeline, for the "to4chan" mixer. ```yaml pipeline: @@ -106,8 +115,11 @@ Put everything together, and run it. It should work and give unfiltered output o ## Filters -The poor tweeters don't like the full range signal so we need lowpass filters for them. Left and right should be filtered with the same settings, so a single definition is enough. -Let's use a simple 2nd order Butterworth at 2 kHz and name it "highpass2k". Create a "filters" section like this: +The poor tweeters don't like the full range signal so we need lowpass filters for them. +Left and right should be filtered with the same settings, so a single definition is enough. +Let's use a simple 2nd order Butterworth at 2 kHz and name it "highpass2k". + +Create a "filters" section like this: ```yaml filters: highpass2k: @@ -117,23 +129,23 @@ filters: freq: 2000 q: 0.707 ``` -Next we need to plug this into the pipeline after the mixer. Thus we need to extend the pipeline with two "Filter" steps, one for each tweeter channel. +Next we need to plug this into the pipeline after the mixer. +Thus we need to extend the pipeline with a "Filter" step, +that acts on the two tweeter channels. ```yaml pipeline: - type: Mixer name: to4chan - type: Filter <---- here! - channel: 2 - names: - - highpass2k - - type: Filter <---- here! - channel: 3 + channels: [2, 3] names: - highpass2k ``` -When we try this we get properly filtered output for the tweeters on channels 2 and 3. Let's fix the woofers as well. Then we need a lowpass filter, so we add a definition to the filters section. +When we try this we get properly filtered output for the tweeters on channels 2 and 3. +Let's fix the woofers as well. +Then we need a lowpass filter, so we add a definition to the filters section. ```yaml filters: highpass2k: @@ -149,30 +161,26 @@ filters: freq: 2000 q: 0.707 ``` -Then we plug it into the pipeline with two new Filter blocks: + +Then we plug the woofer filter into the pipeline with a new Filter block: ```yaml pipeline: - type: Mixer name: to4chan - type: Filter - channel: 2 + channels: [2, 3] names: - highpass2k - - type: Filter - channel: 3 - names: - - highpass2k - - type: Filter <---- new! - channel: 0 - names: - - lowpass2k - type: Filter <---- new! - channel: 1 + channels: [0, 1] names: - lowpass2k ``` -We try this and it works, but the sound isn't very nice. First off, the tweeters have higher sensitivity than the woofers, so they need to be attenuated. This can be done in the mixer, or via a separate "Gain" filter. Let's do it in the mixer, and attenuate by 5 dB. +We try this and it works, but the sound isn't very nice. +First off, the tweeters have higher sensitivity than the woofers, so they need to be attenuated. +This can be done in the mixer, or via a separate "Gain" filter. +Let's do it in the mixer, and attenuate by 5 dB. Just modify the "gain" parameters in the mixer config: ```yaml @@ -204,7 +212,9 @@ mixers: gain: -5.0 <---- here! inverted: false ``` -This is far better but we need baffle step compensation as well. We can do this with a "Highshelf" filter. The measurements say we need to attenuate by 4 dB from 500 Hz and up. +This is far better but we need baffle step compensation as well. +We can do this with a "Highshelf" filter. +The measurements say we need to attenuate by 4 dB from 500 Hz and up. Add this filter definition: ```yaml @@ -216,37 +226,27 @@ Add this filter definition: slope: 6.0 gain: -4.0 ``` -The baffle step correction should be applied to both woofers and tweeters, so let's add this in two new Filter steps (one per channel) before the Mixer: +The baffle step correction should be applied to both woofers and tweeters, +so let's add this in a new Filter step before the Mixer: ```yaml pipeline: - - type: Filter \ - channel: 0 | - names: | - - bafflestep | <---- new - - type: Filter | - channel: 1 | + - type: Filter \ + channels: [0, 1] | <---- new names: | - - bafflestep / + - bafflestep / - type: Mixer name: to4chan - type: Filter - channel: 2 - names: - - highpass2k - - type: Filter - channel: 3 + channels: [2, 3] names: - highpass2k - type: Filter - channel: 0 - names: - - lowpass2k - - type: Filter - channel: 1 + channels: [0, 1] names: - lowpass2k ``` -The last thing we need to do is to adjust the delay between tweeter and woofer. Measurements tell us we need to delay the tweeter by 0.5 ms. +The last thing we need to do is to adjust the delay between tweeter and woofer. +Measurements tell us we need to delay the tweeter by 0.5 ms. Add this filter definition: ```yaml @@ -261,31 +261,18 @@ Now we add this to the tweeter channels: ```yaml pipeline: - type: Filter - channel: 0 - names: - - bafflestep - - type: Filter - channel: 1 + channels: [0, 1] names: - bafflestep - type: Mixer name: to4chan - type: Filter - channel: 2 - names: - - highpass2k - - tweeterdelay <---- here! - - type: Filter - channel: 3 + channels: [2, 3] names: - highpass2k - tweeterdelay <---- here! - type: Filter - channel: 0 - names: - - lowpass2k - - type: Filter - channel: 1 + channels: [0, 1] names: - lowpass2k ``` @@ -294,7 +281,8 @@ And we are done! ## Result Now we have all the parts of the configuration. -As a final touch, let's add descriptions to all pipeline steps while we have things fresh in memory. +As a final touch, let's add descriptions to all pipeline steps +while we have things fresh in memory. ```yaml --- @@ -314,7 +302,7 @@ devices: channels: 4 device: "hw:Generic_1" format: S32LE - + mixers: to4chan: description: "Expand 2 channels to 4" @@ -375,37 +363,21 @@ filters: pipeline: - type: Filter - description: "Pre-mixer filters left" - channel: 0 - names: - - bafflestep - - type: Filter - description: "Pre-mixer filters right" - channel: 1 + description: "Pre-mixer filters" + channela: [0, 1] names: - bafflestep - type: Mixer name: to4chan - type: Filter - description: "Highpass for left tweeter" - channel: 2 + description: "Highpass for tweeters" + channels: [2, 3] names: - highpass2k - tweeterdelay - type: Filter - description: "Highpass for right tweeter" - channel: 3 - names: - - highpass2k - - tweeterdelay - - type: Filter - description: "Lowpass for left woofer" - channel: 0 - names: - - lowpass2k - - type: Filter - description: "Lowpass for right woofer" - channel: 1 + description: "Lowpass for woofers" + channels: [0, 1] names: - lowpass2k ``` diff --git a/testdata/f32_ex.wav b/testdata/f32_ex.wav new file mode 100644 index 00000000..9bc1eefa Binary files /dev/null and b/testdata/f32_ex.wav differ diff --git a/testscripts/analyze_wav.py b/testscripts/analyze_wav.py deleted file mode 100644 index e82ff4f0..00000000 --- a/testscripts/analyze_wav.py +++ /dev/null @@ -1,85 +0,0 @@ -import os -import struct -import logging - -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(): - print("{} : {}".format(name, val)) diff --git a/testscripts/config_load_test/conf1.yml b/testscripts/config_load_test/conf1.yml index bb4f4e65..ad5c5838 100644 --- a/testscripts/config_load_test/conf1.yml +++ b/testscripts/config_load_test/conf1.yml @@ -25,8 +25,8 @@ pipeline: - type: Filter names: - testfilter - channel: 0 + channels: [0] - type: Filter names: - testfilter - channel: 1 \ No newline at end of file + channels: [1] \ No newline at end of file diff --git a/testscripts/config_load_test/conf2.yml b/testscripts/config_load_test/conf2.yml index 0cdc73c0..3b69c5c4 100644 --- a/testscripts/config_load_test/conf2.yml +++ b/testscripts/config_load_test/conf2.yml @@ -25,8 +25,8 @@ pipeline: - type: Filter names: - testfilter - channel: 0 + channels: [0] - type: Filter names: - testfilter - channel: 1 \ No newline at end of file + channels: [1] \ No newline at end of file diff --git a/testscripts/config_load_test/conf3.yml b/testscripts/config_load_test/conf3.yml index 4aa45c76..81da79c4 100644 --- a/testscripts/config_load_test/conf3.yml +++ b/testscripts/config_load_test/conf3.yml @@ -25,8 +25,8 @@ pipeline: - type: Filter names: - testfilter - channel: 0 + channels: [0] - type: Filter names: - testfilter - channel: 1 \ No newline at end of file + channels: [1] \ No newline at end of file diff --git a/testscripts/config_load_test/conf4.yml b/testscripts/config_load_test/conf4.yml index 00f9dc23..9824aa43 100644 --- a/testscripts/config_load_test/conf4.yml +++ b/testscripts/config_load_test/conf4.yml @@ -25,8 +25,8 @@ pipeline: - type: Filter names: - testfilter - channel: 0 + channels: [0] - type: Filter names: - testfilter - channel: 1 \ No newline at end of file + channels: [1] \ No newline at end of file diff --git a/testscripts/config_load_test/test_set_config.py b/testscripts/config_load_test/test_set_config.py index 8a83297f..f5b359ac 100644 --- a/testscripts/config_load_test/test_set_config.py +++ b/testscripts/config_load_test/test_set_config.py @@ -5,6 +5,9 @@ import signal import shutil from subprocess import check_output +from copy import deepcopy +import yaml +import json # ---------- Constants ----------- @@ -81,6 +84,83 @@ def test_set_via_ws(camillaclient, delay, reps): time.sleep(0.5) assert_active(camillaclient, "nbr 4") +def test_only_pipeline_via_ws(camillaclient): + # Change between configs that only differ in the pipeline + print("Changing slowly") + for n in range(4): + print(f"Set config 1") + conf = yaml.safe_load(CONFIGS[0]) + # conf1 unmodified + camillaclient.config.set_active(conf) + time.sleep(1) + active = camillaclient.config.active() + assert active["pipeline"][0]["names"] == ["testfilter"] + + # conf1 with added filter in pipeline + active["pipeline"][0]["names"] = ["testfilter", "testfilter"] + camillaclient.config.set_active(active) + time.sleep(1) + active = camillaclient.config.active() + assert active["pipeline"][0]["names"] == ["testfilter", "testfilter"] + + # conf1 with empty pipeline + active["pipeline"] = [] + camillaclient.config.set_active(active) + time.sleep(1) + active = camillaclient.config.active() + assert active["pipeline"] == [] + +def test_only_filter_via_ws(camillaclient): + # Change between configs that only differ in the filter defs + print("Changing slowly") + for n in range(4): + print(f"Set config 1") + conf = yaml.safe_load(CONFIGS[0]) + # conf1 unmodified + camillaclient.config.set_active(conf) + time.sleep(1) + active = camillaclient.config.active() + assert active["filters"]["testfilter"]["parameters"]["freq"] == 5000.0 + + # conf1 with added filter in pipeline + active["filters"]["testfilter"]["parameters"]["freq"] = 6000.0 + camillaclient.config.set_active(active) + time.sleep(1) + active = camillaclient.config.active() + assert active["filters"]["testfilter"]["parameters"]["freq"] == 6000.0 + + # conf1 with empty pipeline + active["filters"]["testfilter"]["parameters"]["freq"] = 7000.0 + camillaclient.config.set_active(active) + time.sleep(1) + active = camillaclient.config.active() + assert active["filters"]["testfilter"]["parameters"]["freq"] == 7000.0 + +def test_only_pipeline_json_via_ws(camillaclient): + # Change between configs that only differ in the pipeline, sent as json + print("Changing slowly") + for n in range(4): + print(f"Set config 1") + conf = yaml.safe_load(CONFIGS[0]) + # conf1 unmodified + camillaclient.config.set_active_json(json.dumps(conf)) + time.sleep(1) + active = camillaclient.config.active() + assert active["pipeline"][0]["names"] == ["testfilter"] + + # conf1 with added filter in pipeline + active["pipeline"][0]["names"] = ["testfilter", "testfilter"] + camillaclient.config.set_active_json(json.dumps(active)) + time.sleep(1) + active = camillaclient.config.active() + assert active["pipeline"][0]["names"] == ["testfilter", "testfilter"] + + # conf1 with empty pipeline + active["pipeline"] = [] + camillaclient.config.set_active_json(json.dumps(active)) + time.sleep(1) + active = camillaclient.config.active() + assert active["pipeline"] == [] # ---------- Test changing config by changing config path and reloading ----------- diff --git a/testscripts/log_load_and_level.py b/testscripts/log_load_and_level.py new file mode 100644 index 00000000..93a30c4f --- /dev/null +++ b/testscripts/log_load_and_level.py @@ -0,0 +1,69 @@ +import csv +import time +from datetime import datetime +from camilladsp import CamillaClient +from matplotlib import pyplot + +cdsp = CamillaClient("localhost", 1234) +cdsp.connect() + +loop_delay = 0.5 +plot_interval = 10 + +times = [] +loads = [] +levels = [] + +start = time.time() +start_time = datetime.now().strftime("%y.%m.%d_%H.%M.%S") + +pyplot.ion() +fig = pyplot.figure() +ax1 = fig.add_subplot(311) +plot1, = ax1.plot([], []) +ax2 = fig.add_subplot(312) +ax3 = fig.add_subplot(313) +plot3, = ax3.plot([], []) + + +running = True +plot_counter = 0 +try: + while running: + now = time.time() + prc_load = cdsp.status.processing_load() + buffer_level = cdsp.status.buffer_level() + times.append(now - start) + loads.append(prc_load) + levels.append(buffer_level) + plot_counter += 1 + if plot_counter > plot_interval: + plot_counter = 0 + #ax.plot(times, loads) + plot1.set_data(times, loads) + plot3.set_data(times, levels) + ax1.relim() + ax1.autoscale_view(True, True, True) + ax3.relim() + ax3.autoscale_view(True, True, True) + ax2.cla() + ax2.hist(loads) + + # drawing updated values + pyplot.draw() + fig.canvas.draw() + fig.canvas.flush_events() + print(now) + #pyplot.show() + time.sleep(loop_delay) +except KeyboardInterrupt: + print("stopping") + pass + +csv_name = f"loadlog_{start_time}.csv" +with open(csv_name, 'w', newline='') as f: + writer = csv.writer(f) + writer.writerow(["time", "load", "bufferlevel"]) + writer.writerows(zip(times, loads, levels)) + +print(f"saved {len(times)} records to '{csv_name}'") \ No newline at end of file diff --git a/testscripts/makesineraw.py b/testscripts/makesineraw.py index 0d9bd932..04c15281 100644 --- a/testscripts/makesineraw.py +++ b/testscripts/makesineraw.py @@ -1,17 +1,25 @@ -# Make a simple sine for testing purposes +# Make simple sines for testing purposes +# Example: 20 seconds of 1kHz + 2 kHz at 44.1 kHz +# > python testscripts/makesineraw.py 44100 20 1000 2000 import numpy as np import sys -f = float(sys.argv[2]) +f = float(sys.argv[3]) fs = float(sys.argv[1]) -length = int(sys.argv[3]) +length = int(sys.argv[2]) t = np.linspace(0, 20, num=int(20*fs), endpoint=False) wave = 0.5*np.sin(f*2*np.pi*t) +f_label = "{:.0f}".format(f) +for f2 in sys.argv[4:]: + f2f = float(f2) + wave += 0.5*np.sin(f2f*2*np.pi*t) + f_label = "{}-{:.0f}".format(f_label, f2f) + wave= np.reshape(wave,(-1,1)) wave = np.concatenate((wave, wave), axis=1) wave64 = wave.astype('float64') -name = "sine_{:.0f}_{:.0f}_{}s_f64_2ch.raw".format(f, fs, length) +name = "sine_{}_{:.0f}_{}s_f64_2ch.raw".format(f_label, fs, length) #print(wave64) wave64.tofile(name) diff --git a/testscripts/play_wav.py b/testscripts/play_wav.py deleted file mode 100644 index 04212243..00000000 --- a/testscripts/play_wav.py +++ /dev/null @@ -1,52 +0,0 @@ -#play wav -import yaml -from websocket import create_connection -import sys -import os -import json -from analyze_wav import read_wav_header - -try: - port = int(sys.argv[1]) - template_file = os.path.abspath(sys.argv[2]) - wav_file = os.path.abspath(sys.argv[3]) -except: - print("Usage: start CamillaDSP with the websocket server enabled, and wait mode:") - print("> camilladsp -p4321 -w") - print("Then play a wav file:") - print("> python play_wav.py 4321 path/to/some/template/config.yml path/to/file.wav") - sys.exit() -# read the config to a Python dict -with open(template_file) as f: - cfg=yaml.safe_load(f) - -wav_info = read_wav_header(wav_file) -if wav_info["SampleFormat"] == "unknown": - print("Unknown wav sample format!") - -# template -capt_device = { - "type": "File", - "filename": wav_file, - "format": wav_info["SampleFormat"], - "channels": wav_info["NumChannels"], - "skip_bytes": wav_info["DataStart"], - "read_bytes": wav_info["DataLength"], -} -# Modify config -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" -else: - cfg["devices"]["enable_resampling"] = False -cfg["devices"]["capture"] = capt_device - -# Serialize to yaml string -modded = yaml.dump(cfg) - -# Send the modded config -ws = create_connection("ws://127.0.0.1:{}".format(port)) -ws.send(json.dumps({"SetConfig": modded)) -ws.recv() \ No newline at end of file diff --git a/testscripts/test_file.yml b/testscripts/test_file.yml index 81898e63..356b7498 100644 --- a/testscripts/test_file.yml +++ b/testscripts/test_file.yml @@ -5,18 +5,17 @@ devices: target_level: 512 adjust_period: 10 playback: - type: Raw + type: RawFile channels: 2 filename: "result_i32.raw" format: S32LE capture: - type: Raw + type: File channels: 1 filename: "spike_i32.raw" format: S32LE extra_samples: 1 - mixers: splitter: channels: @@ -34,7 +33,6 @@ mixers: gain: 0 inverted: false - filters: testlp: type: BiquadCombo @@ -49,18 +47,14 @@ filters: freq: 1000 order: 4 - - pipeline: - type: Mixer name: splitter - type: Filter - channel: 0 + channels: [0] names: - testlp - type: Filter - channel: 1 + channels: [1] names: - testhp - - diff --git a/testscripts/test_file_sine.yml b/testscripts/test_file_sine.yml index 7e759a94..e2f05de7 100644 --- a/testscripts/test_file_sine.yml +++ b/testscripts/test_file_sine.yml @@ -3,20 +3,17 @@ devices: samplerate: 44100 chunksize: 1024 playback: - type: Raw + type: RawFile channels: 2 filename: "result_f64.raw" format: FLOAT64LE capture: - type: Raw + type: File channels: 2 filename: "sine_1000_44100_20s_f64_2ch.raw" format: FLOAT64LE extra_samples: 0 - - - filters: dummy: type: Conv @@ -25,17 +22,8 @@ filters: filename: testscripts/spike_f64_65k.raw format: FLOAT64LE - - pipeline: - type: Filter - channel: 0 + channels: [0, 1] names: - dummy - - type: Filter - channel: 1 - names: - - dummy - - - diff --git a/troubleshooting.md b/troubleshooting.md index c97fcc68..3ea6cbc3 100644 --- a/troubleshooting.md +++ b/troubleshooting.md @@ -13,7 +13,7 @@ The config file is invalid Yaml. The error from the Yaml parser is printed in the next line. ### Config options -- target_level can't be larger than *1234*, +- target_level cannot be larger than *1234*, Target level can't be larger than twice the chunksize. @@ -58,11 +58,11 @@ The coefficient file for a filter was found to be empty. -- Could not open coefficient file '*examplefile.raw*'. Error: *description from OS* +- Could not open coefficient file '*examplefile.raw*'. Reason: *description from OS* The specified file could not be opened. The description from the OS may give more info. -- Can't parse value on line *X* of file '*examplefile.txt*'. Error: *description from parser* +- Can't parse value on line *X* of file '*examplefile.txt*'. Reason: *description from parser* The value on the specified line could not be parsed as a number. Check that the file only contains numbers. diff --git a/websocket.md b/websocket.md index 9fdd9a88..c1140d2c 100644 --- a/websocket.md +++ b/websocket.md @@ -63,7 +63,7 @@ ws.on('message', function message(data) { } }); ``` -*Wrapped the parse with a try/catch as that's good practice to avoid crashes with improperly formatted JSON etc.* +*Wrapping the parse with a try/catch is good practice to avoid crashes with improperly formatted JSON etc.* ## All commands The available commands are listed below. All commands return the result, and for the ones that return a value are this described here. @@ -143,39 +143,72 @@ Combined commands for reading several levels with a single request. These comman - `GetSignalLevelsSinceLast` Get the peak since start. -- `GetSignalPeaksSinceStart` : Get the playback and capture peak level since processing started. The values are returned as a json object with keys `playback` and `capture`. +- `GetSignalPeaksSinceStart` : Get the playback and capture peak level since processing started. + The values are returned as a json object with keys `playback` and `capture`. - `ResetSignalPeaksSinceStart` : Reset the peak values. Note that this resets the peak for all clients. ### Volume control Commands for setting and getting the volume and mute of the default volume control on control `Main`. + - `GetVolume` : Get the current volume setting in dB. * Returns the value as a float. + - `SetVolume` : Set the volume control to the given value in dB. Clamped to the range -150 to +50 dB. -- `AdjustVolume` : Change the volume setting by the given number of dB, positive or negative. The resulting volume is clamped to the range -150 to +50 dB. + +- `AdjustVolume` : Change the volume setting by the given number of dB, positive or negative. + The resulting volume is clamped to the range -150 to +50 dB. + The allowed range can be reduced by providing two more values, for minimum and maximum. + + Example, reduce the volume by 3 dB, with limits of -50 and +10 dB: + ```{"AdjustVolume": [-3.0, -50.0, 10.0]}``` + * Returns the new value as a float. + - `GetMute` : Get the current mute setting. * Returns the muting status as a boolean. + - `SetMute` : Set muting to the given value. + - `ToggleMute` : Toggle muting. * Returns the new muting status as a boolean. + Commands for setting and getting the volume and mute setting of a given fader. The faders are selected using an integer, 0 for `Main` and 1 to 4 for `Aux1` to `Aux4`. All commands take the fader number as the first parameter. + - `GetFaderVolume` : Get the current volume setting in dB. * Returns a struct with the fader as an integer and the volume value as a float. + - `SetFaderVolume` : Set the volume control to the given value in dB. Clamped to the range -150 to +50 dB. + - `SetFaderExternalVolume` : Special command for setting the volume when a Loudness filter is being combined with an external volume control (without a Volume filter). Clamped to the range -150 to +50 dB. -- `AdjustFaderVolume` : Change the volume setting by the given number of dB, positive or negative. The resulting volume is clamped to the range -150 to +50 dB. + +- `AdjustFaderVolume` : Change the volume setting by the given number of dB, positive or negative. + The resulting volume is clamped to the range -150 to +50 dB. + The allowed range can be reduced by providing two more values, for minimum and maximum. + + Example, reduce the volume of fader 0 by 3 dB, with default limits: + ```{"AdjustFaderVolume": [0, -3.0]}``` + + Example, reduce the volume of fader 0 by 3 dB, with limits of -50 and +10 dB: + ```{"AdjustFaderVolume": [0, [-3.0, -50.0, 10.0]]}``` + * Returns a struct with the fader as an integer and the new volume value as a float. + - `GetFaderMute` : Get the current mute setting. * Returns a struct with the fader as an integer and the muting status as a boolean. - `SetFaderMute` : Set muting to the given value. - `ToggleFaderMute` : Toggle muting. * Returns a struct with the fader as an integer and the new muting status as a boolean. +There is also a command for getting the volume and mute settings for all faders with a single query. +- `GetFaders` : Read all faders. + * Returns a list of objects, each containing a `volume` and a `mute` property. + + ### Config management Commands for reading and changing the active configuration.