251 lines
10 KiB
Markdown
251 lines
10 KiB
Markdown
|
---
|
|||
|
author: Sanchayan Maity
|
|||
|
title: Writing a simple PipeWire parametric equalizer module
|
|||
|
tags: linux, pipewire
|
|||
|
---
|
|||
|
|
|||
|
# Motivation
|
|||
|
|
|||
|
When using headphones or in-ear monitors (IEMs), one might want to EQ their headphones or IEMs. Equalization or EQ is the process of adjusting the volume of different frequency bands in an audio signal. Some popular EQ software are EasyEffects on Linux and Equalizer APO on Windows. PipeWire supports EQ via the [filter-chain](https://docs.pipewire.org/page_module_filter_chain.html) module.
|
|||
|
|
|||
|
For an understanding of EQ, following resources might help.
|
|||
|
|
|||
|
- [The Headphone Show - EQ Basics](https://youtu.be/FRm9qTmQHKo?si=BFi0IH_XiCz1AxWa)
|
|||
|
- [The Headphone Show - The Limits of EQ](https://www.youtube.com/watch?v=FD_s2s8Mw9k&t=0s)
|
|||
|
- [Graphs 101 - How to Read Headphone Measurements](https://crinacle.com/2020/04/08/graphs-101-how-to-read-headphone-measurements/)
|
|||
|
|
|||
|
The basic idea is that there are some “standard” frequency response curves that might sound good to different individuals, and knowing the frequency response characteristics of a specific headphone/IEM model, you can apply a set of filters via an equalizer to achieve something close to the “standard” frequency response curve that sounds good to you.
|
|||
|
|
|||
|
Websites like [Squig](http://squig.link) or [autoeq.app](https://www.autoeq.app/) generate a file for parametric equalization for a given target, but this isn't a format that can be directly given to filter chain module. Squig is also useful for evaluating the frequency response curves of various in-ear monitors and headphones when making buying decisions.
|
|||
|
|
|||
|
An example of Parametric EQ generated from either AutoEQ or Squig looks like below.
|
|||
|
|
|||
|
```
|
|||
|
Preamp: -6.8 dB
|
|||
|
Filter 1: ON PK Fc 20 Hz Gain -1.3 dB Q 2.000
|
|||
|
Filter 2: ON PK Fc 31 Hz Gain -7.0 dB Q 0.500
|
|||
|
Filter 3: ON PK Fc 36 Hz Gain 0.7 dB Q 2.000
|
|||
|
Filter 4: ON PK Fc 88 Hz Gain -0.4 dB Q 2.000
|
|||
|
Filter 5: ON PK Fc 430 Hz Gain 1.6 dB Q 0.700
|
|||
|
Filter 6: ON PK Fc 3200 Hz Gain -1.5 dB Q 0.700
|
|||
|
Filter 7: ON PK Fc 7800 Hz Gain -3.9 dB Q 2.000
|
|||
|
Filter 8: ON PK Fc 13000 Hz Gain -6.7 dB Q 2.000
|
|||
|
Filter 9: ON PK Fc 15000 Hz Gain 9.1 dB Q 0.700
|
|||
|
```
|
|||
|
|
|||
|
`Fc` is the frequency, `Gain` is the amount with which the signal gets boosted or attenuated around that frequency. `Q` factor controls the bandwidth around the frequency point. To be more precise, `Q` is the ratio of center frequency to bandwidth. If the center frequency is fixed, the bandwidth is inversely proportional to Q implying that as one raises the Q, the bandwidth is narrowed. Q is by far the most useful tool a parametric EQ offers, allowing one to attenuate or boost a narrow or wide range of frequencies within each EQ band.
|
|||
|
|
|||
|
If one wants to build a better intuition for this, playing around with the filter type and parameters [here](https://arachnoid.com/BiQuadDesigner/index.html), and seeing the effects on the frequency response helps. This linked article also goes into the basics of filters.
|
|||
|
|
|||
|
[***EasyEffects***](https://github.com/wwmm/easyeffects) allows importing such a file via it’s `Import APO` option, however, one might want to use an EQ input like this directly in PipeWire without having to resort to additional software like EasyEffects. However, during the course of testing, trying out multiple EQ is definitely much easier with EasyEffects GUI.
|
|||
|
|
|||
|
Now, this needs to be converted manually into something which [filter-chain](https://docs.pipewire.org/page_module_filter_chain.html) module can accept.
|
|||
|
|
|||
|
To simplify this, a simple PipeWire module is implemented which reads a parametric EQ text file like preceding and loads filter chain module while translating the inputs from the text file to what the filter chain module expects.
|
|||
|
|
|||
|
# Module
|
|||
|
|
|||
|
A module is a client in a shared library `.so` file which shares a PipeWire context with the loading entity. PipeWire context is an object which manages all locally available resources. See [here](https://docs.pipewire.org/group__pw__context.html#details).
|
|||
|
|
|||
|
A module is loaded when it's listed in a PipeWire configuration file. Module's entry point is the `pipewire__module_init` function.
|
|||
|
|
|||
|
# Writing the module
|
|||
|
|
|||
|
A PipeWire module needs to go into `src/modules` directory. The file is named `module-parametric-equalizer.c` and starts with the `pipewire__module_init` function.
|
|||
|
|
|||
|
This module primarily has to two tasks:
|
|||
|
|
|||
|
- Parse the provided equalizer configuration into what `filter-chain` module accepts
|
|||
|
|
|||
|
- Load the `filter-chain` module with these arguments
|
|||
|
|
|||
|
The focus is on these two tasks and ignore rest of the ceremony around writing the module.
|
|||
|
|
|||
|
# Parsing parametric equalizer configuration
|
|||
|
|
|||
|
A Parametric EQ configuration generated via *AutoEq* or *Squig*, might look like below. This configuration is converted to match the module args.
|
|||
|
|
|||
|
```
|
|||
|
Preamp: -2.4 dB
|
|||
|
Filter 1: ON PK Fc 52 Hz Gain 1.0 dB Q 0.600
|
|||
|
Filter 2: ON PK Fc 210 Hz Gain -0.5 dB Q 1.800
|
|||
|
Filter 3: ON PK Fc 390 Hz Gain -0.5 dB Q 1.600
|
|||
|
Filter 4: ON PK Fc 2100 Hz Gain 1.3 dB Q 1.200
|
|||
|
Filter 5: ON PK Fc 3600 Hz Gain -1.7 dB Q 2.000
|
|||
|
Filter 6: ON PK Fc 4900 Hz Gain 3.0 dB Q 2.000
|
|||
|
```
|
|||
|
|
|||
|
For every line read, a node entry like below is generated.
|
|||
|
|
|||
|
```
|
|||
|
{
|
|||
|
type = builtin
|
|||
|
name = eq_band_1
|
|||
|
label = bq_low/highshelf/peaking
|
|||
|
control = { "Freq" = Fc "Q" = Q "Gain" = Gain }
|
|||
|
}
|
|||
|
```
|
|||
|
|
|||
|
PipeWire repository contains a filter chain configuration [here](https://gitlab.freedesktop.org/pipewire/pipewire/-/blob/master/src/daemon/filter-chain/sink-eq6.conf) which shows the structure of `args` it expects.
|
|||
|
|
|||
|
When a pre-amp gain is required, which is usually the case when applying EQ, the first EQ band needs to be modified to apply a `bq_highshelf` filter at frequency `0 Hz`with the provided negative gain. Pre-amp gain is always negative to offset the effect of possible clipping introduced by the amplification resulting from EQ. For the example preceding,
|
|||
|
|
|||
|
```
|
|||
|
{
|
|||
|
type = builtin,
|
|||
|
name = eq_band_1,
|
|||
|
label = bq_highshelf,
|
|||
|
control = { Freq = 0, Gain = -2.4, Q = 1.0 },
|
|||
|
}
|
|||
|
```
|
|||
|
|
|||
|
Similarly, for `Filter 1` this would be
|
|||
|
|
|||
|
```
|
|||
|
{
|
|||
|
type = builtin,
|
|||
|
name = eq_band_2,
|
|||
|
label = bq_peaking,
|
|||
|
control = { Freq = 52, Gain = 1.0, Q = 0.600 },
|
|||
|
}
|
|||
|
```
|
|||
|
|
|||
|
Similarly, for the other filters.
|
|||
|
|
|||
|
PipeWire as of this writing, doesn't have helpers to create module arguments in code. `fprintf` is used for constructing filter module arguments as a string.
|
|||
|
|
|||
|
First open a `memstream` ,
|
|||
|
|
|||
|
```c
|
|||
|
char *args = NULL;
|
|||
|
size_t size;
|
|||
|
|
|||
|
FILE *memstream = open_memstream(&args, &size)
|
|||
|
```
|
|||
|
|
|||
|
Write a helper function which generates a `node` entry for the `nodes` array in the filter chain configuration.
|
|||
|
|
|||
|
```c
|
|||
|
struct eq_node_param {
|
|||
|
char filter_type[4];
|
|||
|
char filter[4];
|
|||
|
uint32_t freq;
|
|||
|
float gain;
|
|||
|
float q_fact;
|
|||
|
};
|
|||
|
|
|||
|
void init_eq_node(FILE *f, const char *node_desc) {
|
|||
|
fprintf(f, "{\n");
|
|||
|
fprintf(f, "node.description = \"%s\"\n", node_desc);
|
|||
|
fprintf(f, "media.name = \"%s\"\n", node_desc);
|
|||
|
fprintf(f, "filter.graph = {\n");
|
|||
|
fprintf(f, "nodes = [\n");
|
|||
|
}
|
|||
|
|
|||
|
void add_eq_node(FILE *f, struct eq_node_param *param, uint32_t eq_band_idx) {
|
|||
|
fprintf(f, "{\n");
|
|||
|
fprintf(f, "type = builtin\n");
|
|||
|
fprintf(f, "name = eq_band_%d\n", eq_band_idx);
|
|||
|
|
|||
|
if (strcmp(param->filter_type, "PK") == 0) {
|
|||
|
fprintf(f, "label = bq_peaking\n");
|
|||
|
} else if (strcmp(param->filter_type, "LSC") == 0) {
|
|||
|
fprintf(f, "label = bq_lowshelf\n");
|
|||
|
} else if (strcmp(param->filter_type, "HSC") == 0) {
|
|||
|
fprintf(f, "label = bq_highshelf\n");
|
|||
|
} else {
|
|||
|
fprintf(f, "label = bq_peaking\n");
|
|||
|
}
|
|||
|
|
|||
|
fprintf(f, "control = { \"Freq\" = %d \"Q\" = %f \"Gain\" = %f }\n", param->freq, param->q_fact, param->gain);
|
|||
|
|
|||
|
fprintf(f, "}\n");
|
|||
|
}
|
|||
|
|
|||
|
void end_eq_node(struct impl *impl, FILE *f, uint32_t number_of_nodes) {
|
|||
|
fprintf(f, "]\n");
|
|||
|
|
|||
|
fprintf(f, "links = [\n");
|
|||
|
for (uint32_t i = 1; i < number_of_nodes; i++) {
|
|||
|
fprintf(f, "{ output = \"eq_band_%d:Out\" input = \"eq_band_%d:In\" }\n", i, i + 1);
|
|||
|
}
|
|||
|
fprintf(f, "]\n");
|
|||
|
|
|||
|
fprintf(f, "}\n");
|
|||
|
fprintf(f, "audio.channels = %d\n", impl->channels);
|
|||
|
fprintf(f, "audio.position = %s\n", impl->position);
|
|||
|
|
|||
|
fprintf(f, "capture.props = {\n");
|
|||
|
fprintf(f, "node.name = \"effect_input.eq%d\"\n", number_of_nodes);
|
|||
|
fprintf(f, "media.class = Audio/Sink\n");
|
|||
|
fprintf(f, "}\n");
|
|||
|
|
|||
|
fprintf(f, "playback.props = {\n");
|
|||
|
fprintf(f, "node.name = \"effect_output.eq%d\"\n", number_of_nodes);
|
|||
|
fprintf(f, "node.passive = true\n");
|
|||
|
fprintf(f, "}\n");
|
|||
|
|
|||
|
fprintf(f, "}\n");
|
|||
|
}
|
|||
|
```
|
|||
|
|
|||
|
The parsing function relies on the preceding helpers and is now straight forward. Read line by line from the file stream using `getline` and use `sscanf` to parse the line itself and call these helpers.
|
|||
|
|
|||
|
```c
|
|||
|
spa_zero(eq_param);
|
|||
|
/* Check for Pre-amp gain */
|
|||
|
nread = getline(&line, &len, f);
|
|||
|
if (nread != -1 && sscanf(line, "%*s %6f %*s", &eq_param.gain) == 1) {
|
|||
|
memcpy(eq_param.filter, "ON", 2);
|
|||
|
memcpy(eq_param.filter_type, "HSC", 3);
|
|||
|
eq_param.freq = 0;
|
|||
|
eq_param.q_fact = 1.0;
|
|||
|
|
|||
|
add_eq_node(memstream, &eq_param, eq_band_idx);
|
|||
|
|
|||
|
eq_band_idx++;
|
|||
|
eq_bands++;
|
|||
|
}
|
|||
|
|
|||
|
/* Read the filter bands */
|
|||
|
while ((nread = getline(&line, &len, f)) != -1) {
|
|||
|
spa_zero(eq_param);
|
|||
|
|
|||
|
if (sscanf(line, "%*s %*d: %3s %3s %*s %5d %*s %*s %6f %*s %*c %6f", eq_param.filter, eq_param.filter_type, &eq_param.freq, &eq_param.gain, &eq_param.q_fact) == 5) {
|
|||
|
if (strcmp(eq_param.filter, "ON") == 0) {
|
|||
|
add_eq_node(memstream, &eq_param, eq_band_idx);
|
|||
|
|
|||
|
eq_band_idx++;
|
|||
|
eq_bands++;
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
}
|
|||
|
|
|||
|
end_eq_node(impl, memstream, eq_bands);
|
|||
|
|
|||
|
fclose(memstream);
|
|||
|
memstream = NULL;
|
|||
|
```
|
|||
|
|
|||
|
Now, `args` has a string representation of the parametric equalizer configuration which can now be passed while loading the filter chain module.
|
|||
|
|
|||
|
# Loading ***`filter-chain`***
|
|||
|
|
|||
|
The filter chain module can now be loaded with `args` from the previous step.
|
|||
|
|
|||
|
```c
|
|||
|
struct pw_impl_module *eq_module;
|
|||
|
eq_module = pw_context_load_module(impl->context,
|
|||
|
"libpipewire-module-filter-chain",
|
|||
|
args, NULL);
|
|||
|
```
|
|||
|
|
|||
|
# Conclusion
|
|||
|
|
|||
|
The merge request for this upstream can be found [here](https://gitlab.freedesktop.org/pipewire/pipewire/-/merge_requests/2006).
|
|||
|
|
|||
|
The module allows one to leverage the built-in equalizer capabilities of PipeWire via it’s filter chain module without having to resort to writing the configuration by hand.
|
|||
|
|
|||
|
There are examples on writing [filter](https://gitlab.freedesktop.org/pipewire/pipewire/-/blob/master/src/modules/module-example-filter.c), [sink](https://gitlab.freedesktop.org/pipewire/pipewire/-/blob/master/src/modules/module-example-sink.c) and [source](https://gitlab.freedesktop.org/pipewire/pipewire/-/blob/master/src/modules/module-example-source.c) modules in the PipeWire repository.
|