From 54aba261d23410bf0e87b1027ce5e3f93638c472 Mon Sep 17 00:00:00 2001 From: Wim Taymans Date: Thu, 16 Apr 2026 12:08:53 +0200 Subject: [PATCH] tools: add pw-audioconvert Takes an input file, processes it with audioconvert and writes to an output file. Can be used to test all audioconvert features such as resample, channelmix, filter-graph, format conversion, dither, etc. Boilerplate written by Claude. --- doc/dox/programs/index.md | 1 + doc/dox/programs/pw-audioconvert.1.md | 62 +++ doc/meson.build | 1 + src/tools/meson.build | 8 + src/tools/pw-audioconvert.c | 672 ++++++++++++++++++++++++++ 5 files changed, 744 insertions(+) create mode 100644 doc/dox/programs/pw-audioconvert.1.md create mode 100644 src/tools/pw-audioconvert.c diff --git a/doc/dox/programs/index.md b/doc/dox/programs/index.md index 991ec7263..01ba07720 100644 --- a/doc/dox/programs/index.md +++ b/doc/dox/programs/index.md @@ -4,6 +4,7 @@ Manual pages: - \subpage page_man_pipewire_1 - \subpage page_man_pipewire-pulse_1 +- \subpage page_man_pw-audioconvert_1 - \subpage page_man_pw-cat_1 - \subpage page_man_pw-cli_1 - \subpage page_man_pw-config_1 diff --git a/doc/dox/programs/pw-audioconvert.1.md b/doc/dox/programs/pw-audioconvert.1.md new file mode 100644 index 000000000..e78e4c7ec --- /dev/null +++ b/doc/dox/programs/pw-audioconvert.1.md @@ -0,0 +1,62 @@ +\page page_man_pw-audioconvert_1 pw-audioconvert + +The PipeWire audioconvert utility + +# SYNOPSIS + +**pw-audioconvert** \[*OPTIONS*\] *INFILE* *OUTFILE* + +# DESCRIPTION + +Use the PipeWire audioconvert to convert input file to output file, +following the given options. + +This is useful only for doing audio conversion but also apply effects +on the audio using a filter-graph. + +It understands all audio file formats supported by `libsndfile` for input +and output. The filename extension is used to guess the output file +container and format with the WAV file format as the default. + +# OPTIONS + +\par -r RATE | \--rate=RATE +Output sample rate. Default the same as the input sample rate. + +\par -f FORMAT | \--format=FORMAT +Output sample format (s8 | s16 | s32 | f32 | f64). Default the same +as the input format. + +\par -b BLOCKSIZE | \--blocksize=BLOCKSIZE +Number of samples per iteration (default 4096) + +\par -P PROPERTIES | \--properties=PROPERTIES +Set extra stream properties as a JSON object. One can also use @filename to +read the JSON object with properties from filename. + +\par -c CHANNELS | \--channels=CHANNELS +The number of output channels, default the same as the input. + +\par \--channel-map=VALUE +The channelmap. Possible values include are either a predefined channel layout +such as **Mono**, **Stereo**, **2.1**, **Quad**, **2.2**, **5.1**, +or comma separated array of channel names such as **FL,FR**. + +\par -h +Show help. + +\par -v +Verbose operation. + +# EXAMPLES + +**pw-audioconvert** -r 48000 -f s32 in.wav out.wav + +# AUTHORS + +The PipeWire Developers <$(PACKAGE_BUGREPORT)>; +PipeWire is available from <$(PACKAGE_URL)> + +# SEE ALSO + +\ref page_man_pipewire_1 "pipewire(1)" diff --git a/doc/meson.build b/doc/meson.build index d014d227d..645b4b26a 100644 --- a/doc/meson.build +++ b/doc/meson.build @@ -100,6 +100,7 @@ manpage_docs = [ 'dox/config/libpipewire-modules.7.md', 'dox/programs/pipewire-pulse.1.md', 'dox/programs/pipewire.1.md', + 'dox/programs/pw-audioconvert.1.md', 'dox/programs/pw-cat.1.md', 'dox/programs/pw-cli.1.md', 'dox/programs/pw-config.1.md', diff --git a/src/tools/meson.build b/src/tools/meson.build index 8cc6b955d..a9a112aa0 100644 --- a/src/tools/meson.build +++ b/src/tools/meson.build @@ -95,6 +95,14 @@ if build_pw_cat summary({'Build pw-cat with FFmpeg integration': build_pw_cat_with_ffmpeg}, bool_yn: true, section: 'pw-cat/pw-play/pw-dump tool') endif +if sndfile_dep.found() + executable('pw-audioconvert', + 'pw-audioconvert.c', + install: true, + dependencies : [pipewire_dep, sndfile_dep, mathlib], + ) +endif + build_avb_virtual = get_option('avb-virtual').require( host_machine.system() == 'linux', error_message: 'AVB support is only available on Linux' diff --git a/src/tools/pw-audioconvert.c b/src/tools/pw-audioconvert.c new file mode 100644 index 000000000..8bc5a0de9 --- /dev/null +++ b/src/tools/pw-audioconvert.c @@ -0,0 +1,672 @@ +/* PipeWire - pw-filter-graph */ +/* SPDX-FileCopyrightText: Copyright © 2026 Wim Taymans */ +/* SPDX-License-Identifier: MIT */ + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#define MAX_SAMPLES 4096u + +enum { + OPT_CHANNELMAP = 1000, +}; + +struct data { + bool verbose; + int rate; + int format; + uint32_t blocksize; + int out_channels; + const char *channel_map; + struct pw_properties *props; + + const char *iname; + SF_INFO iinfo; + SNDFILE *ifile; + + const char *oname; + SF_INFO oinfo; + SNDFILE *ofile; + + struct pw_main_loop *loop; + struct pw_context *context; + + struct spa_handle *handle; + struct spa_node *node; +}; + +#define STR_FMTS "(s8|s16|s32|f32|f64)" + +#define OPTIONS "hvr:f:b:P:c:" +static const struct option long_options[] = { + { "help", no_argument, NULL, 'h' }, + { "verbose", no_argument, NULL, 'v' }, + + { "rate", required_argument, NULL, 'r' }, + { "format", required_argument, NULL, 'f' }, + { "blocksize", required_argument, NULL, 'b' }, + { "properties", required_argument, NULL, 'P' }, + { "channels", required_argument, NULL, 'c' }, + { "channel-map", required_argument, NULL, OPT_CHANNELMAP }, + + { NULL, 0, NULL, 0 } +}; + +static void show_usage(const char *name, bool is_error) +{ + FILE *fp; + + fp = is_error ? stderr : stdout; + + fprintf(fp, "%s [options] \n", name); + fprintf(fp, + " -h, --help Show this help\n" + " -v --verbose Be verbose\n" + "\n"); + fprintf(fp, + " -r --rate Output sample rate (default as input)\n" + " -f --format Output sample format %s (default as input)\n" + " -b --blocksize Number of samples per iteration (default %u)\n" + " -P --properties Set node properties (optional)\n" + " Use @filename to read from file\n" + " -c --channels Output channel count\n" + " --channel-map Output channel layout (e.g. \"stereo\", \"5.1\",\n" + " \"FL,FR,FC,LFE,SL,SR\")\n", + STR_FMTS, MAX_SAMPLES); + fprintf(fp, "\n"); +} + +static inline const char * +sf_fmt_to_str(int fmt) +{ + switch(fmt & SF_FORMAT_SUBMASK) { + case SF_FORMAT_PCM_S8: + return "s8"; + case SF_FORMAT_PCM_16: + return "s16"; + case SF_FORMAT_PCM_24: + return "s24"; + case SF_FORMAT_PCM_32: + return "s32"; + case SF_FORMAT_FLOAT: + return "f32"; + case SF_FORMAT_DOUBLE: + return "f64"; + default: + return "unknown"; + } +} + +static inline int +sf_str_to_fmt(const char *str) +{ + if (!str) + return -1; + if (spa_streq(str, "s8")) + return SF_FORMAT_PCM_S8; + if (spa_streq(str, "s16")) + return SF_FORMAT_PCM_16; + if (spa_streq(str, "s24")) + return SF_FORMAT_PCM_24; + if (spa_streq(str, "s32")) + return SF_FORMAT_PCM_32; + if (spa_streq(str, "f32")) + return SF_FORMAT_FLOAT; + if (spa_streq(str, "f64")) + return SF_FORMAT_DOUBLE; + return -1; +} + +static int parse_channelmap(const char *channel_map, struct spa_audio_layout_info *map) +{ + if (spa_audio_layout_info_parse_name(map, sizeof(*map), channel_map) >= 0) + return 0; + + spa_audio_parse_position_n(channel_map, strlen(channel_map), + map->position, SPA_N_ELEMENTS(map->position), &map->n_channels); + return map->n_channels > 0 ? 0 : -EINVAL; +} + +static int channelmap_default(struct spa_audio_layout_info *map, int n_channels) +{ + switch(n_channels) { + case 1: parse_channelmap("Mono", map); break; + case 2: parse_channelmap("Stereo", map); break; + case 3: parse_channelmap("2.1", map); break; + case 4: parse_channelmap("Quad", map); break; + case 5: parse_channelmap("5.0", map); break; + case 6: parse_channelmap("5.1", map); break; + case 7: parse_channelmap("7.0", map); break; + case 8: parse_channelmap("7.1", map); break; + default: n_channels = 0; break; + } + map->n_channels = n_channels; + return 0; +} + +static int open_input(struct data *d) +{ + d->ifile = sf_open(d->iname, SFM_READ, &d->iinfo); + if (d->ifile == NULL) { + fprintf(stderr, "error: failed to open input file \"%s\": %s\n", + d->iname, sf_strerror(NULL)); + return -EIO; + } + if (d->verbose) + fprintf(stdout, "input '%s': channels:%d rate:%d format:%s\n", + d->iname, d->iinfo.channels, d->iinfo.samplerate, + sf_fmt_to_str(d->iinfo.format)); + return 0; +} + +static int open_output(struct data *d, int channels) +{ + int i, count = 0, format = -1; + + d->oinfo.channels = channels; + d->oinfo.samplerate = d->rate > 0 ? d->rate : d->iinfo.samplerate; + d->oinfo.format = d->format > 0 ? d->format : (d->iinfo.format & SF_FORMAT_SUBMASK); + + /* try to guess the format from the extension */ + if (sf_command(NULL, SFC_GET_FORMAT_MAJOR_COUNT, &count, sizeof(int)) != 0) + count = 0; + + for (i = 0; i < count; i++) { + SF_FORMAT_INFO fi; + + spa_zero(fi); + fi.format = i; + if (sf_command(NULL, SFC_GET_FORMAT_MAJOR, &fi, sizeof(fi)) != 0) + continue; + + if (spa_strendswith(d->oname, fi.extension)) { + format = fi.format; + break; + } + } + if (format == -1) + format = d->iinfo.format & ~SF_FORMAT_SUBMASK; + if (format == SF_FORMAT_WAV && d->oinfo.channels > 2) + format = SF_FORMAT_WAVEX; + + d->oinfo.format |= format; + + d->ofile = sf_open(d->oname, SFM_WRITE, &d->oinfo); + if (d->ofile == NULL) { + fprintf(stderr, "error: failed to open output file \"%s\": %s\n", + d->oname, sf_strerror(NULL)); + return -EIO; + } + sf_command(d->ofile, SFC_SET_CLIPPING, NULL, 1); + + if (d->verbose) + fprintf(stdout, "output '%s': channels:%d rate:%d format:%s\n", + d->oname, d->oinfo.channels, d->oinfo.samplerate, + sf_fmt_to_str(d->oinfo.format)); + return 0; +} + +static int setup_convert_direction(struct spa_node *node, + enum spa_direction direction, struct spa_audio_info_raw *info) +{ + struct spa_pod_builder b = { 0 }; + uint8_t buffer[1024]; + struct spa_pod *param, *format; + int res; + + /* set port config to convert mode */ + spa_pod_builder_init(&b, buffer, sizeof(buffer)); + param = spa_pod_builder_add_object(&b, + SPA_TYPE_OBJECT_ParamPortConfig, SPA_PARAM_PortConfig, + SPA_PARAM_PORT_CONFIG_direction, SPA_POD_Id(direction), + SPA_PARAM_PORT_CONFIG_mode, SPA_POD_Id(SPA_PARAM_PORT_CONFIG_MODE_convert)); + + res = spa_node_set_param(node, SPA_PARAM_PortConfig, 0, param); + if (res < 0) + return res; + + /* set format on port 0 */ + spa_pod_builder_init(&b, buffer, sizeof(buffer)); + format = spa_format_audio_raw_build(&b, SPA_PARAM_Format, info); + + res = spa_node_port_set_param(node, direction, 0, + SPA_PARAM_Format, 0, format); + return res; +} + +static int do_filter(struct data *d) +{ + void *iface; + struct spa_node *node; + int in_channels = d->iinfo.channels; + int out_channels; + int in_rate = d->iinfo.samplerate; + int out_rate = d->rate > 0 ? d->rate : in_rate; + uint32_t blocksize = d->blocksize > 0 ? d->blocksize : MAX_SAMPLES; + int res; + struct spa_audio_info_raw in_info, out_info; + struct spa_audio_layout_info in_layout, out_layout; + + /* determine output channels */ + out_channels = d->out_channels > 0 ? d->out_channels : in_channels; + + /* set up input channel layout */ + spa_zero(in_layout); + channelmap_default(&in_layout, in_channels); + + /* set up output channel layout */ + spa_zero(out_layout); + if (d->channel_map != NULL) { + if (parse_channelmap(d->channel_map, &out_layout) < 0) { + fprintf(stderr, "error: can't parse channel-map '%s'\n", + d->channel_map); + return -EINVAL; + } + if (d->out_channels > 0 && + out_layout.n_channels != (uint32_t)d->out_channels) { + fprintf(stderr, "error: channel-map has %u channels " + "but -c specifies %d\n", + out_layout.n_channels, d->out_channels); + return -EINVAL; + } + out_channels = out_layout.n_channels; + } else { + channelmap_default(&out_layout, out_channels); + } + + /* open the output file */ + res = open_output(d, out_channels); + if (res < 0) + return res; + + /* calculate output buffer size accounting for resampling */ + uint32_t out_blocksize = (uint32_t)((uint64_t)blocksize * + out_rate / in_rate) + 64; + + uint32_t quant_limit = SPA_ROUND_UP_N(SPA_MAX(out_blocksize, blocksize), 4096); + + pw_properties_setf(d->props, "clock.quantum-limit", "%u", quant_limit); + pw_properties_set(d->props, "convert.direction", "output"); + + d->handle = pw_context_load_spa_handle(d->context, + SPA_NAME_AUDIO_CONVERT, &d->props->dict); + + if (d->handle == NULL) { + fprintf(stderr, "can't load %s: %m\n", SPA_NAME_AUDIO_CONVERT); + return -errno; + } + + res = spa_handle_get_interface(d->handle, + SPA_TYPE_INTERFACE_Node, &iface); + if (res < 0 || iface == NULL) { + fprintf(stderr, "can't get Node interface: %s\n", + spa_strerror(res)); + return res; + } + node = d->node = iface; + + /* build input format: interleaved F32 */ + spa_zero(in_info); + in_info.format = SPA_AUDIO_FORMAT_F32; + in_info.rate = in_rate; + in_info.channels = in_channels; + for (uint32_t i = 0; i < in_layout.n_channels && + i < SPA_AUDIO_MAX_CHANNELS; i++) + in_info.position[i] = in_layout.position[i]; + + /* build output format: interleaved F32 */ + spa_zero(out_info); + out_info.format = SPA_AUDIO_FORMAT_F32; + out_info.rate = out_rate; + out_info.channels = out_channels; + for (uint32_t i = 0; i < out_layout.n_channels && + i < SPA_AUDIO_MAX_CHANNELS; i++) + out_info.position[i] = out_layout.position[i]; + + /* set up convert directions */ + res = setup_convert_direction(node, SPA_DIRECTION_INPUT, &in_info); + if (res < 0) { + fprintf(stderr, "can't set input format: %s\n", + spa_strerror(res)); + return res; + } + res = setup_convert_direction(node, SPA_DIRECTION_OUTPUT, &out_info); + if (res < 0) { + fprintf(stderr, "can't set output format: %s\n", + spa_strerror(res)); + return res; + } + + /* send Start command */ + { + struct spa_command cmd = + SPA_NODE_COMMAND_INIT(SPA_NODE_COMMAND_Start); + res = spa_node_send_command(node, &cmd); + if (res < 0) { + fprintf(stderr, "can't start node: %s\n", + spa_strerror(res)); + return res; + } + } + + if (d->verbose) + fprintf(stdout, "convert: in:%dch@%dHz -> out:%dch@%dHz " + "blocksize:%u\n", + in_channels, in_rate, + out_channels, out_rate, blocksize); + + /* process audio */ + { + float ibuf[blocksize * in_channels] SPA_ALIGNED(64); + float obuf[out_blocksize * out_channels] SPA_ALIGNED(64); + + struct spa_chunk in_chunk, out_chunk; + struct spa_data in_sdata, out_sdata; + struct spa_buffer in_buffer, out_buffer; + struct spa_buffer *in_buffers[1] = { &in_buffer }; + struct spa_buffer *out_buffers[1] = { &out_buffer }; + struct spa_io_buffers in_io, out_io; + size_t read_total = 0, written_total = 0; + + /* setup input buffer */ + spa_zero(in_chunk); + spa_zero(in_sdata); + in_sdata.type = SPA_DATA_MemPtr; + in_sdata.flags = SPA_DATA_FLAG_READABLE; + in_sdata.fd = -1; + in_sdata.maxsize = sizeof(ibuf); + in_sdata.data = ibuf; + in_sdata.chunk = &in_chunk; + + spa_zero(in_buffer); + in_buffer.datas = &in_sdata; + in_buffer.n_datas = 1; + + res = spa_node_port_use_buffers(node, + SPA_DIRECTION_INPUT, 0, 0, + in_buffers, 1); + if (res < 0) { + fprintf(stderr, "can't set input buffers: %s\n", + spa_strerror(res)); + goto stop; + } + + /* setup output buffer */ + spa_zero(out_chunk); + spa_zero(out_sdata); + out_sdata.type = SPA_DATA_MemPtr; + out_sdata.flags = SPA_DATA_FLAG_READWRITE; + out_sdata.fd = -1; + out_sdata.maxsize = sizeof(obuf); + out_sdata.data = obuf; + out_sdata.chunk = &out_chunk; + + spa_zero(out_buffer); + out_buffer.datas = &out_sdata; + out_buffer.n_datas = 1; + + res = spa_node_port_use_buffers(node, + SPA_DIRECTION_OUTPUT, 0, 0, + out_buffers, 1); + if (res < 0) { + fprintf(stderr, "can't set output buffers: %s\n", + spa_strerror(res)); + goto stop; + } + + /* setup IO */ + res = spa_node_port_set_io(node, SPA_DIRECTION_INPUT, 0, + SPA_IO_Buffers, &in_io, sizeof(in_io)); + if (res < 0) { + fprintf(stderr, "can't set input IO: %s\n", + spa_strerror(res)); + goto stop; + } + res = spa_node_port_set_io(node, SPA_DIRECTION_OUTPUT, 0, + SPA_IO_Buffers, &out_io, sizeof(out_io)); + if (res < 0) { + fprintf(stderr, "can't set output IO: %s\n", + spa_strerror(res)); + goto stop; + } + + /* process loop */ + while (true) { + sf_count_t n_read; + + n_read = sf_readf_float(d->ifile, ibuf, blocksize); + + read_total += n_read; + + in_chunk.offset = 0; + in_chunk.size = n_read * in_channels * sizeof(float); + in_chunk.stride = 0; + + out_chunk.offset = 0; + out_chunk.size = 0; + out_chunk.stride = 0; + + in_io.status = n_read > 0 ? SPA_STATUS_HAVE_DATA : SPA_STATUS_DRAINED; + in_io.buffer_id = 0; + out_io.status = SPA_STATUS_NEED_DATA; + out_io.buffer_id = 0; + + res = spa_node_process(node); + if (res < 0) { + fprintf(stderr, "process error: %s\n", + spa_strerror(res)); + break; + } + + if (out_io.status == SPA_STATUS_HAVE_DATA && + out_io.buffer_id == 0) { + uint32_t out_frames = out_chunk.size / + (out_channels * sizeof(float)); + if (out_frames > 0) + written_total += sf_writef_float( + d->ofile, obuf, + out_frames); + } + if (n_read == 0) + break; + } + if (d->verbose) + fprintf(stdout, "read %zu samples, wrote %zu samples\n", + read_total, written_total); + } + + res = 0; + +stop: + { + struct spa_command cmd = + SPA_NODE_COMMAND_INIT(SPA_NODE_COMMAND_Suspend); + spa_node_send_command(node, &cmd); + } + return res; +} + +static char *read_file(const char *path) +{ + FILE *f; + long size; + char *buf; + + f = fopen(path, "r"); + if (f == NULL) + return NULL; + + fseek(f, 0, SEEK_END); + size = ftell(f); + fseek(f, 0, SEEK_SET); + + buf = malloc(size + 1); + if (buf == NULL) { + fclose(f); + return NULL; + } + + if ((long)fread(buf, 1, size, f) != size) { + free(buf); + fclose(f); + return NULL; + } + buf[size] = '\0'; + fclose(f); + return buf; +} + +int main(int argc, char *argv[]) +{ + int c; + int longopt_index = 0, ret; + struct data data; + struct spa_error_location loc; + char *file_content = NULL, *str; + + spa_zero(data); + data.props = pw_properties_new(NULL, NULL); + + pw_init(&argc, &argv); + + while ((c = getopt_long(argc, argv, OPTIONS, long_options, &longopt_index)) != -1) { + switch (c) { + case 'h': + show_usage(argv[0], false); + ret = EXIT_SUCCESS; + goto done; + case 'v': + data.verbose = true; + break; + case 'r': + ret = atoi(optarg); + if (ret <= 0) { + fprintf(stderr, "error: bad rate %s\n", optarg); + goto error_usage; + } + data.rate = ret; + break; + case 'f': + ret = sf_str_to_fmt(optarg); + if (ret < 0) { + fprintf(stderr, "error: bad format %s\n", optarg); + goto error_usage; + } + data.format = ret; + break; + case 'b': + ret = atoi(optarg); + if (ret <= 0) { + fprintf(stderr, "error: bad blocksize %s\n", optarg); + goto error_usage; + } + data.blocksize = ret; + break; + case 'P': + if (optarg[0] == '@') { + file_content = read_file(optarg + 1); + if (file_content == NULL) { + fprintf(stderr, "error: can't read graph file '%s': %m\n", + optarg + 1); + ret = EXIT_FAILURE; + goto done; + } + str = file_content; + } else { + str = optarg; + } + if (pw_properties_update_string_checked(data.props, str, strlen(str), &loc) < 0) { + spa_debug_file_error_location(stderr, &loc, + "error: syntax error in --properties: %s", + loc.reason); + goto error_usage; + } + break; + case 'c': + ret = atoi(optarg); + if (ret <= 0) { + fprintf(stderr, "error: bad channel count %s\n", optarg); + goto error_usage; + } + data.out_channels = ret; + break; + case OPT_CHANNELMAP: + data.channel_map = optarg; + break; + default: + fprintf(stderr, "error: unknown option '%c'\n", c); + goto error_usage; + } + } + if (optind + 1 >= argc) { + fprintf(stderr, "error: filename arguments missing\n"); + goto error_usage; + } + data.iname = argv[optind++]; + data.oname = argv[optind++]; + + data.loop = pw_main_loop_new(NULL); + if (data.loop == NULL) { + fprintf(stderr, "error: can't create main loop: %m\n"); + ret = EXIT_FAILURE; + goto done; + } + + data.context = pw_context_new(pw_main_loop_get_loop(data.loop), NULL, 0); + if (data.context == NULL) { + fprintf(stderr, "error: can't create context: %m\n"); + ret = EXIT_FAILURE; + goto done; + } + + if (open_input(&data) < 0) { + ret = EXIT_FAILURE; + goto done; + } + + ret = do_filter(&data); + +done: + if (data.ifile) + sf_close(data.ifile); + if (data.ofile) + sf_close(data.ofile); + if (data.props) + pw_properties_free(data.props); + if (data.handle) + pw_unload_spa_handle(data.handle); + if (data.context) + pw_context_destroy(data.context); + if (data.loop) + pw_main_loop_destroy(data.loop); + free(file_content); + pw_deinit(); + + return ret < 0 ? EXIT_FAILURE : EXIT_SUCCESS; + +error_usage: + show_usage(argv[0], true); + ret = EXIT_FAILURE; + goto done; +}