diff --git a/doc/dox/config/pipewire-client.conf.5.md b/doc/dox/config/pipewire-client.conf.5.md index 48775a0aa..8d20f7fec 100644 --- a/doc/dox/config/pipewire-client.conf.5.md +++ b/doc/dox/config/pipewire-client.conf.5.md @@ -87,6 +87,7 @@ stream.properties = { #dither.noise = 0 #dither.method = none # rectangular, triangular, triangular-hf, wannamaker3, shaped5 #debug.wav-path = "" + #zeroramp.gap = 0 #zeroramp.duration = 0.005 } ``` diff --git a/doc/dox/config/pipewire-props.7.md b/doc/dox/config/pipewire-props.7.md index 6968e0300..208001562 100644 --- a/doc/dox/config/pipewire-props.7.md +++ b/doc/dox/config/pipewire-props.7.md @@ -842,6 +842,23 @@ Dithering is only useful for conversion to a format with less than 24 bits and w disabled otherwise. \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 The duration of fade-in and fade-out of the signal in seconds on silence gaps. diff --git a/doc/dox/config/pipewire-pulse.conf.5.md b/doc/dox/config/pipewire-pulse.conf.5.md index bdbd4469b..8ba192e8e 100644 --- a/doc/dox/config/pipewire-pulse.conf.5.md +++ b/doc/dox/config/pipewire-pulse.conf.5.md @@ -100,6 +100,7 @@ stream.properties = { #dither.noise = 0 #dither.method = none # rectangular, triangular, triangular-hf, wannamaker3, shaped5 #debug.wav-path = "" + #zeroramp.gap = 0 #zeroramp.duration = 0.005 } ``` diff --git a/spa/plugins/audioconvert/audioconvert.c b/spa/plugins/audioconvert/audioconvert.c index 8a485060b..379b5b91e 100644 --- a/spa/plugins/audioconvert/audioconvert.c +++ b/spa/plugins/audioconvert/audioconvert.c @@ -39,6 +39,7 @@ #include "volume-ops.h" #include "fmt-ops.h" +#include "gaps-ops.h" #include "channelmix-ops.h" #include "resample.h" #include "wavfile.h" @@ -108,6 +109,7 @@ struct props { char wav_path[512]; unsigned int lock_volumes:1; unsigned int filter_graph_disabled:1; + float zeroramp_duration; }; static void props_reset(struct props *props) @@ -131,6 +133,7 @@ static void props_reset(struct props *props) spa_zero(props->wav_path); props->lock_volumes = false; props->filter_graph_disabled = false; + props->zeroramp_duration = 0.005f; } struct buffer { @@ -181,6 +184,7 @@ struct port { const struct spa_pod_sequence *ctrl; uint32_t ctrl_offset; + bool ramp_start; struct spa_list queue; }; @@ -212,7 +216,8 @@ struct stage_context { #define CTX_DATA_REMAP_SRC 3 #define CTX_DATA_TMP_0 4 #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]; uint32_t in_samples; uint32_t n_samples; @@ -309,6 +314,7 @@ struct impl { struct dir dir[2]; struct channelmix mix; struct resample resample; + struct gaps gaps; struct volume volume; double rate_scale; 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)); } } + 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 return 0; return 1; @@ -2414,6 +2424,14 @@ static int setup_resample(struct impl *this) if (this->resample.free) 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.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", this, j, i); } - b->datas[j] = data; 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 struct io_data *d = user_data; + if (d->data == NULL && d->port->io != NULL) + d->port->ramp_start = true; d->port->io = d->data; 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; } +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) { 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) { struct dir *dir; - bool test, do_wav; + bool test, do_wav, do_gap; struct port *ctrlport = ctx->ctrlport; bool in_need_remap, out_need_remap; 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); 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 * actually write something to the destination buffer */ 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 (do_gap) + add_gap_detect_stage(this, ctx); + if (SPA_FLAG_IS_SET(ctx->bits, RESAMPLE_BIT)) 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); if (this->direction == SPA_DIRECTION_OUTPUT) { + if (do_gap) + add_gap_detect_stage(this, ctx); + if (SPA_FLAG_IS_SET(ctx->bits, RESAMPLE_BIT)) add_resample_stage(this, ctx); } @@ -4030,6 +4088,11 @@ static int impl_node_process(void *object) } else { remap = n_src_datas++; 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, i * port->blocks + j, remap); max_in = SPA_MIN(max_in, this->scratch_size / port->stride); diff --git a/spa/plugins/audioconvert/gaps-ops-c.c b/spa/plugins/audioconvert/gaps-ops-c.c new file mode 100644 index 000000000..d4269ad62 --- /dev/null +++ b/spa/plugins/audioconvert/gaps-ops-c.c @@ -0,0 +1,137 @@ +/* Spa */ +/* SPDX-FileCopyrightText: Copyright © 2025 Wim Taymans */ +/* SPDX-License-Identifier: MIT */ + +#include + +#include + +#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); +} diff --git a/spa/plugins/audioconvert/gaps-ops.c b/spa/plugins/audioconvert/gaps-ops.c new file mode 100644 index 000000000..90cbdef55 --- /dev/null +++ b/spa/plugins/audioconvert/gaps-ops.c @@ -0,0 +1,77 @@ +/* Spa */ +/* SPDX-FileCopyrightText: Copyright © 2022 Wim Taymans */ +/* SPDX-License-Identifier: MIT */ + +#include +#include +#include +#include + +#include +#include +#include + +#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; +} diff --git a/spa/plugins/audioconvert/gaps-ops.h b/spa/plugins/audioconvert/gaps-ops.h new file mode 100644 index 000000000..d49e59f17 --- /dev/null +++ b/spa/plugins/audioconvert/gaps-ops.h @@ -0,0 +1,60 @@ +/* Spa */ +/* SPDX-FileCopyrightText: Copyright © 2025 Wim Taymans */ +/* SPDX-License-Identifier: MIT */ + +#include +#include + +#include +#include + +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 diff --git a/spa/plugins/audioconvert/meson.build b/spa/plugins/audioconvert/meson.build index 71d5d56cd..00191829c 100644 --- a/spa/plugins/audioconvert/meson.build +++ b/spa/plugins/audioconvert/meson.build @@ -19,6 +19,7 @@ audioconvert_c = static_library('audioconvert_c', 'biquad.c', 'crossover.c', 'volume-ops-c.c', + 'gaps-ops-c.c', 'peaks-ops-c.c', 'resample-native-c.c', 'fmt-ops-c.c' ], @@ -158,6 +159,7 @@ resample_native_precomp_h = custom_target( audioconvert_lib = static_library('audioconvert', ['fmt-ops.c', 'channelmix-ops.c', + 'gaps-ops.c', 'peaks-ops.c', resample_native_precomp_h, 'resample-native.c', diff --git a/src/daemon/client.conf.in b/src/daemon/client.conf.in index d4dbba285..dc0a9364b 100644 --- a/src/daemon/client.conf.in +++ b/src/daemon/client.conf.in @@ -112,6 +112,7 @@ stream.properties = { #channelmix.stereo-widen = 0.0 #channelmix.hilbert-taps = 0 #dither.noise = 0 + #zeroramp.gap = 0 #zeroramp.duration = 0.005 } diff --git a/src/daemon/minimal.conf.in b/src/daemon/minimal.conf.in index 4a7120f08..73b53560e 100644 --- a/src/daemon/minimal.conf.in +++ b/src/daemon/minimal.conf.in @@ -361,6 +361,7 @@ context.objects = [ #channelmix.hilbert-taps = 0 #channelmix.disable = false #dither.noise = 0 + #zeroramp.gap = 0 #zeroramp.duration = 0.005 #node.param.Props = { # params = [ @@ -428,6 +429,7 @@ context.objects = [ #channelmix.hilbert-taps = 0 #channelmix.disable = false #dither.noise = 0 + #zeroramp.gap = 0 #zeroramp.duration = 0.005 #node.param.Props = { # params = [ diff --git a/src/daemon/pipewire-pulse.conf.in b/src/daemon/pipewire-pulse.conf.in index ced8c4146..5c6893d7f 100644 --- a/src/daemon/pipewire-pulse.conf.in +++ b/src/daemon/pipewire-pulse.conf.in @@ -110,6 +110,7 @@ stream.properties = { #channelmix.lfe-level = 0.5 #channelmix.hilbert-taps = 0 #dither.noise = 0 + #zeroramp.gap = 0 #zeroramp.duration = 0.005 }