audioconvert: add zeroramp and gap detection to audioconvert

Make a new zeroramp.duration and zeroramp.gap property on audioconver.
It detects N samples of silence before triggering a fade-in or fade-out
of the given zeroramp.duration.

The zeroramp.duration is by default 5ms and zeroramp.gap is set to 0.

When the zeroramp.gap is set to 0, the audioconver will not do any gap
detection but it will only do fade-out from the last sample when the IO
Buffer area is removed from a port.

This by default makes the audio adapter perform a fade-out when the last
input of the port mixer was removed and the mixer is no longer scheduled
and the IO Area removed from the audioconverter input port.
This commit is contained in:
Wim Taymans 2026-06-17 17:11:42 +02:00
parent c2083336a4
commit 8971c488f3
11 changed files with 365 additions and 3 deletions

View file

@ -87,6 +87,7 @@ stream.properties = {
#dither.noise = 0 #dither.noise = 0
#dither.method = none # rectangular, triangular, triangular-hf, wannamaker3, shaped5 #dither.method = none # rectangular, triangular, triangular-hf, wannamaker3, shaped5
#debug.wav-path = "" #debug.wav-path = ""
#zeroramp.gap = 0
#zeroramp.duration = 0.005 #zeroramp.duration = 0.005
} }
``` ```

View file

@ -842,6 +842,23 @@ Dithering is only useful for conversion to a format with less than 24 bits and w
disabled otherwise. disabled otherwise.
\endparblock \endparblock
@PAR@ node-prop zeroramp.gap = 0
\parblock
This instructs the audio converter to run gap detection. If zeroramp.gap consecutive
silence samples are found, the audio converter will perform a fade-in or fade-out with
new/old samples respectively.
Fade-in and fade-out are important when the signal has a sudden changes to and from silence
samples, which can cause loud pops and cracks.
When an application uses the proper PipeWire pause and resume functions, fades will be
performed automatically where needed. For applications that simply send silence when
paused (chrome, ...), zeroramp.gap detection can be a workaround.
This is disabled by default because it might corrupt the signal with excessive fades in
case the signal has large silence gaps.
\endparblock
@PAR@ node-prop zeroramp.duration = 0.005 @PAR@ node-prop zeroramp.duration = 0.005
The duration of fade-in and fade-out of the signal in seconds on silence gaps. The duration of fade-in and fade-out of the signal in seconds on silence gaps.

View file

@ -100,6 +100,7 @@ stream.properties = {
#dither.noise = 0 #dither.noise = 0
#dither.method = none # rectangular, triangular, triangular-hf, wannamaker3, shaped5 #dither.method = none # rectangular, triangular, triangular-hf, wannamaker3, shaped5
#debug.wav-path = "" #debug.wav-path = ""
#zeroramp.gap = 0
#zeroramp.duration = 0.005 #zeroramp.duration = 0.005
} }
``` ```

View file

