From f5259828b63d17dbe8f1b33d431dfa4df80c65a2 Mon Sep 17 00:00:00 2001 From: "Christian F.K. Schaller" Date: Tue, 7 Apr 2026 17:39:22 -0400 Subject: [PATCH] tools: add pw-avb-virtual for virtual AVB graph nodes Add a standalone tool that creates virtual AVB talker/listener endpoints visible in the PipeWire graph (e.g. Helvum). Uses the loopback transport so no AVB hardware or network access is needed. The sink node consumes audio silently, the source produces silence. Supports --milan flag for Milan v1.2 mode and --name for custom node name prefix. Co-Authored-By: Claude Opus 4.6 --- src/tools/meson.build | 44 ++++++ src/tools/pw-avb-virtual.c | 283 +++++++++++++++++++++++++++++++++++++ 2 files changed, 327 insertions(+) create mode 100644 src/tools/pw-avb-virtual.c diff --git a/src/tools/meson.build b/src/tools/meson.build index 8147906fb..c082f169f 100644 --- a/src/tools/meson.build +++ b/src/tools/meson.build @@ -95,6 +95,50 @@ 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 +build_avb_virtual = get_option('avb').require( + host_machine.system() == 'linux', + error_message: 'AVB support is only available on Linux' +).allowed() + +if build_avb_virtual + avb_tool_inc = include_directories('../modules') + avb_tool_sources = [ + 'pw-avb-virtual.c', + '../modules/module-avb/avb.c', + '../modules/module-avb/adp.c', + '../modules/module-avb/acmp.c', + '../modules/module-avb/aecp.c', + '../modules/module-avb/aecp-aem.c', + '../modules/module-avb/aecp-aem-cmds-resps/cmd-available.c', + '../modules/module-avb/aecp-aem-cmds-resps/cmd-get-set-control.c', + '../modules/module-avb/aecp-aem-cmds-resps/cmd-get-set-name.c', + '../modules/module-avb/aecp-aem-cmds-resps/cmd-get-set-clock-source.c', + '../modules/module-avb/aecp-aem-cmds-resps/cmd-get-set-sampling-rate.c', + '../modules/module-avb/aecp-aem-cmds-resps/cmd-deregister-unsolicited-notifications.c', + '../modules/module-avb/aecp-aem-cmds-resps/cmd-register-unsolicited-notifications.c', + '../modules/module-avb/aecp-aem-cmds-resps/cmd-get-set-stream-format.c', + '../modules/module-avb/aecp-aem-cmds-resps/cmd-lock-entity.c', + '../modules/module-avb/aecp-aem-cmds-resps/cmd-get-set-configuration.c', + '../modules/module-avb/aecp-aem-cmds-resps/reply-unsol-helpers.c', + '../modules/module-avb/es-builder.c', + '../modules/module-avb/avdecc.c', + '../modules/module-avb/descriptors.c', + '../modules/module-avb/maap.c', + '../modules/module-avb/mmrp.c', + '../modules/module-avb/mrp.c', + '../modules/module-avb/msrp.c', + '../modules/module-avb/mvrp.c', + '../modules/module-avb/srp.c', + '../modules/module-avb/stream.c', + ] + executable('pw-avb-virtual', + avb_tool_sources, + install: true, + include_directories: [configinc, avb_tool_inc], + dependencies: [mathlib, dl_lib, rt_lib, pipewire_dep], + ) +endif + if dbus_dep.found() executable('pw-reserve', 'reserve.h', diff --git a/src/tools/pw-avb-virtual.c b/src/tools/pw-avb-virtual.c new file mode 100644 index 000000000..6b123949e --- /dev/null +++ b/src/tools/pw-avb-virtual.c @@ -0,0 +1,283 @@ +/* PipeWire */ +/* SPDX-FileCopyrightText: Copyright © 2026 PipeWire contributors */ +/* SPDX-License-Identifier: MIT */ + +/** + * pw-avb-virtual: Create virtual AVB audio devices in the PipeWire graph. + * + * This tool creates virtual AVB talker/listener endpoints that appear + * as Audio/Source and Audio/Sink nodes in the PipeWire graph (visible + * in tools like Helvum). No AVB hardware or network access is needed — + * the loopback transport is used for all protocol and stream operations. + * + * The sink node consumes audio silently (data goes nowhere). + * The source node produces silence (no network data to receive). + */ + +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include "module-avb/internal.h" +#include "module-avb/stream.h" +#include "module-avb/avb-transport-loopback.h" +#include "module-avb/descriptors.h" +#include "module-avb/mrp.h" +#include "module-avb/adp.h" +#include "module-avb/acmp.h" +#include "module-avb/aecp.h" +#include "module-avb/maap.h" +#include "module-avb/mmrp.h" +#include "module-avb/msrp.h" +#include "module-avb/mvrp.h" + +struct data { + struct pw_main_loop *loop; + struct pw_context *context; + struct pw_core *core; + struct spa_hook core_listener; + + struct impl impl; + struct server *server; + + const char *opt_remote; + const char *opt_name; + bool opt_milan; +}; + +static void do_quit(void *data, int signal_number) +{ + struct data *d = data; + pw_main_loop_quit(d->loop); +} + +static void on_core_error(void *data, uint32_t id, int seq, + int res, const char *message) +{ + struct data *d = data; + + pw_log_error("error id:%u seq:%d res:%d (%s): %s", + id, seq, res, spa_strerror(res), message); + + if (id == PW_ID_CORE && res == -EPIPE) + pw_main_loop_quit(d->loop); +} + +static const struct pw_core_events core_events = { + PW_VERSION_CORE_EVENTS, + .error = on_core_error, +}; + +static struct server *create_virtual_server(struct data *data) +{ + struct impl *impl = &data->impl; + struct server *server; + struct stream *stream; + uint16_t idx; + char name_buf[256]; + + server = calloc(1, sizeof(*server)); + if (server == NULL) + return NULL; + + server->impl = impl; + server->ifname = strdup("virtual0"); + server->avb_mode = data->opt_milan ? AVB_MODE_MILAN_V12 : AVB_MODE_LEGACY; + server->transport = &avb_transport_loopback; + + spa_list_append(&impl->servers, &server->link); + spa_hook_list_init(&server->listener_list); + spa_list_init(&server->descriptors); + spa_list_init(&server->streams); + + if (server->transport->setup(server) < 0) + goto error; + + server->mrp = avb_mrp_new(server); + if (server->mrp == NULL) + goto error; + + avb_aecp_register(server); + server->maap = avb_maap_register(server); + server->mmrp = avb_mmrp_register(server); + server->msrp = avb_msrp_register(server); + server->mvrp = avb_mvrp_register(server); + avb_adp_register(server); + avb_acmp_register(server); + + server->domain_attr = avb_msrp_attribute_new(server->msrp, + AVB_MSRP_ATTRIBUTE_TYPE_DOMAIN); + server->domain_attr->attr.domain.sr_class_id = AVB_MSRP_CLASS_ID_DEFAULT; + server->domain_attr->attr.domain.sr_class_priority = AVB_MSRP_PRIORITY_DEFAULT; + server->domain_attr->attr.domain.sr_class_vid = htons(AVB_DEFAULT_VLAN); + + avb_maap_reserve(server->maap, 1); + + init_descriptors(server); + + /* Update stream properties and activate */ + idx = 0; + spa_list_for_each(stream, &server->streams, link) { + if (stream->direction == SPA_DIRECTION_INPUT) { + snprintf(name_buf, sizeof(name_buf), "%s.source.%u", + data->opt_name, idx); + pw_stream_update_properties(stream->stream, + &SPA_DICT_INIT_ARRAY(((struct spa_dict_item[]) { + { PW_KEY_NODE_NAME, name_buf }, + { PW_KEY_NODE_DESCRIPTION, "AVB Virtual Source" }, + { PW_KEY_NODE_VIRTUAL, "true" }, + }))); + } else { + snprintf(name_buf, sizeof(name_buf), "%s.sink.%u", + data->opt_name, idx); + pw_stream_update_properties(stream->stream, + &SPA_DICT_INIT_ARRAY(((struct spa_dict_item[]) { + { PW_KEY_NODE_NAME, name_buf }, + { PW_KEY_NODE_DESCRIPTION, "AVB Virtual Sink" }, + { PW_KEY_NODE_VIRTUAL, "true" }, + }))); + } + + if (stream_activate_virtual(stream, idx) < 0) + pw_log_warn("failed to activate stream %u", idx); + + idx++; + } + + return server; + +error: + spa_list_remove(&server->link); + free(server->ifname); + free(server); + return NULL; +} + +static void show_help(const char *name) +{ + printf("%s [options]\n" + " -h, --help Show this help\n" + " --version Show version\n" + " -r, --remote NAME Remote daemon name\n" + " -n, --name PREFIX Node name prefix (default: avb-virtual)\n" + " -m, --milan Use Milan v1.2 mode (default: legacy)\n", + name); +} + +int main(int argc, char *argv[]) +{ + struct data data = { 0 }; + struct pw_loop *l; + int res = -1; + static const struct option long_options[] = { + { "help", no_argument, NULL, 'h' }, + { "version", no_argument, NULL, 'V' }, + { "remote", required_argument, NULL, 'r' }, + { "name", required_argument, NULL, 'n' }, + { "milan", no_argument, NULL, 'm' }, + { NULL, 0, NULL, 0 } + }; + int c; + + setlocale(LC_ALL, ""); + pw_init(&argc, &argv); + + data.opt_name = "avb-virtual"; + + while ((c = getopt_long(argc, argv, "hVr:n:m", long_options, NULL)) != -1) { + switch (c) { + case 'h': + show_help(argv[0]); + return 0; + case 'V': + printf("%s\n" + "Compiled with libpipewire %s\n" + "Linked with libpipewire %s\n", + argv[0], + pw_get_headers_version(), + pw_get_library_version()); + return 0; + case 'r': + data.opt_remote = optarg; + break; + case 'n': + data.opt_name = optarg; + break; + case 'm': + data.opt_milan = true; + break; + default: + show_help(argv[0]); + return -1; + } + } + + data.loop = pw_main_loop_new(NULL); + if (data.loop == NULL) { + fprintf(stderr, "can't create main loop: %m\n"); + goto exit; + } + + l = pw_main_loop_get_loop(data.loop); + pw_loop_add_signal(l, SIGINT, do_quit, &data); + pw_loop_add_signal(l, SIGTERM, do_quit, &data); + + data.context = pw_context_new(l, NULL, 0); + if (data.context == NULL) { + fprintf(stderr, "can't create context: %m\n"); + goto exit; + } + + data.core = pw_context_connect(data.context, + pw_properties_new( + PW_KEY_REMOTE_NAME, data.opt_remote, + NULL), + 0); + if (data.core == NULL) { + fprintf(stderr, "can't connect to PipeWire: %m\n"); + goto exit; + } + + pw_core_add_listener(data.core, &data.core_listener, + &core_events, &data); + + /* Initialize the AVB impl */ + data.impl.loop = l; + data.impl.timer_queue = pw_context_get_timer_queue(data.context); + data.impl.context = data.context; + data.impl.core = data.core; + spa_list_init(&data.impl.servers); + + /* Create the virtual AVB server with streams */ + data.server = create_virtual_server(&data); + if (data.server == NULL) { + fprintf(stderr, "can't create virtual AVB server: %m\n"); + goto exit; + } + + fprintf(stdout, "Virtual AVB device running (%s mode). Press Ctrl-C to stop.\n", + data.opt_milan ? "Milan v1.2" : "legacy"); + + pw_main_loop_run(data.loop); + + res = 0; +exit: + if (data.server) + avdecc_server_free(data.server); + if (data.core) + pw_core_disconnect(data.core); + if (data.context) + pw_context_destroy(data.context); + if (data.loop) + pw_main_loop_destroy(data.loop); + pw_deinit(); + + return res; +}