From e06fe8e344780f15adb62909e0234549805e202c Mon Sep 17 00:00:00 2001 From: Naveen Albert Date: Mon, 15 Aug 2022 20:04:38 +0000 Subject: [PATCH] app_broadcast: Add Broadcast application Adds a new application, Broadcast, which can be used for one-to-many transmission and many-to-one reception of channel audio in Asterisk. This is similar to ChanSpy, except it is designed for multiple channel targets instead of a single one. This can make certain kinds of audio manipulation more efficient and streamlined. New kinds of audio injection impossible with ChanSpy are also made possible. ASTERISK-30180 #close Change-Id: I7ba72f765dbab9b58deeae028baca3f4f8377726 --- apps/app_broadcast.c | 619 ++++++++++++++++++++++++++ doc/CHANGES-staging/app_broadcast.txt | 4 + 2 files changed, 623 insertions(+) create mode 100644 apps/app_broadcast.c create mode 100644 doc/CHANGES-staging/app_broadcast.txt diff --git a/apps/app_broadcast.c b/apps/app_broadcast.c new file mode 100644 index 0000000000..de4b81db31 --- /dev/null +++ b/apps/app_broadcast.c @@ -0,0 +1,619 @@ +/* + * Asterisk -- An open source telephony toolkit. + * + * Copyright (C) 2022, Naveen Albert + * + * See http://www.asterisk.org for more information about + * the Asterisk project. Please do not directly contact + * any of the maintainers of this project for assistance; + * the project provides a web site, mailing lists and IRC + * channels for your use. + * + * This program is free software, distributed under the terms of + * the GNU General Public License Version 2. See the LICENSE file + * at the top of the source tree. + */ + +/*! \file + * + * \brief Channel audio broadcasting + * + * \author Naveen Albert + * + * \ingroup applications + */ + +/*** MODULEINFO + extended + ***/ + +#include "asterisk.h" + +#include +#include + +#include "asterisk/channel.h" +#include "asterisk/audiohook.h" +#include "asterisk/app.h" +#include "asterisk/utils.h" +#include "asterisk/pbx.h" +#include "asterisk/module.h" +#include "asterisk/lock.h" +#include "asterisk/options.h" +#include "asterisk/autochan.h" +#include "asterisk/format_cache.h" +#include "asterisk/cli.h" /* use ESS macro */ + +/*** DOCUMENTATION + + + Transmit or receive audio to or from multiple channels simultaneously + + + + + + + + + + + + + + List of channels for broadcast targets. + Channel names must be the full channel names, not merely device names. + Broadcasting will continue until the broadcasting channel hangs up or all target channels have hung up. + + + + This application can be used to broadcast audio to multiple channels at once. + Any audio received on this channel will be transmitted to all of the specified channels and, optionally, their bridged peers. + It can also be used to aggregate audio from multiple channels at once. + Any audio on any of the specified channels, and optionally their bridged peers, will be transmitted to this channel. + Execution of the application continues until either the broadcasting channel hangs up + or all specified channels have hung up. + This application is used for one-to-many and many-to-one audio applications where + bridge mixing cannot be done synchronously on all the involved channels. + This is primarily useful for injecting the same audio stream into multiple channels at once, + or doing the reverse, combining the audio from multiple channels into a single stream. + This contrasts with using a separate injection channel for each target channel and/or + using a conference bridge. + The channel running the Broadcast application must do so synchronously. The specified channels, + however, may be doing other things. + + same => n,Broadcast(wb,DAHDI/1,DAHDI/3,PJSIP/doorphone) + + + same => n,Broadcast(w,DAHDI/1,DAHDI/3,PJSIP/doorphone) + + + same => n,Broadcast(s,DAHDI/1,DAHDI/3,PJSIP/doorphone) + + + same => n,Broadcast(so,DAHDI/1,DAHDI/3,PJSIP/doorphone) + + + same => n,Broadcast(wbso,DAHDI/1,DAHDI/3,PJSIP/doorphone) + + Note that in the last example above, this is NOT the same as a conference bridge. + The specified channels are not audible to each other, only to the channel running the + Broadcast application. The two-way audio is only between the broadcasting channel and + each of the specified channels, individually. + + + ChanSpy + + + ***/ + +static const char app_broadcast[] = "Broadcast"; + +enum { + OPTION_READONLY = (1 << 0), /* Don't mix the two channels */ + OPTION_BARGE = (1 << 1), /* Barge mode (whisper to both channels) */ + OPTION_LONG_QUEUE = (1 << 2), /* Allow usage of a long queue to store audio frames. */ + OPTION_WHISPER = (1 << 3), + OPTION_SPY = (1 << 4), + OPTION_REVERSE_FEED = (1 << 5), + OPTION_ANSWER_WARN = (1 << 6), /* Internal flag, not set by user */ +}; + +AST_APP_OPTIONS(spy_opts, { + AST_APP_OPTION('b', OPTION_BARGE), + AST_APP_OPTION('l', OPTION_LONG_QUEUE), + AST_APP_OPTION('o', OPTION_READONLY), + AST_APP_OPTION('r', OPTION_REVERSE_FEED), + AST_APP_OPTION('s', OPTION_SPY), + AST_APP_OPTION('w', OPTION_WHISPER), +}); + +struct multi_autochan { + char *name; + struct ast_autochan *autochan; + struct ast_autochan *bridge_autochan; + struct ast_audiohook whisper_audiohook; + struct ast_audiohook bridge_whisper_audiohook; + struct ast_audiohook spy_audiohook; + unsigned int connected:1; + unsigned int bridge_connected:1; + unsigned int spying:1; + AST_LIST_ENTRY(multi_autochan) entry; /*!< Next record */ +}; + +AST_RWLIST_HEAD(multi_autochan_list, multi_autochan); + +struct multi_spy { + struct multi_autochan_list *chanlist; + unsigned int readonly:1; +}; + +static void *spy_alloc(struct ast_channel *chan, void *data) +{ + return data; /* just store the data pointer in the channel structure */ +} + +static void spy_release(struct ast_channel *chan, void *data) +{ + return; /* nothing to do */ +} + +static int spy_generate(struct ast_channel *chan, void *data, int len, int samples) +{ + struct multi_spy *multispy = data; + struct multi_autochan_list *chanlist = multispy->chanlist; + struct multi_autochan *mac; + struct ast_frame *f; + short *data1, *data2; + int res, i; + + /* All the frames we get are slin, so they will all have the same number of samples. */ + static const int num_samples = 160; + short combine_buf[num_samples]; + struct ast_frame wf = { + .frametype = AST_FRAME_VOICE, + .offset = 0, + .subclass.format = ast_format_slin, + .datalen = num_samples * 2, + .samples = num_samples, + .src = __FUNCTION__, + }; + + memset(&combine_buf, 0, sizeof(combine_buf)); + wf.data.ptr = combine_buf; + + AST_RWLIST_WRLOCK(chanlist); + AST_RWLIST_TRAVERSE_SAFE_BEGIN(chanlist, mac, entry) { + ast_audiohook_lock(&mac->spy_audiohook); + if (mac->spy_audiohook.status != AST_AUDIOHOOK_STATUS_RUNNING) { + ast_audiohook_unlock(&mac->spy_audiohook); /* Channel is already gone more than likely, the broadcasting channel will clean this up. */ + continue; + } + + if (multispy->readonly) { /* Option 'o' was set, so don't mix channel audio */ + f = ast_audiohook_read_frame(&mac->spy_audiohook, samples, AST_AUDIOHOOK_DIRECTION_READ, ast_format_slin); + } else { + f = ast_audiohook_read_frame(&mac->spy_audiohook, samples, AST_AUDIOHOOK_DIRECTION_BOTH, ast_format_slin); + } + ast_audiohook_unlock(&mac->spy_audiohook); + + if (!f) { + continue; /* No frame? No problem. */ + } + + /* Mix the samples. */ + for (i = 0, data1 = combine_buf, data2 = f->data.ptr; i < num_samples; i++, data1++, data2++) { + ast_slinear_saturated_add(data1, data2); + } + ast_frfree(f); + } + AST_RWLIST_TRAVERSE_SAFE_END; + AST_RWLIST_UNLOCK(chanlist); + + res = ast_write(chan, &wf); + ast_frfree(&wf); + + return res; +} + +static struct ast_generator spygen = { + .alloc = spy_alloc, + .release = spy_release, + .generate = spy_generate, +}; + +static int start_spying(struct ast_autochan *autochan, const char *spychan_name, struct ast_audiohook *audiohook, struct ast_flags *flags) +{ + int res; + + ast_autochan_channel_lock(autochan); + ast_debug(1, "Attaching spy channel %s to %s\n", spychan_name, ast_channel_name(autochan->chan)); + + if (ast_test_flag(flags, OPTION_READONLY)) { + ast_set_flag(audiohook, AST_AUDIOHOOK_MUTE_WRITE); + } else { + ast_set_flag(audiohook, AST_AUDIOHOOK_TRIGGER_SYNC); + } + if (ast_test_flag(flags, OPTION_LONG_QUEUE)) { + ast_debug(2, "Using a long queue to store audio frames in spy audiohook\n"); + } else { + ast_set_flag(audiohook, AST_AUDIOHOOK_SMALL_QUEUE); + } + res = ast_audiohook_attach(autochan->chan, audiohook); + ast_autochan_channel_unlock(autochan); + return res; +} + +static int attach_barge(struct ast_autochan *spyee_autochan, struct ast_autochan **spyee_bridge_autochan, + struct ast_audiohook *bridge_whisper_audiohook, const char *spyer_name, const char *name, struct ast_flags *flags) +{ + int retval = 0; + struct ast_autochan *internal_bridge_autochan; + struct ast_channel *spyee_chan; + RAII_VAR(struct ast_channel *, bridged, NULL, ast_channel_cleanup); + + ast_autochan_channel_lock(spyee_autochan); + spyee_chan = ast_channel_ref(spyee_autochan->chan); + ast_autochan_channel_unlock(spyee_autochan); + + /* Note that ast_channel_bridge_peer only returns non-NULL for 2-party bridges, not n-party bridges (e.g. ConfBridge) */ + bridged = ast_channel_bridge_peer(spyee_chan); + ast_channel_unref(spyee_chan); + if (!bridged) { + ast_debug(9, "Channel %s is not yet bridged, unable to setup barge\n", ast_channel_name(spyee_chan)); + /* If we're bridged, but it's not a 2-party bridge, then we probably should have used OPTION_REVERSE_FEED. */ + if (ast_test_flag(flags, OPTION_ANSWER_WARN) && ast_channel_is_bridged(spyee_chan)) { + ast_clear_flag(flags, OPTION_ANSWER_WARN); /* Don't warn more than once. */ + ast_log(LOG_WARNING, "Barge failed: channel is bridged, but not to a 2-party bridge. Use the 'r' option.\n"); + } + return -1; + } + + ast_audiohook_init(bridge_whisper_audiohook, AST_AUDIOHOOK_TYPE_WHISPER, "Broadcast", 0); + internal_bridge_autochan = ast_autochan_setup(bridged); + if (!internal_bridge_autochan) { + return -1; + } + + if (start_spying(internal_bridge_autochan, spyer_name, bridge_whisper_audiohook, flags)) { + ast_log(LOG_WARNING, "Unable to attach barge audiohook on spyee '%s'. Barge mode disabled.\n", name); + retval = -1; + } + + *spyee_bridge_autochan = internal_bridge_autochan; + return retval; +} + +static void multi_autochan_free(struct multi_autochan *mac) +{ + if (mac->connected) { + if (mac->whisper_audiohook.status != AST_AUDIOHOOK_STATUS_RUNNING) { + ast_debug(2, "Whisper audiohook no longer running\n"); + } + ast_audiohook_lock(&mac->whisper_audiohook); + ast_audiohook_detach(&mac->whisper_audiohook); + ast_audiohook_unlock(&mac->whisper_audiohook); + ast_audiohook_destroy(&mac->whisper_audiohook); + } + if (mac->bridge_connected) { + if (mac->bridge_whisper_audiohook.status != AST_AUDIOHOOK_STATUS_RUNNING) { + ast_debug(2, "Whisper (bridged) audiohook no longer running\n"); + } + ast_audiohook_lock(&mac->bridge_whisper_audiohook); + ast_audiohook_detach(&mac->bridge_whisper_audiohook); + ast_audiohook_unlock(&mac->bridge_whisper_audiohook); + ast_audiohook_destroy(&mac->bridge_whisper_audiohook); + } + if (mac->spying) { + if (mac->spy_audiohook.status != AST_AUDIOHOOK_STATUS_RUNNING) { + ast_debug(2, "Spy audiohook no longer running\n"); + } + ast_audiohook_lock(&mac->spy_audiohook); + ast_audiohook_detach(&mac->spy_audiohook); + ast_audiohook_unlock(&mac->spy_audiohook); + ast_audiohook_destroy(&mac->spy_audiohook); + } + if (mac->name) { + int total = mac->connected + mac->bridge_connected + mac->spying; + ast_debug(1, "Removing channel %s from target list (%d hook%s)\n", mac->name, total, ESS(total)); + ast_free(mac->name); + } + if (mac->autochan) { + ast_autochan_destroy(mac->autochan); + } + if (mac->bridge_autochan) { + ast_autochan_destroy(mac->bridge_autochan); + } + ast_free(mac); +} + +static int do_broadcast(struct ast_channel *chan, struct ast_flags *flags, const char *channels) +{ + int res = 0; + struct ast_frame *f; + struct ast_silence_generator *silgen = NULL; + struct multi_spy multispy; + struct multi_autochan_list chanlist; + struct multi_autochan *mac; + int numchans = 0; + int readonly = ast_test_flag(flags, OPTION_READONLY) ? 1 : 0; + char *next, *chansdup = ast_strdupa(channels); + + AST_RWLIST_HEAD_INIT(&chanlist); + ast_channel_set_flag(chan, AST_FLAG_SPYING); + + ast_set_flag(flags, OPTION_ANSWER_WARN); /* Initialize answer warn to 1 */ + + /* Hey, look ma, no list lock needed! Sometimes, it's nice to not have to share... */ + + /* Build a list of targets */ + while ((next = strsep(&chansdup, ","))) { + struct ast_channel *ochan; + if (ast_strlen_zero(next)) { + continue; + } + if (!strcmp(next, ast_channel_name(chan))) { + ast_log(LOG_WARNING, "Refusing to broadcast to ourself: %s\n", next); + continue; + } + ochan = ast_channel_get_by_name(next); + if (!ochan) { + ast_log(LOG_WARNING, "No such channel: %s\n", next); + continue; + } + /* Append to end of list. */ + if (!(mac = ast_calloc(1, sizeof(*mac)))) { + ast_log(LOG_WARNING, "Multi autochan allocation failure\n"); + continue; + } + mac->name = ast_strdup(next); + mac->autochan = ast_autochan_setup(ochan); + if (!mac->name || !mac->autochan) { + multi_autochan_free(mac); + continue; + } + if (ast_test_flag(flags, OPTION_WHISPER)) { + mac->connected = 1; + ast_audiohook_init(&mac->whisper_audiohook, AST_AUDIOHOOK_TYPE_WHISPER, "Broadcast", 0); + /* Inject audio from our channel to this target. */ + if (start_spying(mac->autochan, next, &mac->whisper_audiohook, flags)) { + ast_log(LOG_WARNING, "Unable to attach whisper audiohook to %s\n", next); + multi_autochan_free(mac); + continue; + } + } + if (ast_test_flag(flags, OPTION_SPY)) { + mac->spying = 1; + ast_audiohook_init(&mac->spy_audiohook, AST_AUDIOHOOK_TYPE_SPY, "Broadcast", 0); + if (start_spying(mac->autochan, next, &mac->spy_audiohook, flags)) { + ast_log(LOG_WARNING, "Unable to attach spy audiohook to %s\n", next); + multi_autochan_free(mac); + continue; + } + } + AST_RWLIST_INSERT_TAIL(&chanlist, mac, entry); + numchans++; + ochan = ast_channel_unref(ochan); + } + + ast_verb(4, "Broadcasting to %d channel%s on %s\n", numchans, ESS(numchans), ast_channel_name(chan)); + ast_debug(1, "Broadcasting: (TX->1) whisper=%d, (TX->2) barge=%d, (RX<-%d) spy=%d (%s)\n", + ast_test_flag(flags, OPTION_WHISPER) ? 1 : 0, + ast_test_flag(flags, OPTION_BARGE) ? 1 : 0, + readonly ? 1 : 2, + ast_test_flag(flags, OPTION_SPY) ? 1 : 0, + readonly ? "single" : "both"); + + if (ast_test_flag(flags, OPTION_SPY)) { + multispy.chanlist = &chanlist; + multispy.readonly = readonly; + ast_activate_generator(chan, &spygen, &multispy); + } else { + /* We're not expecting to read any audio, just broadcast audio to a bunch of other channels. */ + silgen = ast_channel_start_silence_generator(chan); + } + + while (numchans && ast_waitfor(chan, -1) > 0) { + int fres = 0; + f = ast_read(chan); + if (!f) { + ast_debug(1, "Channel %s must have hung up\n", ast_channel_name(chan)); + res = -1; + break; + } + if (f->frametype != AST_FRAME_VOICE) { /* Ignore any non-voice frames */ + ast_frfree(f); + continue; + } + /* Write the frame to all our targets. */ + AST_RWLIST_WRLOCK(&chanlist); + AST_RWLIST_TRAVERSE_SAFE_BEGIN(&chanlist, mac, entry) { + /* Note that if no media is received, execution is suspended, but assuming continuous or + * or frequent audio on the broadcasting channel, we'll quickly enough detect hung up targets. + * This isn't really an issue, just something that might be confusing at first, but this is + * due to the limitation with audiohooks of using the channel for timing. */ + if ((ast_test_flag(flags, OPTION_WHISPER) && mac->whisper_audiohook.status != AST_AUDIOHOOK_STATUS_RUNNING) + || (ast_test_flag(flags, OPTION_SPY) && mac->spy_audiohook.status != AST_AUDIOHOOK_STATUS_RUNNING) + || (mac->bridge_connected && ast_test_flag(flags, OPTION_BARGE) && mac->bridge_whisper_audiohook.status != AST_AUDIOHOOK_STATUS_RUNNING)) { + /* Even if we're spying only and not actually broadcasting audio, we need to detect channel hangup. */ + AST_RWLIST_REMOVE_CURRENT(entry); + ast_debug(2, "Looks like %s has hung up\n", mac->name); + multi_autochan_free(mac); + numchans--; + ast_debug(2, "%d channel%s remaining in broadcast on %s\n", numchans, ESS(numchans), ast_channel_name(chan)); + continue; + } + + if (ast_test_flag(flags, OPTION_WHISPER)) { + ast_audiohook_lock(&mac->whisper_audiohook); + fres |= ast_audiohook_write_frame(&mac->whisper_audiohook, AST_AUDIOHOOK_DIRECTION_WRITE, f); + ast_audiohook_unlock(&mac->whisper_audiohook); + } + + if (ast_test_flag(flags, OPTION_BARGE)) { + /* This hook lets us inject audio into the channel that the spyee is currently + * bridged with. If the spyee isn't bridged with anything yet, nothing will + * be attached and we'll need to continue attempting to attach the barge + * audio hook. + * The exception to this is if we are emulating barge by doing it "directly", + * that is injecting the frames onto this channel's read queue, rather than + * its bridged peer's write queue, then skip this. We only do one or the other. */ + if (!ast_test_flag(flags, OPTION_REVERSE_FEED) && !mac->bridge_connected && !attach_barge(mac->autochan, &mac->bridge_autochan, + &mac->bridge_whisper_audiohook, ast_channel_name(chan), mac->name, flags)) { + ast_debug(2, "Attached barge channel for %s\n", mac->name); + mac->bridge_connected = 1; + } + + if (mac->bridge_connected) { + ast_audiohook_lock(&mac->bridge_whisper_audiohook); + fres |= ast_audiohook_write_frame(&mac->bridge_whisper_audiohook, AST_AUDIOHOOK_DIRECTION_WRITE, f); + ast_audiohook_unlock(&mac->bridge_whisper_audiohook); + } else if (ast_test_flag(flags, OPTION_REVERSE_FEED)) { + /* So, this is really clever... + * If we're connected to an n-party bridge instead of a 2-party bridge, + * attach_barge will ALWAYS fail because we're connected to a bridge, not + * a single peer channel. + * Recall that the objective is for injected audio to be audible to both + * sides of the channel. So really, the typical way of doing this by + * directly injecting frames separately onto both channels is kind of + * bizarre to begin with, when you think about it. + * + * In other words, this is how ChanSpy and this module by default work: + * We have audio F to inject onto channels A and B, which are <= bridged =>: + * READ <- A -> WRITE <==> READ <- B -> WRITE + * F --^ F --^ + * + * So that makes the same audio audible to both channels A and B, but + * in kind of a roundabout way. What if the bridged peer changes at + * some point, for example? + * + * While that method works for 2-party bridges, it doesn't work at all + * for an n-party bridge, so we do the thing that seems obvious to begin with: + * dump the frames onto THIS channel's read queue, and the channels will + * make their way into the bridge like any other audio from this channel, + * and everything just works perfectly, no matter what kind of bridging + * scenario is being used. At that point, we don't even care if we're + * bridged or not, and really, why should we? + * + * In other words, we do this: + * READ <- A -> WRITE <==> READ <- B -> WRITE + * F --^ F --^ + */ + ast_audiohook_lock(&mac->whisper_audiohook); + fres |= ast_audiohook_write_frame(&mac->whisper_audiohook, AST_AUDIOHOOK_DIRECTION_READ, f); + ast_audiohook_unlock(&mac->whisper_audiohook); + } + } + if (fres) { + ast_log(LOG_WARNING, "Failed to write to audiohook for %s\n", mac->name); + fres = 0; + } + } + AST_RWLIST_TRAVERSE_SAFE_END; + AST_RWLIST_UNLOCK(&chanlist); + ast_frfree(f); + } + + if (!numchans) { + ast_debug(1, "Exiting due to all target channels having left the broadcast\n"); + } + + if (ast_test_flag(flags, OPTION_SPY)) { + ast_deactivate_generator(chan); + } else { + ast_channel_stop_silence_generator(chan, silgen); + } + + /* Cleanup any remaining targets */ + AST_RWLIST_TRAVERSE_SAFE_BEGIN(&chanlist, mac, entry) { + AST_RWLIST_REMOVE_CURRENT(entry); + multi_autochan_free(mac); + } + AST_RWLIST_TRAVERSE_SAFE_END; + + ast_channel_clear_flag(chan, AST_FLAG_SPYING); + return res; +} + +static int broadcast_exec(struct ast_channel *chan, const char *data) +{ + struct ast_flags flags; + struct ast_format *write_format; + int res = -1; + AST_DECLARE_APP_ARGS(args, + AST_APP_ARG(options); + AST_APP_ARG(channels); /* Channel list last, so we can have multiple */ + ); + char *parse = NULL; + + if (ast_strlen_zero(data)) { + ast_log(LOG_WARNING, "Broadcast requires at least one channel\n"); + return -1; + } + + parse = ast_strdupa(data); + AST_STANDARD_APP_ARGS(args, parse); + + if (ast_strlen_zero(args.channels)) { + ast_log(LOG_WARNING, "Must specify at least one channel for broadcast\n"); + return -1; + } + if (args.options) { + ast_app_parse_options(spy_opts, &flags, NULL, args.options); + } else { + ast_clear_flag(&flags, AST_FLAGS_ALL); + } + + if (!ast_test_flag(&flags, OPTION_BARGE) && !ast_test_flag(&flags, OPTION_SPY) && !ast_test_flag(&flags, OPTION_WHISPER)) { + ast_log(LOG_WARNING, "At least one of the b, s, or w option must be specified (provided options have no effect)\n"); + return -1; + } + + write_format = ao2_bump(ast_channel_writeformat(chan)); + if (ast_set_write_format(chan, ast_format_slin) < 0) { + ast_log(LOG_ERROR, "Failed to set write format to slin.\n"); + goto cleanup; + } + + res = do_broadcast(chan, &flags, args.channels); + + /* Restore previous write format */ + if (ast_set_write_format(chan, write_format)) { + ast_log(LOG_ERROR, "Failed to restore write format for channel %s\n", ast_channel_name(chan)); + } + +cleanup: + ao2_ref(write_format, -1); + return res; +} + +static int unload_module(void) +{ + return ast_unregister_application(app_broadcast); +} + +static int load_module(void) +{ + return ast_register_application_xml(app_broadcast, broadcast_exec); +} + +AST_MODULE_INFO_STANDARD_EXTENDED(ASTERISK_GPL_KEY, "Channel Audio Broadcasting"); diff --git a/doc/CHANGES-staging/app_broadcast.txt b/doc/CHANGES-staging/app_broadcast.txt new file mode 100644 index 0000000000..03e6848362 --- /dev/null +++ b/doc/CHANGES-staging/app_broadcast.txt @@ -0,0 +1,4 @@ +Subject: app_broadcast + +A Broadcast application is now available which allows +for asynchronous one-to-many and many-to-one channel audio.