@ -39,6 +39,7 @@
#include "volume-ops.h" #include "volume-ops.h"
#include "fmt-ops.h" #include "fmt-ops.h"
#include "gaps-ops.h"
#include "channelmix-ops.h" #include "channelmix-ops.h"
#include "resample.h" #include "resample.h"
#include "wavfile.h" #include "wavfile.h"
@ -108,6 +109,7 @@ struct props {
char wav_path[512]; char wav_path[512];
unsigned int lock_volumes:1; unsigned int lock_volumes:1;
unsigned int filter_graph_disabled:1; unsigned int filter_graph_disabled:1;
float zeroramp_duration;
}; };
static void props_reset(struct props *props) static void props_reset(struct props *props)
@ -131,6 +133,7 @@ static void props_reset(struct props *props)
spa_zero(props->wav_path); spa_zero(props->wav_path);
props->lock_volumes = false; props->lock_volumes = false;
props->filter_graph_disabled = false; props->filter_graph_disabled = false;
props->zeroramp_duration = 0.005f;
} }
struct buffer { struct buffer {
@ -181,6 +184,7 @@ struct port {
const struct spa_pod_sequence *ctrl; const struct spa_pod_sequence *ctrl;
uint32_t ctrl_offset; uint32_t ctrl_offset;
bool ramp_start;
struct spa_list queue; struct spa_list queue;
}; };
@ -212,7 +216,8 @@ struct stage_context {
#define CTX_DATA_REMAP_SRC 3 #define CTX_DATA_REMAP_SRC 3
#define CTX_DATA_TMP_0 4 #define CTX_DATA_TMP_0 4
#define CTX_DATA_TMP_1 5 #define CTX_DATA_TMP_1 5
#define CTX_DATA_MAX 6 #define CTX_DATA_GAP 6
#define CTX_DATA_MAX 7
void **datas[CTX_DATA_MAX]; void **datas[CTX_DATA_MAX];
uint32_t in_samples; uint32_t in_samples;
uint32_t n_samples; uint32_t n_samples;
@ -309,6 +314,7 @@ struct impl {
struct dir dir[2]; struct dir dir[2];
struct channelmix mix; struct channelmix mix;
struct resample resample; struct resample resample;
struct gaps gaps;
struct volume volume; struct volume volume;
double rate_scale; double rate_scale;
struct spa_pod_sequence *vol_ramp_sequence; struct spa_pod_sequence *vol_ramp_sequence;
@ -1599,6 +1605,10 @@ static int audioconvert_set_param(struct impl *this, const char *k, const char *
order, spa_strerror(res)); order, spa_strerror(res));
} }
} }
else if (spa_streq(k, "zeroramp.gap"))
spa_atou32(s, &this->gaps.gap, 0);
else if (spa_streq(k, "zeroramp.duration"))
spa_atof(s, &this->props.zeroramp_duration);
else else
return 0; return 0;
return 1; return 1;
@ -2414,6 +2424,14 @@ static int setup_resample(struct impl *this)
if (this->resample.free) if (this->resample.free)
resample_free(&this->resample); resample_free(&this->resample);
if (this->gaps.free)
gaps_free(&this->gaps);
this->gaps.channels = channels;
this->gaps.log = this->log;
this->gaps.cpu_flags = this->cpu_flags;
this->gaps.duration = (uint32_t)(this->props.zeroramp_duration * in->format.info.raw.rate);
gaps_init(&this->gaps);
this->resample.channels = channels; this->resample.channels = channels;
this->resample.i_rate = in->format.info.raw.rate; this->resample.i_rate = in->format.info.raw.rate;
@ -3419,7 +3437,6 @@ impl_node_port_use_buffers(void *object,
spa_log_warn(this->log, "%p: memory %d on buffer %d not aligned", spa_log_warn(this->log, "%p: memory %d on buffer %d not aligned",
this, j, i); this, j, i);
} }
b->datas[j] = data; b->datas[j] = data;
maxsize = SPA_MAX(maxsize, d[j].maxsize); maxsize = SPA_MAX(maxsize, d[j].maxsize);
@ -3446,6 +3463,8 @@ static int do_set_port_io(struct spa_loop *loop, bool async, uint32_t seq,
const void *data, size_t size, void *user_data) const void *data, size_t size, void *user_data)
{ {
const struct io_data *d = user_data; const struct io_data *d = user_data;
if (d->data == NULL && d->port->io != NULL)
d->port->ramp_start = true;
d->port->io = d->data; d->port->io = d->data;
return 0; return 0;
} }
@ -3724,6 +3743,37 @@ static void add_src_convert_stage(struct impl *impl, struct stage_context *ctx)
ctx->src_idx = s->out_idx; ctx->src_idx = s->out_idx;
} }
static void run_gap_detect_stage(struct stage *s, struct stage_context *c)
{
struct impl *impl = s->impl;
if (gaps_check(&impl->gaps, (const float**)c->datas[s->in_idx], c->n_samples)) {
gaps_fix(&impl->gaps, (float**)c->datas[s->out_idx],
(const float**)c->datas[s->in_idx], c->n_samples);
c->datas[CTX_DATA_GAP] = c->datas[s->out_idx];
} else {
c->empty = impl->gaps.empty;
c->datas[CTX_DATA_GAP] = c->datas[s->in_idx];
}
spa_log_trace_fp(impl->log, "%p: gap %d", impl, c->n_samples);
}
static bool is_src(uint32_t idx)
{
return idx == CTX_DATA_REMAP_SRC || idx == CTX_DATA_SRC;
}
static void add_gap_detect_stage(struct impl *impl, struct stage_context *ctx)
{
struct stage *s = &impl->stages[impl->n_stages];
s->impl = impl;
s->in_idx = ctx->src_idx;
s->out_idx = is_src(s->in_idx) ? get_dst_idx(ctx) : ctx->src_idx;
s->run = run_gap_detect_stage;
s->data = NULL;
spa_log_trace(impl->log, "%p: stage %d", impl, impl->n_stages);
impl->n_stages++;
ctx->src_idx = CTX_DATA_GAP;
}
static void run_resample_stage(struct stage *s, struct stage_context *c) static void run_resample_stage(struct stage *s, struct stage_context *c)
{ {
struct impl *impl = s->impl; struct impl *impl = s->impl;
@ -3851,7 +3901,7 @@ static void add_dst_convert_stage(struct impl *impl, struct stage_context *ctx)
static void recalc_stages(struct impl *this, struct stage_context *ctx) static void recalc_stages(struct impl *this, struct stage_context *ctx)
{ {
struct dir *dir; struct dir *dir;
bool test, do_wav; bool test, do_wav, do_gap;
struct port *ctrlport = ctx->ctrlport; struct port *ctrlport = ctx->ctrlport;
bool in_need_remap, out_need_remap; bool in_need_remap, out_need_remap;
uint32_t i; uint32_t i;
@ -3883,6 +3933,8 @@ static void recalc_stages(struct impl *this, struct stage_context *ctx)
(ctrlport == NULL || ctrlport->ctrl == NULL) && (this->vol_ramp_sequence == NULL); (ctrlport == NULL || ctrlport->ctrl == NULL) && (this->vol_ramp_sequence == NULL);
SPA_FLAG_UPDATE(ctx->bits, MIX_BIT, !test); SPA_FLAG_UPDATE(ctx->bits, MIX_BIT, !test);
do_gap = this->gaps.duration > 0;
/* if we have nothing to do, force a conversion to the destination to make sure we /* if we have nothing to do, force a conversion to the destination to make sure we
* actually write something to the destination buffer */ * actually write something to the destination buffer */
if (ctx->bits == 0) if (ctx->bits == 0)
@ -3904,6 +3956,9 @@ static void recalc_stages(struct impl *this, struct stage_context *ctx)
} }
if (this->direction == SPA_DIRECTION_INPUT) { if (this->direction == SPA_DIRECTION_INPUT) {
if (do_gap)
add_gap_detect_stage(this, ctx);
if (SPA_FLAG_IS_SET(ctx->bits, RESAMPLE_BIT)) if (SPA_FLAG_IS_SET(ctx->bits, RESAMPLE_BIT))
add_resample_stage(this, ctx); add_resample_stage(this, ctx);
} }
@ -3921,6 +3976,9 @@ static void recalc_stages(struct impl *this, struct stage_context *ctx)
add_channelmix_stage(this, ctx); add_channelmix_stage(this, ctx);
if (this->direction == SPA_DIRECTION_OUTPUT) { if (this->direction == SPA_DIRECTION_OUTPUT) {
if (do_gap)
add_gap_detect_stage(this, ctx);
if (SPA_FLAG_IS_SET(ctx->bits, RESAMPLE_BIT)) if (SPA_FLAG_IS_SET(ctx->bits, RESAMPLE_BIT))
add_resample_stage(this, ctx); add_resample_stage(this, ctx);
} }
@ -4030,6 +4088,11 @@ static int impl_node_process(void *object)
} else { } else {
remap = n_src_datas++; remap = n_src_datas++;
src_datas[remap] = SPA_PTR_ALIGN(this->empty, MAX_ALIGN, void); src_datas[remap] = SPA_PTR_ALIGN(this->empty, MAX_ALIGN, void);
if (SPA_UNLIKELY(port->ramp_start)) {
this->gaps.states[remap].mode = 3;
this->gaps.states[remap].count = 0;
port->ramp_start = false;
}
spa_log_trace_fp(this->log, "%p: empty input %d->%d", this, spa_log_trace_fp(this->log, "%p: empty input %d->%d", this,
i * port->blocks + j, remap); i * port->blocks + j, remap);
max_in = SPA_MIN(max_in, this->scratch_size / port->stride); max_in = SPA_MIN(max_in, this->scratch_size / port->stride);

View file

@ -0,0 +1,137 @@
/* Spa */
/* SPDX-FileCopyrightText: Copyright © 2025 Wim Taymans */
/* SPDX-License-Identifier: MIT */
#include <math.h>
#include <spa/support/log.h>
#include "gaps-ops.h"
#ifndef M_PIf
# define M_PIf 3.14159265358979323846f /* pi */
#endif
static int run_gap_check(struct gaps *gaps, uint32_t c, const float * SPA_RESTRICT src[], uint32_t n_samples,
bool *empty)
{
uint32_t n;
bool head_filled = true, tail_filled = true;
struct gaps_state *s = &gaps->states[c];
const float *in = src[c];
for (n = 0; n < gaps->gap; n++) {
if (in[n] == 0.0f) {
head_filled = false;
break;
}
}
if (gaps->gap > 0 && n_samples > gaps->gap) {
for (n = n_samples - gaps->gap - 1; n < n_samples; n++) {
if (in[n] == 0.0f) {
tail_filled = false;
break;
}
}
} else {
tail_filled = head_filled;
}
if (s->mode == 1 && head_filled && tail_filled) {
/* in normal mode and head and tail seem to have data */
if (n_samples > 0)
s->history[0] = in[n_samples-1];
*empty = false;
return 0;
}
else if (s->mode == 0 && !tail_filled && !head_filled) {
/* zero mode and head and tail seem to be empty */
return 0;
}
*empty = false;
return 1;
}
int gaps_check_c(struct gaps *gaps, const float * SPA_RESTRICT src[], uint32_t n_samples)
{
uint32_t c;
int res = 0;
gaps->empty = true;
for (c = 0; c < gaps->channels; c++)
res += run_gap_check(gaps, c, src, n_samples, &gaps->empty);
return res;
}
static void run_gap_fix(struct gaps *gaps, uint32_t c, float * SPA_RESTRICT dst[],
const float * SPA_RESTRICT src[], uint32_t n_samples)
{
uint32_t n;
struct gaps_state *s = &gaps->states[c];
const float *in = src[c];
float *out = dst[c];
for (n = 0; n < n_samples; n++) {
bool is_zero = in[n] == 0.0f;
if (s->mode == 0) {
/* zero mode */
if (!is_zero) {
/* gap ended, move to fade-in mode */
s->mode = gaps->gap ? 2 : 1;
s->count = 0;
} else {
out[n] = 0.0f;
}
}
else if (s->mode == 1) {
out[n] = in[n];
/* normal mode, finding gaps */
if (is_zero && gaps->gap > 0) {
if (++s->count >= gaps->gap) {
n -= SPA_MIN(s->count, n);
s->mode = 3;
s->count = 0;
}
} else {
/* keep last samples to fade out when needed */
s->count = 0;
s->history[0] = in[n];
}
}
if (s->mode == 2) {
/* fade-in mode */
if (s->count == 0)
spa_log_info(gaps->log, "%p start %d fade-in %d", gaps, c, n);
out[n] = in[n] * gaps->curve[s->count];
if (++s->count >= gaps->duration) {
/* fade in complete, back to normal mode */
s->mode = 1;
s->count = 0;
spa_log_debug(gaps->log, "%p stop %d fade-in %d", gaps, c, n);
}
}
else if (s->mode == 3) {
/* fade-out mode */
if (s->count == 0)
spa_log_info(gaps->log, "%p start %d fade-out %f %d",
gaps, c, s->history[0], n);
out[n] = s->history[0] * (1.0f - gaps->curve[s->count]);
if (++s->count >= gaps->duration) {
/* fade out complete, go to zero mode */
s->mode = gaps->gap ? 0 : 1;
s->count = 0;
spa_log_debug(gaps->log, "%p stop %d fade-out %d", gaps, c, n);
}
}
}
}
void gaps_fix_c(struct gaps *gaps, float * SPA_RESTRICT dst[],
const float * SPA_RESTRICT src[], uint32_t n_samples)
{
uint32_t c;
for (c = 0; c < gaps->channels; c++)
run_gap_fix(gaps, c, dst, src, n_samples);
}

View file

@ -0,0 +1,77 @@
/* Spa */
/* SPDX-FileCopyrightText: Copyright © 2022 Wim Taymans */
/* SPDX-License-Identifier: MIT */
#include <string.h>
#include <stdio.h>
#include <math.h>
#include <errno.h>
#include <spa/support/cpu.h>
#include <spa/support/log.h>
#include <spa/utils/defs.h>
#include "gaps-ops.h"
typedef int (*gaps_check_func_t) (struct gaps *gaps, const float * SPA_RESTRICT src[],
uint32_t n_samples);
typedef void (*gaps_fix_func_t) (struct gaps *gaps, float * SPA_RESTRICT dst[],
const float * SPA_RESTRICT src[], uint32_t n_samples);
#define MAKE(check,fix,...) \
{ check, fix, #fix , __VA_ARGS__ }
static const struct gaps_info {
gaps_check_func_t check;
gaps_fix_func_t fix;
const char *name;
uint32_t cpu_flags;
} gaps_table[] =
{
MAKE(gaps_check_c, gaps_fix_c, 0),
};
#undef MAKE
#define MATCH_CPU_FLAGS(a,b) ((a) == 0 || ((a) & (b)) == a)
static const struct gaps_info *find_gaps_info(uint32_t cpu_flags)
{
SPA_FOR_EACH_ELEMENT_VAR(gaps_table, t) {
if (MATCH_CPU_FLAGS(t->cpu_flags, cpu_flags))
return t;
}
return NULL;
}
static void impl_gaps_free(struct gaps *gaps)
{
gaps->fix = NULL;
}
int gaps_init(struct gaps *gaps)
{
const struct gaps_info *info;
uint32_t i;
info = find_gaps_info(gaps->cpu_flags);
if (info == NULL)
return -ENOTSUP;
if (gaps->channels > SPA_AUDIO_MAX_CHANNELS)
return -EINVAL;
gaps->duration = SPA_MIN(gaps->duration, GAPS_MAX_CURVE);
for (i = 0; i < gaps->duration; i++)
gaps->curve[i] = (float)(0.5 + 0.5 * cos(M_PI + M_PI * i / gaps->duration));
for (i = 0; i < gaps->channels; i++)
spa_zero(gaps->states[i]);
gaps->cpu_flags = info->cpu_flags;
gaps->func_name = info->name;
gaps->free = impl_gaps_free;
gaps->check = info->check;
gaps->fix = info->fix;
return 0;
}

View file

@ -0,0 +1,60 @@
/* Spa */
/* SPDX-FileCopyrightText: Copyright © 2025 Wim Taymans */
/* SPDX-License-Identifier: MIT */
#include <string.h>
#include <stdio.h>
#include <spa/utils/defs.h>
#include <spa/param/audio/raw.h>
struct gaps_state {
uint32_t mode;
uint32_t count;
float history[1];
};
#define GAPS_MAX_CURVE 4096u
struct gaps {
uint32_t cpu_flags;
const char *func_name;
struct spa_log *log;
uint32_t flags;
uint32_t channels;
uint32_t gap;
uint32_t duration;
float curve[GAPS_MAX_CURVE];
bool empty;
int (*check) (struct gaps *gaps, const float * SPA_RESTRICT src[], uint32_t n_samples);
void (*fix) (struct gaps *gaps, float * SPA_RESTRICT dst[],
const float * SPA_RESTRICT src[], uint32_t n_samples);
void (*free) (struct gaps *gaps);
struct gaps_state states[SPA_AUDIO_MAX_CHANNELS];
};
int gaps_init(struct gaps *gaps);
#define gaps_check(gaps,...) (gaps)->check(gaps, __VA_ARGS__)
#define gaps_fix(gaps,...) (gaps)->fix(gaps, __VA_ARGS__)
#define gaps_free(gaps) (gaps)->free(gaps)
#define DEFINE_CHECK_FUNCTION(arch) \
int gaps_check_##arch(struct gaps *gaps, const float * SPA_RESTRICT src[], \
uint32_t n_samples);
#define DEFINE_FIX_FUNCTION(arch) \
void gaps_fix_##arch(struct gaps *gaps, float * SPA_RESTRICT dst[], \
const float * SPA_RESTRICT src[], uint32_t n_samples);
#define GAPS_OPS_MAX_ALIGN 16
DEFINE_CHECK_FUNCTION(c);
DEFINE_FIX_FUNCTION(c);
#undef DEFINE_CHECK_FUNCTION
#undef DEFINE_FIX_FUNCTION

View file

@ -19,6 +19,7 @@ audioconvert_c = static_library('audioconvert_c',
'biquad.c', 'biquad.c',
'crossover.c', 'crossover.c',
'volume-ops-c.c', 'volume-ops-c.c',
'gaps-ops-c.c',
'peaks-ops-c.c', 'peaks-ops-c.c',
'resample-native-c.c', 'resample-native-c.c',
'fmt-ops-c.c' ], 'fmt-ops-c.c' ],
@ -158,6 +159,7 @@ resample_native_precomp_h = custom_target(
audioconvert_lib = static_library('audioconvert', audioconvert_lib = static_library('audioconvert',
['fmt-ops.c', ['fmt-ops.c',
'channelmix-ops.c', 'channelmix-ops.c',
'gaps-ops.c',
'peaks-ops.c', 'peaks-ops.c',
resample_native_precomp_h, resample_native_precomp_h,
'resample-native.c', 'resample-native.c',

View file

@ -112,6 +112,7 @@ stream.properties = {
#channelmix.stereo-widen = 0.0 #channelmix.stereo-widen = 0.0
#channelmix.hilbert-taps = 0 #channelmix.hilbert-taps = 0
#dither.noise = 0 #dither.noise = 0
#zeroramp.gap = 0
#zeroramp.duration = 0.005 #zeroramp.duration = 0.005
} }

View file

@ -361,6 +361,7 @@ context.objects = [
#channelmix.hilbert-taps = 0 #channelmix.hilbert-taps = 0
#channelmix.disable = false #channelmix.disable = false
#dither.noise = 0 #dither.noise = 0
#zeroramp.gap = 0
#zeroramp.duration = 0.005 #zeroramp.duration = 0.005
#node.param.Props = { #node.param.Props = {
# params = [ # params = [
@ -428,6 +429,7 @@ context.objects = [
#channelmix.hilbert-taps = 0 #channelmix.hilbert-taps = 0
#channelmix.disable = false #channelmix.disable = false
#dither.noise = 0 #dither.noise = 0
#zeroramp.gap = 0
#zeroramp.duration = 0.005 #zeroramp.duration = 0.005
#node.param.Props = { #node.param.Props = {
# params = [ # params = [

View file

@ -110,6 +110,7 @@ stream.properties = {
#channelmix.lfe-level = 0.5 #channelmix.lfe-level = 0.5
#channelmix.hilbert-taps = 0 #channelmix.hilbert-taps = 0
#dither.noise = 0 #dither.noise = 0
#zeroramp.gap = 0
#zeroramp.duration = 0.005 #zeroramp.duration = 0.005
} }