use super::types::{ChannelDspChain, DspChainOutput, PluginConfigWrapper};
use math_audio_iir_fir::{Biquad, BiquadFilterType};
use std::fmt::Write as FmtWrite;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum ExportFormat {
#[value(name = "camilladsp")]
CamillaDsp,
#[value(name = "apo")]
EqualizerApo,
#[value(name = "easyeffects")]
EasyEffects,
#[value(name = "wavelet")]
Wavelet,
#[value(name = "pipewire")]
PipeWire,
#[value(name = "roon")]
RoonDsp,
}
impl ExportFormat {
pub fn default_extension(&self) -> &'static str {
match self {
ExportFormat::CamillaDsp => "yaml",
ExportFormat::EqualizerApo => "txt",
ExportFormat::EasyEffects => "json",
ExportFormat::Wavelet => "txt",
ExportFormat::PipeWire => "conf",
ExportFormat::RoonDsp => "json",
}
}
}
pub fn export_dsp_chain(
output: &DspChainOutput,
format: ExportFormat,
path: &Path,
sample_rate: f64,
) -> anyhow::Result<()> {
let content = match format {
ExportFormat::CamillaDsp => export_camilladsp(output, sample_rate)?,
ExportFormat::EqualizerApo => export_equalizer_apo(output)?,
ExportFormat::EasyEffects => export_easyeffects(output)?,
ExportFormat::Wavelet => export_wavelet(output, sample_rate)?,
ExportFormat::PipeWire => export_pipewire(output, sample_rate)?,
ExportFormat::RoonDsp => export_roon(output)?,
};
std::fs::write(path, content)?;
Ok(())
}
struct BiquadExport {
filter_type: String,
freq: f64,
q: f64,
gain_db: f64,
}
fn extract_eq_filters(plugins: &[PluginConfigWrapper]) -> Vec<BiquadExport> {
let mut filters = Vec::new();
for p in plugins {
if p.plugin_type == "eq"
&& let Some(arr) = p.parameters.get("filters").and_then(|v| v.as_array())
{
for f in arr {
filters.push(BiquadExport {
filter_type: f
.get("filter_type")
.and_then(|v| v.as_str())
.unwrap_or("peak")
.to_string(),
freq: f.get("freq").and_then(|v| v.as_f64()).unwrap_or(1000.0),
q: f.get("q").and_then(|v| v.as_f64()).unwrap_or(1.0),
gain_db: f.get("db_gain").and_then(|v| v.as_f64()).unwrap_or(0.0),
});
}
}
}
filters
}
fn extract_gain_db(plugins: &[PluginConfigWrapper]) -> f64 {
plugins
.iter()
.filter(|p| p.plugin_type == "gain")
.filter_map(|p| p.parameters.get("gain_db").and_then(|v| v.as_f64()))
.sum()
}
fn extract_delay_ms(plugins: &[PluginConfigWrapper]) -> Option<f64> {
let total: f64 = plugins
.iter()
.filter(|p| p.plugin_type == "delay")
.filter_map(|p| p.parameters.get("delay_ms").and_then(|v| v.as_f64()))
.sum();
if total.abs() > 0.001 {
Some(total)
} else {
None
}
}
fn extract_convolution_paths(plugins: &[PluginConfigWrapper]) -> Vec<String> {
plugins
.iter()
.filter(|p| p.plugin_type == "convolution")
.filter_map(|p| {
p.parameters
.get("ir_file")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
.collect()
}
fn collect_all_plugins(chain: &ChannelDspChain) -> Vec<&PluginConfigWrapper> {
let mut all = Vec::new();
if let Some(drivers) = &chain.drivers {
for driver in drivers {
all.extend(driver.plugins.iter());
}
}
all.extend(chain.plugins.iter());
all
}
fn channel_short_name(name: &str) -> &str {
match name {
"left" => "L",
"right" => "R",
"center" => "C",
"lfe" | "sub" | "subwoofer" => "LFE",
"surround_left" => "SL",
"surround_right" => "SR",
"back_left" => "BL",
"back_right" => "BR",
other => other,
}
}
fn channel_index(name: &str) -> Option<usize> {
match name {
"left" => Some(0),
"right" => Some(1),
"center" => Some(2),
"lfe" | "sub" | "subwoofer" => Some(3),
"surround_left" => Some(4),
"surround_right" => Some(5),
"back_left" => Some(6),
"back_right" => Some(7),
_ => None,
}
}
fn sorted_channels(output: &DspChainOutput) -> Vec<(&String, &ChannelDspChain)> {
let mut channels: Vec<_> = output.channels.iter().collect();
channels.sort_by(|(a, _), (b, _)| {
let ia = channel_index(a);
let ib = channel_index(b);
match (ia, ib) {
(Some(a_idx), Some(b_idx)) => a_idx.cmp(&b_idx),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => a.cmp(b),
}
});
channels
}
fn camilladsp_filter_type(ft: &str) -> &str {
match ft {
"peak" => "Peaking",
"lowshelf" => "Lowshelf",
"highshelf" => "Highshelf",
"lowpass" => "Lowpass",
"highpass" | "highpassvariableq" => "Highpass",
"notch" => "Notch",
"bandpass" => "Bandpass",
"allpass" => "Allpass",
other => other,
}
}
fn apo_filter_type(ft: &str) -> &str {
match ft {
"peak" => "PK",
"lowshelf" => "LSC",
"highshelf" => "HSC",
"lowpass" => "LP",
"highpass" | "highpassvariableq" => "HP",
"notch" => "NO",
"bandpass" => "BP",
"allpass" => "AP",
other => other,
}
}
fn easyeffects_filter_type(ft: &str) -> &str {
match ft {
"peak" => "Bell",
"lowshelf" => "Lo Shelf",
"highshelf" => "Hi Shelf",
"lowpass" => "Lo-pass",
"highpass" => "Hi-pass",
"notch" => "Notch",
"bandpass" => "Bandpass",
"allpass" => "Allpass",
other => other,
}
}
fn pipewire_filter_label(ft: &str) -> &str {
match ft {
"peak" => "bq_peaking",
"lowshelf" => "bq_lowshelf",
"highshelf" => "bq_highshelf",
"lowpass" => "bq_lowpass",
"highpass" | "highpassvariableq" => "bq_highpass",
"notch" => "bq_notch",
"bandpass" => "bq_bandpass",
"allpass" => "bq_allpass",
other => other,
}
}
fn export_camilladsp(output: &DspChainOutput, sample_rate: f64) -> anyhow::Result<String> {
let mut out = String::new();
writeln!(out, "# CamillaDSP configuration")?;
writeln!(out, "# Generated by roomeq")?;
writeln!(out)?;
let channels = sorted_channels(output);
let num_channels = channels.len();
writeln!(out, "devices:")?;
writeln!(out, " samplerate: {}", sample_rate as u32)?;
writeln!(out, " chunksize: 4096")?;
writeln!(out, " capture:")?;
writeln!(out, " type: File")?;
writeln!(out, " channels: {num_channels}")?;
writeln!(out, " filename: /dev/stdin")?;
writeln!(out, " format: S32LE")?;
writeln!(out, " playback:")?;
writeln!(out, " type: File")?;
writeln!(out, " channels: {num_channels}")?;
writeln!(out, " filename: /dev/stdout")?;
writeln!(out, " format: S32LE")?;
writeln!(out)?;
writeln!(out, "filters:")?;
for (ch_name, chain) in &channels {
let prefix = ch_name.replace(' ', "_");
write_camilladsp_filters_for_plugins(&mut out, &prefix, &chain.plugins, "")?;
if let Some(drivers) = &chain.drivers {
for driver in drivers {
let driver_prefix = format!("{}_{}", prefix, driver.name.replace(' ', "_"));
write_camilladsp_filters_for_plugins(
&mut out,
&driver_prefix,
&driver.plugins,
"",
)?;
}
}
}
writeln!(out)?;
writeln!(out, "pipeline:")?;
for (i, (ch_name, chain)) in channels.iter().enumerate() {
let prefix = ch_name.replace(' ', "_");
if let Some(drivers) = &chain.drivers {
writeln!(out, " # Channel: {} (drivers)", ch_name)?;
for driver in drivers {
let driver_prefix = format!("{}_{}", prefix, driver.name.replace(' ', "_"));
let filter_names =
collect_camilladsp_filter_names(&driver_prefix, &driver.plugins, "");
if !filter_names.is_empty() {
writeln!(out, " - type: Filter")?;
writeln!(out, " channels: [{}]", i)?;
writeln!(out, " names:")?;
for name in &filter_names {
writeln!(out, " - {name}")?;
}
}
}
}
let filter_names = collect_camilladsp_filter_names(&prefix, &chain.plugins, "");
if !filter_names.is_empty() {
writeln!(out, " - type: Filter")?;
writeln!(out, " channels: [{}]", i)?;
writeln!(out, " names:")?;
for name in &filter_names {
writeln!(out, " - {name}")?;
}
}
}
Ok(out)
}
fn write_camilladsp_filters_for_plugins(
out: &mut String,
prefix: &str,
plugins: &[PluginConfigWrapper],
_suffix: &str,
) -> anyhow::Result<()> {
let mut eq_idx = 0;
let mut gain_idx = 0;
let mut delay_idx = 0;
let mut conv_idx = 0;
for plugin in plugins {
match plugin.plugin_type.as_str() {
"gain" => {
let gain_db = plugin
.parameters
.get("gain_db")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let inverted = plugin
.parameters
.get("invert")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let name = if gain_idx == 0 {
format!("{prefix}_gain")
} else {
format!("{prefix}_gain_{gain_idx}")
};
writeln!(out, " {name}:")?;
writeln!(out, " type: Gain")?;
writeln!(out, " parameters:")?;
writeln!(out, " gain: {gain_db:.2}")?;
if inverted {
writeln!(out, " inverted: true")?;
}
gain_idx += 1;
}
"delay" => {
let delay_ms = plugin
.parameters
.get("delay_ms")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let name = if delay_idx == 0 {
format!("{prefix}_delay")
} else {
format!("{prefix}_delay_{delay_idx}")
};
writeln!(out, " {name}:")?;
writeln!(out, " type: Delay")?;
writeln!(out, " parameters:")?;
writeln!(out, " delay: {delay_ms:.3}")?;
writeln!(out, " unit: ms")?;
delay_idx += 1;
}
"eq" => {
if let Some(filters) = plugin.parameters.get("filters").and_then(|v| v.as_array()) {
for f in filters {
let ft = f
.get("filter_type")
.and_then(|v| v.as_str())
.unwrap_or("peak");
let freq = f.get("freq").and_then(|v| v.as_f64()).unwrap_or(1000.0);
let q = f.get("q").and_then(|v| v.as_f64()).unwrap_or(1.0);
let gain = f.get("db_gain").and_then(|v| v.as_f64()).unwrap_or(0.0);
writeln!(out, " {prefix}_peq_{eq_idx}:")?;
writeln!(out, " type: Biquad")?;
writeln!(out, " parameters:")?;
writeln!(out, " type: {}", camilladsp_filter_type(ft))?;
writeln!(out, " freq: {freq:.1}")?;
writeln!(out, " q: {q:.4}")?;
match ft {
"lowpass" | "highpass" | "notch" | "bandpass" | "allpass" => {}
_ => {
writeln!(out, " gain: {gain:.2}")?;
}
}
eq_idx += 1;
}
}
}
"convolution" => {
if let Some(ir_file) = plugin.parameters.get("ir_file").and_then(|v| v.as_str()) {
let name = if conv_idx == 0 {
format!("{prefix}_conv")
} else {
format!("{prefix}_conv_{conv_idx}")
};
writeln!(out, " {name}:")?;
writeln!(out, " type: Conv")?;
writeln!(out, " parameters:")?;
writeln!(out, " type: Wav")?;
writeln!(out, " filename: {ir_file}")?;
conv_idx += 1;
}
}
_ => {} }
}
Ok(())
}
fn collect_camilladsp_filter_names(
prefix: &str,
plugins: &[PluginConfigWrapper],
_suffix: &str,
) -> Vec<String> {
let mut names = Vec::new();
let mut eq_idx = 0;
let mut gain_idx = 0;
let mut delay_idx = 0;
let mut conv_idx = 0;
for plugin in plugins {
match plugin.plugin_type.as_str() {
"gain" => {
names.push(if gain_idx == 0 {
format!("{prefix}_gain")
} else {
format!("{prefix}_gain_{gain_idx}")
});
gain_idx += 1;
}
"delay" => {
names.push(if delay_idx == 0 {
format!("{prefix}_delay")
} else {
format!("{prefix}_delay_{delay_idx}")
});
delay_idx += 1;
}
"eq" => {
if let Some(filters) = plugin.parameters.get("filters").and_then(|v| v.as_array()) {
for _ in filters {
names.push(format!("{prefix}_peq_{eq_idx}"));
eq_idx += 1;
}
}
}
"convolution" => {
names.push(if conv_idx == 0 {
format!("{prefix}_conv")
} else {
format!("{prefix}_conv_{conv_idx}")
});
conv_idx += 1;
}
_ => {}
}
}
names
}
fn export_equalizer_apo(output: &DspChainOutput) -> anyhow::Result<String> {
let mut out = String::new();
writeln!(out, "# Equalizer APO configuration")?;
writeln!(out, "# Generated by roomeq")?;
writeln!(out)?;
let channels = sorted_channels(output);
for (ch_name, chain) in &channels {
let short = channel_short_name(ch_name);
writeln!(out, "Channel: {short}")?;
let plugins: Vec<PluginConfigWrapper> =
collect_all_plugins(chain).into_iter().cloned().collect();
let gain = extract_gain_db(&plugins);
if gain.abs() > 0.01 {
writeln!(out, "Preamp: {gain:+.1} dB")?;
}
if let Some(delay) = extract_delay_ms(&plugins) {
writeln!(out, "Delay: {delay:.3} ms")?;
}
let filters = extract_eq_filters(&plugins);
for (i, f) in filters.iter().enumerate() {
let ft = apo_filter_type(&f.filter_type);
match f.filter_type.as_str() {
"lowpass" | "highpass" | "highpassvariableq" => {
writeln!(out, "Filter {:2}: ON {ft} Fc {:.0} Hz", i + 1, f.freq)?;
}
_ => {
writeln!(
out,
"Filter {:2}: ON {ft} Fc {:.0} Hz Gain {:+.2} dB Q {:.4}",
i + 1,
f.freq,
f.gain_db,
f.q
)?;
}
}
}
let conv_paths = extract_convolution_paths(&plugins);
for path in &conv_paths {
writeln!(out, "Convolution: {path}")?;
}
writeln!(out)?;
}
Ok(out)
}
fn export_easyeffects(output: &DspChainOutput) -> anyhow::Result<String> {
let channels = sorted_channels(output);
let mut all_filters = Vec::new();
let mut min_gain = 0.0f64;
let mut has_unsupported = false;
for (ch_name, chain) in &channels {
let plugins: Vec<PluginConfigWrapper> =
collect_all_plugins(chain).into_iter().cloned().collect();
let filters = extract_eq_filters(&plugins);
let gain = extract_gain_db(&plugins);
if !extract_convolution_paths(&plugins).is_empty() {
has_unsupported = true;
log::warn!(
"EasyEffects: skipping convolution for channel '{}' (not supported)",
ch_name
);
}
if gain < min_gain {
min_gain = gain;
}
all_filters.extend(filters);
}
if has_unsupported {
log::warn!("EasyEffects does not support FIR convolution filters; they were skipped");
}
let mut bands = serde_json::Map::new();
for (i, f) in all_filters.iter().enumerate().take(30) {
let band_key = format!("band{i}");
let mut band = serde_json::Map::new();
band.insert("frequency".to_string(), serde_json::json!(f.freq));
band.insert("gain".to_string(), serde_json::json!(f.gain_db));
band.insert("q".to_string(), serde_json::json!(f.q));
band.insert(
"type".to_string(),
serde_json::json!(easyeffects_filter_type(&f.filter_type)),
);
band.insert("mode".to_string(), serde_json::json!("RLC (BT)"));
band.insert("slope".to_string(), serde_json::json!("x1"));
band.insert("solo".to_string(), serde_json::json!(false));
band.insert("mute".to_string(), serde_json::json!(false));
bands.insert(band_key, serde_json::Value::Object(band));
}
let preset = serde_json::json!({
"output": {
"equalizer#0": {
"input-gain": min_gain,
"output-gain": 0.0,
"num-bands": all_filters.len().min(30),
"split-channels": false,
"left": bands,
"right": bands,
}
}
});
Ok(serde_json::to_string_pretty(&preset)?)
}
const WAVELET_BANDS: [f64; 9] = [
32.0, 64.0, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0,
];
fn export_wavelet(output: &DspChainOutput, sample_rate: f64) -> anyhow::Result<String> {
let channels = sorted_channels(output);
let mut has_unsupported = false;
let mut band_gains = [0.0f64; 9];
let mut n_channels = 0;
for (ch_name, chain) in &channels {
let plugins: Vec<PluginConfigWrapper> =
collect_all_plugins(chain).into_iter().cloned().collect();
let filters = extract_eq_filters(&plugins);
let gain = extract_gain_db(&plugins);
if !extract_convolution_paths(&plugins).is_empty() {
has_unsupported = true;
log::warn!(
"Wavelet: skipping convolution for channel '{}' (not supported)",
ch_name
);
}
let biquads: Vec<Biquad> = filters
.iter()
.map(|f| {
let ft = parse_biquad_filter_type(&f.filter_type);
Biquad::new(ft, f.freq, sample_rate, f.q, f.gain_db)
})
.collect();
for (i, &freq) in WAVELET_BANDS.iter().enumerate() {
let mut db = gain;
for bq in &biquads {
db += bq.log_result(freq);
}
band_gains[i] += db;
}
n_channels += 1;
}
if has_unsupported {
log::warn!("Wavelet does not support FIR convolution filters; they were skipped");
}
if n_channels > 1 {
for g in &mut band_gains {
*g /= n_channels as f64;
}
}
let mut out = String::new();
writeln!(out, "# Wavelet GraphicEQ")?;
writeln!(out, "# Generated by roomeq")?;
write!(out, "GraphicEQ:")?;
for (i, (&freq, &gain)) in WAVELET_BANDS.iter().zip(band_gains.iter()).enumerate() {
if i > 0 {
write!(out, ";")?;
}
write!(out, " {:.0} {:.1}", freq, gain)?;
}
writeln!(out)?;
Ok(out)
}
fn parse_biquad_filter_type(ft: &str) -> BiquadFilterType {
match ft {
"peak" => BiquadFilterType::Peak,
"lowshelf" => BiquadFilterType::Lowshelf,
"highshelf" => BiquadFilterType::Highshelf,
"lowpass" => BiquadFilterType::Lowpass,
"highpass" => BiquadFilterType::Highpass,
"highpassvariableq" => BiquadFilterType::HighpassVariableQ,
"notch" => BiquadFilterType::Notch,
"bandpass" => BiquadFilterType::Bandpass,
"allpass" => BiquadFilterType::AllPass,
_ => BiquadFilterType::Peak,
}
}
fn export_pipewire(output: &DspChainOutput, sample_rate: f64) -> anyhow::Result<String> {
let mut out = String::new();
writeln!(out, "# PipeWire filter-chain configuration")?;
writeln!(out, "# Generated by roomeq")?;
writeln!(out)?;
writeln!(out, "context.modules = [")?;
writeln!(out, " {{ name = libpipewire-module-filter-chain")?;
writeln!(out, " args = {{")?;
let channels = sorted_channels(output);
let num_channels = channels.len();
let positions: Vec<&str> = channels
.iter()
.map(|(name, _)| channel_short_name(name))
.collect();
let positions_str = positions
.iter()
.map(|p| format!("\"{}\"", pipewire_channel_position(p)))
.collect::<Vec<_>>()
.join(", ");
writeln!(out, " filter.graph = {{")?;
writeln!(out, " nodes = [")?;
let mut all_node_names: Vec<Vec<String>> = Vec::new();
for (ch_idx, (ch_name, chain)) in channels.iter().enumerate() {
let ch_prefix = format!("ch{}_{}", ch_idx, ch_name.replace(' ', "_"));
let mut node_names = Vec::new();
let all_plugins: Vec<PluginConfigWrapper> =
collect_all_plugins(chain).into_iter().cloned().collect();
let gain = extract_gain_db(&all_plugins);
if gain.abs() > 0.01 {
let node_name = format!("{ch_prefix}_gain");
writeln!(
out,
" {{ type = builtin name = \"{node_name}\" label = bq_highshelf control = {{ \"Freq\" = 0 \"Q\" = 1.0 \"Gain\" = {gain:.2} }} }}"
)?;
node_names.push(node_name);
}
if let Some(delay_ms) = extract_delay_ms(&all_plugins) {
let delay_samples = (delay_ms / 1000.0 * sample_rate).round() as u64;
let node_name = format!("{ch_prefix}_delay");
writeln!(
out,
" {{ type = builtin name = \"{node_name}\" label = delay control = {{ \"Delay\" = {delay_samples} }} }}"
)?;
node_names.push(node_name);
}
let filters = extract_eq_filters(&all_plugins);
for (i, f) in filters.iter().enumerate() {
let label = pipewire_filter_label(&f.filter_type);
let node_name = format!("{ch_prefix}_eq_{i}");
match f.filter_type.as_str() {
"lowpass" | "highpass" => {
writeln!(
out,
" {{ type = builtin name = \"{node_name}\" label = {label} control = {{ \"Freq\" = {:.1} \"Q\" = {:.4} }} }}",
f.freq, f.q
)?;
}
_ => {
writeln!(
out,
" {{ type = builtin name = \"{node_name}\" label = {label} control = {{ \"Freq\" = {:.1} \"Q\" = {:.4} \"Gain\" = {:.2} }} }}",
f.freq, f.q, f.gain_db
)?;
}
}
node_names.push(node_name);
}
all_node_names.push(node_names);
}
writeln!(out, " ]")?;
writeln!(out, " links = [")?;
for nodes in &all_node_names {
for pair in nodes.windows(2) {
writeln!(
out,
" {{ output = \"{}:Out\" input = \"{}:In\" }}",
pair[0], pair[1]
)?;
}
}
writeln!(out, " ]")?;
writeln!(out, " inputs = [")?;
for (ch_idx, nodes) in all_node_names.iter().enumerate() {
if let Some(first) = nodes.first() {
writeln!(out, " {{ node = \"{first}\" port = \"In\" }}")?;
} else {
writeln!(out, " {{ }}")?;
}
let _ = ch_idx; }
writeln!(out, " ]")?;
writeln!(out, " outputs = [")?;
for nodes in &all_node_names {
if let Some(last) = nodes.last() {
writeln!(out, " {{ node = \"{last}\" port = \"Out\" }}")?;
} else {
writeln!(out, " {{ }}")?;
}
}
writeln!(out, " ]")?;
writeln!(out, " }}")?;
writeln!(out, " capture.props = {{")?;
writeln!(out, " audio.channels = {num_channels}")?;
writeln!(out, " audio.position = [ {positions_str} ]")?;
writeln!(out, " }}")?;
writeln!(out, " playback.props = {{")?;
writeln!(out, " audio.channels = {num_channels}")?;
writeln!(out, " audio.position = [ {positions_str} ]")?;
writeln!(out, " }}")?;
writeln!(out, " }}")?; writeln!(out, " }}")?; writeln!(out, "]")?;
Ok(out)
}
fn pipewire_channel_position(short: &str) -> &str {
match short {
"L" => "FL",
"R" => "FR",
"C" => "FC",
"LFE" => "LFE",
"SL" => "SL",
"SR" => "SR",
"BL" => "RL",
"BR" => "RR",
other => other,
}
}
fn roon_filter_type(ft: &str) -> &str {
match ft {
"peak" => "Peak/Dip",
"lowshelf" => "Low Shelf",
"highshelf" => "High Shelf",
"lowpass" => "Low Pass",
"highpass" | "highpassvariableq" => "High Pass",
"bandpass" => "Band Pass",
"notch" => "Band Stop",
"allpass" => "Band Stop", other => other,
}
}
fn export_roon(output: &DspChainOutput) -> anyhow::Result<String> {
let channels = sorted_channels(output);
let mut roon = serde_json::Map::new();
roon.insert(
"_comment".to_string(),
serde_json::json!("Roon DSP Engine preset — generated by roomeq"),
);
let mut channel_configs = serde_json::Map::new();
for (ch_name, chain) in &channels {
let all_plugins: Vec<PluginConfigWrapper> =
collect_all_plugins(chain).into_iter().cloned().collect();
let mut ch = serde_json::Map::new();
let gain = extract_gain_db(&all_plugins);
if gain.abs() > 0.01 {
ch.insert("headroom_gain_db".to_string(), serde_json::json!(gain));
}
if let Some(delay_ms) = extract_delay_ms(&all_plugins) {
ch.insert("delay_ms".to_string(), serde_json::json!(delay_ms));
}
let filters = extract_eq_filters(&all_plugins);
let mut bands = Vec::new();
for f in filters.iter().take(20) {
let mut band = serde_json::Map::new();
band.insert(
"type".to_string(),
serde_json::json!(roon_filter_type(&f.filter_type)),
);
band.insert("frequency".to_string(), serde_json::json!(f.freq));
band.insert("gain".to_string(), serde_json::json!(f.gain_db));
band.insert("q".to_string(), serde_json::json!(f.q));
band.insert("enabled".to_string(), serde_json::json!(true));
bands.push(serde_json::Value::Object(band));
}
if !bands.is_empty() {
ch.insert(
"parametric_eq".to_string(),
serde_json::json!({
"bands": bands,
"is_enabled": true
}),
);
}
let conv_paths = extract_convolution_paths(&all_plugins);
if !conv_paths.is_empty() {
ch.insert(
"convolution".to_string(),
serde_json::json!({
"ir_files": conv_paths,
"is_enabled": true
}),
);
}
channel_configs.insert(ch_name.to_string(), serde_json::Value::Object(ch));
}
roon.insert(
"channels".to_string(),
serde_json::Value::Object(channel_configs),
);
Ok(serde_json::to_string_pretty(&serde_json::Value::Object(
roon,
))?)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::roomeq::types::*;
use serde_json::json;
use std::collections::HashMap;
fn make_test_output() -> DspChainOutput {
let mut channels = HashMap::new();
channels.insert(
"left".to_string(),
ChannelDspChain {
channel: "left".to_string(),
plugins: vec![
PluginConfigWrapper {
plugin_type: "gain".to_string(),
parameters: json!({"gain_db": -2.5}),
},
PluginConfigWrapper {
plugin_type: "delay".to_string(),
parameters: json!({"delay_ms": 1.5}),
},
PluginConfigWrapper {
plugin_type: "eq".to_string(),
parameters: json!({
"filters": [
{"filter_type": "peak", "freq": 100.0, "q": 2.0, "db_gain": -5.0},
{"filter_type": "peak", "freq": 1000.0, "q": 1.5, "db_gain": 3.0},
{"filter_type": "highshelf", "freq": 8000.0, "q": 0.7, "db_gain": -2.0},
]
}),
},
],
drivers: None,
initial_curve: None,
final_curve: None,
eq_response: None,
target_curve: None,
pre_ir: None,
post_ir: None,
},
);
channels.insert(
"right".to_string(),
ChannelDspChain {
channel: "right".to_string(),
plugins: vec![
PluginConfigWrapper {
plugin_type: "gain".to_string(),
parameters: json!({"gain_db": -1.0}),
},
PluginConfigWrapper {
plugin_type: "eq".to_string(),
parameters: json!({
"filters": [
{"filter_type": "peak", "freq": 200.0, "q": 1.0, "db_gain": -3.0},
{"filter_type": "lowshelf", "freq": 80.0, "q": 0.71, "db_gain": 4.0},
]
}),
},
],
drivers: None,
initial_curve: None,
final_curve: None,
eq_response: None,
target_curve: None,
pre_ir: None,
post_ir: None,
},
);
DspChainOutput {
version: "1.3.0".to_string(),
channels,
metadata: Some(OptimizationMetadata {
pre_score: 5.0,
post_score: 2.0,
algorithm: "cobyla".to_string(),
iterations: 1000,
timestamp: "2026-01-01T00:00:00Z".to_string(),
inter_channel_deviation: None,
}),
}
}
#[test]
fn test_extract_eq_filters() {
let plugins = vec![PluginConfigWrapper {
plugin_type: "eq".to_string(),
parameters: json!({
"filters": [
{"filter_type": "peak", "freq": 100.0, "q": 2.0, "db_gain": -5.0},
{"filter_type": "highshelf", "freq": 8000.0, "q": 0.7, "db_gain": -2.0},
]
}),
}];
let filters = extract_eq_filters(&plugins);
assert_eq!(filters.len(), 2);
assert_eq!(filters[0].filter_type, "peak");
assert_eq!(filters[0].freq, 100.0);
assert_eq!(filters[1].filter_type, "highshelf");
}
#[test]
fn test_extract_gain_db() {
let plugins = vec![
PluginConfigWrapper {
plugin_type: "gain".to_string(),
parameters: json!({"gain_db": -2.5}),
},
PluginConfigWrapper {
plugin_type: "gain".to_string(),
parameters: json!({"gain_db": 1.0}),
},
];
let gain = extract_gain_db(&plugins);
assert!((gain - (-1.5)).abs() < 0.01);
}
#[test]
fn test_extract_delay_ms() {
let plugins = vec![PluginConfigWrapper {
plugin_type: "delay".to_string(),
parameters: json!({"delay_ms": 3.5}),
}];
assert_eq!(extract_delay_ms(&plugins), Some(3.5));
let empty: Vec<PluginConfigWrapper> = vec![];
assert_eq!(extract_delay_ms(&empty), None);
}
#[test]
fn test_export_camilladsp() {
let output = make_test_output();
let result = export_camilladsp(&output, 48000.0).unwrap();
assert!(result.contains("samplerate: 48000"));
assert!(result.contains("left_gain:"));
assert!(result.contains("left_delay:"));
assert!(result.contains("left_peq_0:"));
assert!(result.contains("left_peq_1:"));
assert!(result.contains("left_peq_2:"));
assert!(result.contains("right_gain:"));
assert!(result.contains("right_peq_0:"));
assert!(result.contains("type: Biquad"));
assert!(result.contains("type: Peaking"));
assert!(result.contains("type: Highshelf"));
assert!(result.contains("type: Gain"));
assert!(result.contains("type: Delay"));
assert!(result.contains("unit: ms"));
assert!(result.contains("pipeline:"));
}
#[test]
fn test_export_equalizer_apo() {
let output = make_test_output();
let result = export_equalizer_apo(&output).unwrap();
assert!(result.contains("Channel: L"));
assert!(result.contains("Channel: R"));
assert!(result.contains("Preamp: -2.5 dB"));
assert!(result.contains("Delay: 1.500 ms"));
assert!(result.contains("Filter 1: ON PK Fc 100 Hz Gain -5.00 dB Q 2.0000"));
assert!(result.contains("Filter 3: ON HSC Fc 8000 Hz Gain -2.00 dB Q 0.7000"));
assert!(result.contains("Filter 1: ON PK Fc 200 Hz Gain -3.00 dB Q 1.0000"));
assert!(result.contains("Filter 2: ON LSC Fc 80 Hz Gain +4.00 dB Q 0.7100"));
}
#[test]
fn test_export_easyeffects() {
let output = make_test_output();
let result = export_easyeffects(&output).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
let eq = &parsed["output"]["equalizer#0"];
assert_eq!(eq["num-bands"].as_u64().unwrap(), 5);
assert!(eq["left"]["band0"]["frequency"].as_f64().unwrap() > 0.0);
let band0_type = eq["left"]["band0"]["type"].as_str().unwrap();
assert_eq!(band0_type, "Bell");
}
#[test]
fn test_export_wavelet() {
let output = make_test_output();
let result = export_wavelet(&output, 48000.0).unwrap();
assert!(result.contains("GraphicEQ:"));
let line = result
.lines()
.find(|l| l.starts_with("GraphicEQ:"))
.unwrap();
let parts: Vec<&str> = line.trim_start_matches("GraphicEQ:").split(';').collect();
assert_eq!(parts.len(), 9);
}
#[test]
fn test_export_pipewire() {
let output = make_test_output();
let result = export_pipewire(&output, 48000.0).unwrap();
assert!(result.contains("libpipewire-module-filter-chain"));
assert!(result.contains("bq_peaking"));
assert!(result.contains("bq_highshelf"));
assert!(result.contains("filter.graph"));
assert!(result.contains("nodes ="));
assert!(result.contains("links ="));
assert!(result.contains("audio.channels = 2"));
assert!(result.contains("\"FL\""));
assert!(result.contains("\"FR\""));
}
#[test]
fn test_export_format_extensions() {
assert_eq!(ExportFormat::CamillaDsp.default_extension(), "yaml");
assert_eq!(ExportFormat::EqualizerApo.default_extension(), "txt");
assert_eq!(ExportFormat::EasyEffects.default_extension(), "json");
assert_eq!(ExportFormat::Wavelet.default_extension(), "txt");
assert_eq!(ExportFormat::PipeWire.default_extension(), "conf");
assert_eq!(ExportFormat::RoonDsp.default_extension(), "json");
}
#[test]
fn test_export_roon() {
let output = make_test_output();
let result = export_roon(&output).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
let channels = &parsed["channels"];
let left = &channels["left"];
assert!(left["headroom_gain_db"].as_f64().unwrap() < 0.0);
assert!((left["delay_ms"].as_f64().unwrap() - 1.5).abs() < 0.01);
let left_bands = left["parametric_eq"]["bands"].as_array().unwrap();
assert_eq!(left_bands.len(), 3);
assert_eq!(left_bands[0]["type"].as_str().unwrap(), "Peak/Dip");
assert_eq!(left_bands[0]["frequency"].as_f64().unwrap(), 100.0);
assert_eq!(left_bands[2]["type"].as_str().unwrap(), "High Shelf");
let right = &channels["right"];
assert!(right["headroom_gain_db"].as_f64().unwrap() < 0.0);
assert!(right.get("delay_ms").is_none());
let right_bands = right["parametric_eq"]["bands"].as_array().unwrap();
assert_eq!(right_bands.len(), 2);
assert_eq!(right_bands[1]["type"].as_str().unwrap(), "Low Shelf");
assert!(right_bands[0]["enabled"].as_bool().unwrap());
}
#[test]
fn test_camilladsp_uses_second_order_filters() {
let mut channels = HashMap::new();
channels.insert(
"left".to_string(),
ChannelDspChain {
channel: "left".to_string(),
plugins: vec![PluginConfigWrapper {
plugin_type: "eq".to_string(),
parameters: json!({
"filters": [
{"filter_type": "highpass", "freq": 80.0, "q": 0.71, "db_gain": 0.0},
{"filter_type": "lowpass", "freq": 16000.0, "q": 0.71, "db_gain": 0.0},
]
}),
}],
drivers: None,
initial_curve: None,
final_curve: None,
eq_response: None,
target_curve: None,
pre_ir: None,
post_ir: None,
},
);
let output = DspChainOutput {
version: "1.3.0".to_string(),
channels,
metadata: None,
};
let result = export_camilladsp(&output, 48000.0).unwrap();
assert!(
result.contains("type: Highpass"),
"Expected second-order Highpass, got:\n{result}"
);
assert!(
result.contains("type: Lowpass"),
"Expected second-order Lowpass, got:\n{result}"
);
assert!(
!result.contains("FO"),
"Should not contain first-order filter types"
);
}
#[test]
fn test_camilladsp_no_duplicate_yaml_keys() {
let mut channels = HashMap::new();
channels.insert(
"left".to_string(),
ChannelDspChain {
channel: "left".to_string(),
plugins: vec![
PluginConfigWrapper {
plugin_type: "gain".to_string(),
parameters: json!({"gain_db": -3.0}),
},
PluginConfigWrapper {
plugin_type: "gain".to_string(),
parameters: json!({"gain_db": -1.0, "invert": true}),
},
],
drivers: None,
initial_curve: None,
final_curve: None,
eq_response: None,
target_curve: None,
pre_ir: None,
post_ir: None,
},
);
let output = DspChainOutput {
version: "1.3.0".to_string(),
channels,
metadata: None,
};
let result = export_camilladsp(&output, 48000.0).unwrap();
assert!(result.contains("left_gain:"));
assert!(result.contains("left_gain_1:"));
}
#[test]
fn test_easyeffects_uses_min_gain() {
let mut channels = HashMap::new();
channels.insert(
"left".to_string(),
ChannelDspChain {
channel: "left".to_string(),
plugins: vec![PluginConfigWrapper {
plugin_type: "gain".to_string(),
parameters: json!({"gain_db": -5.0}),
}],
drivers: None,
initial_curve: None,
final_curve: None,
eq_response: None,
target_curve: None,
pre_ir: None,
post_ir: None,
},
);
channels.insert(
"right".to_string(),
ChannelDspChain {
channel: "right".to_string(),
plugins: vec![PluginConfigWrapper {
plugin_type: "gain".to_string(),
parameters: json!({"gain_db": 3.0}),
}],
drivers: None,
initial_curve: None,
final_curve: None,
eq_response: None,
target_curve: None,
pre_ir: None,
post_ir: None,
},
);
let output = DspChainOutput {
version: "1.3.0".to_string(),
channels,
metadata: None,
};
let result = export_easyeffects(&output).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
let input_gain = parsed["output"]["equalizer#0"]["input-gain"]
.as_f64()
.unwrap();
assert!(
(input_gain - (-5.0)).abs() < 0.01,
"Expected -5.0 gain, got {input_gain}"
);
}
#[test]
fn test_unknown_channels_sort_alphabetically() {
let mut channels = HashMap::new();
for name in &["sub2", "sub0", "sub1"] {
channels.insert(
name.to_string(),
ChannelDspChain {
channel: name.to_string(),
plugins: vec![],
drivers: None,
initial_curve: None,
final_curve: None,
eq_response: None,
target_curve: None,
pre_ir: None,
post_ir: None,
},
);
}
let output = DspChainOutput {
version: "1.3.0".to_string(),
channels,
metadata: None,
};
let sorted = sorted_channels(&output);
let names: Vec<&str> = sorted.iter().map(|(n, _)| n.as_str()).collect();
assert_eq!(names, vec!["sub0", "sub1", "sub2"]);
}
#[test]
fn test_highpassvariableq_mapped_correctly() {
assert_eq!(
parse_biquad_filter_type("highpassvariableq"),
BiquadFilterType::HighpassVariableQ
);
assert_eq!(camilladsp_filter_type("highpassvariableq"), "Highpass");
assert_eq!(apo_filter_type("highpassvariableq"), "HP");
assert_eq!(pipewire_filter_label("highpassvariableq"), "bq_highpass");
}
#[test]
fn test_export_with_drivers() {
let mut channels = HashMap::new();
channels.insert(
"left".to_string(),
ChannelDspChain {
channel: "left".to_string(),
plugins: vec![PluginConfigWrapper {
plugin_type: "eq".to_string(),
parameters: json!({
"filters": [
{"filter_type": "peak", "freq": 500.0, "q": 1.0, "db_gain": -2.0},
]
}),
}],
drivers: Some(vec![
DriverDspChain {
name: "woofer".to_string(),
index: 0,
plugins: vec![
PluginConfigWrapper {
plugin_type: "gain".to_string(),
parameters: json!({"gain_db": -3.0}),
},
PluginConfigWrapper {
plugin_type: "delay".to_string(),
parameters: json!({"delay_ms": 2.0}),
},
],
initial_curve: None,
},
DriverDspChain {
name: "tweeter".to_string(),
index: 1,
plugins: vec![PluginConfigWrapper {
plugin_type: "gain".to_string(),
parameters: json!({"gain_db": 0.0, "invert": true}),
}],
initial_curve: None,
},
]),
initial_curve: None,
final_curve: None,
eq_response: None,
target_curve: None,
pre_ir: None,
post_ir: None,
},
);
let output = DspChainOutput {
version: "1.3.0".to_string(),
channels,
metadata: None,
};
let cdsp = export_camilladsp(&output, 48000.0).unwrap();
assert!(cdsp.contains("left_woofer_gain:"));
assert!(cdsp.contains("left_woofer_delay:"));
assert!(cdsp.contains("left_tweeter_gain:"));
assert!(cdsp.contains("inverted: true"));
let apo = export_equalizer_apo(&output).unwrap();
assert!(apo.contains("Preamp: -3.0 dB"));
assert!(apo.contains("Delay: 2.000 ms"));
}
}