652 lines
14 KiB
C
652 lines
14 KiB
C
/*
|
|
* Copyright (C) 2008-2013 Various Authors
|
|
* Copyright (C) 2009 Gregory Petrosyan
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU General Public License as published by
|
|
* the Free Software Foundation, either version 2 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
#include <string.h>
|
|
|
|
#include <pulse/pulseaudio.h>
|
|
|
|
#include "../op.h"
|
|
#include "../mixer.h"
|
|
#include "../debug.h"
|
|
#include "../utils.h"
|
|
#include "../xmalloc.h"
|
|
|
|
static pa_threaded_mainloop *mainloop;
|
|
static pa_context *context;
|
|
static pa_stream *stream;
|
|
static pa_channel_map channel_map;
|
|
static pa_cvolume volume;
|
|
static pa_sample_spec sample_spec;
|
|
|
|
static int mixer_notify_in;
|
|
static int mixer_notify_out;
|
|
|
|
static int mixer_notify_output_in;
|
|
static int mixer_notify_output_out;
|
|
static long last_output_idx;
|
|
|
|
/* configuration */
|
|
static int pa_restore_volume = 1;
|
|
|
|
#define RET_PA_ERROR(err) \
|
|
do { \
|
|
d_print("PulseAudio error: %s\n", pa_strerror(err)); \
|
|
return -OP_ERROR_INTERNAL; \
|
|
} while (0)
|
|
|
|
#define RET_PA_LAST_ERROR() RET_PA_ERROR(pa_context_errno(context))
|
|
|
|
static int pulse_wait_and_unlock(pa_operation *op)
|
|
{
|
|
pa_operation_state_t state;
|
|
|
|
if (!op) {
|
|
pa_threaded_mainloop_unlock(mainloop);
|
|
RET_PA_LAST_ERROR();
|
|
}
|
|
|
|
while ((state = pa_operation_get_state(op)) == PA_OPERATION_RUNNING)
|
|
pa_threaded_mainloop_wait(mainloop);
|
|
|
|
pa_operation_unref(op);
|
|
pa_threaded_mainloop_unlock(mainloop);
|
|
|
|
if (state == PA_OPERATION_DONE)
|
|
return OP_ERROR_SUCCESS;
|
|
else
|
|
RET_PA_LAST_ERROR();
|
|
}
|
|
|
|
static int pulse_nowait_and_unlock(pa_operation *op)
|
|
{
|
|
if (!op) {
|
|
pa_threaded_mainloop_unlock(mainloop);
|
|
RET_PA_LAST_ERROR();
|
|
}
|
|
|
|
pa_operation_unref(op);
|
|
pa_threaded_mainloop_unlock(mainloop);
|
|
|
|
return OP_ERROR_SUCCESS;
|
|
}
|
|
|
|
static int pulse_wait(pa_operation *op)
|
|
{
|
|
pa_operation_state_t state;
|
|
|
|
if (!op)
|
|
RET_PA_LAST_ERROR();
|
|
|
|
while ((state = pa_operation_get_state(op)) == PA_OPERATION_RUNNING)
|
|
pa_threaded_mainloop_wait(mainloop);
|
|
|
|
pa_operation_unref(op);
|
|
|
|
if (state == PA_OPERATION_DONE)
|
|
return OP_ERROR_SUCCESS;
|
|
else
|
|
RET_PA_LAST_ERROR();
|
|
}
|
|
|
|
static pa_sample_format_t convert_sample_format(sample_format_t sf)
|
|
{
|
|
const int _signed = sf_get_signed(sf);
|
|
const int big_endian = sf_get_bigendian(sf);
|
|
const int sample_size = sf_get_sample_size(sf) * 8;
|
|
|
|
if (!_signed && sample_size == 8)
|
|
return PA_SAMPLE_U8;
|
|
|
|
if (_signed) {
|
|
switch (sample_size) {
|
|
case 16:
|
|
return big_endian ? PA_SAMPLE_S16BE : PA_SAMPLE_S16LE;
|
|
case 24:
|
|
return big_endian ? PA_SAMPLE_S24BE : PA_SAMPLE_S24LE;
|
|
case 32:
|
|
return big_endian ? PA_SAMPLE_S32BE : PA_SAMPLE_S32LE;
|
|
}
|
|
}
|
|
|
|
return PA_SAMPLE_INVALID;
|
|
}
|
|
|
|
#define RET_IF(x) case CHANNEL_POSITION_ ## x: return PA_CHANNEL_POSITION_ ## x
|
|
|
|
static pa_channel_position_t convert_channel_position(channel_position_t p)
|
|
{
|
|
switch (p) {
|
|
RET_IF(MONO);
|
|
RET_IF(FRONT_LEFT); RET_IF(FRONT_RIGHT); RET_IF(FRONT_CENTER);
|
|
RET_IF(REAR_CENTER); RET_IF(REAR_LEFT); RET_IF(REAR_RIGHT);
|
|
RET_IF(LFE);
|
|
RET_IF(FRONT_LEFT_OF_CENTER); RET_IF(FRONT_RIGHT_OF_CENTER);
|
|
RET_IF(SIDE_LEFT); RET_IF(SIDE_RIGHT);
|
|
RET_IF(TOP_CENTER);
|
|
RET_IF(TOP_FRONT_LEFT); RET_IF(TOP_FRONT_RIGHT); RET_IF(TOP_FRONT_CENTER);
|
|
RET_IF(TOP_REAR_LEFT); RET_IF(TOP_REAR_RIGHT); RET_IF(TOP_REAR_CENTER);
|
|
default:
|
|
return PA_CHANNEL_POSITION_INVALID;
|
|
}
|
|
}
|
|
|
|
static pa_proplist *pulse_create_app_proplist(void)
|
|
{
|
|
pa_proplist *pl = pa_proplist_new();
|
|
BUG_ON(!pl);
|
|
|
|
int rc = pa_proplist_sets(pl, PA_PROP_APPLICATION_ID, "cmus");
|
|
BUG_ON(rc);
|
|
|
|
rc = pa_proplist_sets(pl, PA_PROP_APPLICATION_NAME, "C* Music Player");
|
|
BUG_ON(rc);
|
|
|
|
rc = pa_proplist_sets(pl, PA_PROP_APPLICATION_VERSION, VERSION);
|
|
BUG_ON(rc);
|
|
|
|
return pl;
|
|
}
|
|
|
|
static pa_proplist *pulse_create_stream_proplist(void)
|
|
{
|
|
pa_proplist *pl = pa_proplist_new();
|
|
BUG_ON(!pl);
|
|
|
|
int rc = pa_proplist_sets(pl, PA_PROP_MEDIA_ROLE, "music");
|
|
BUG_ON(rc);
|
|
|
|
rc = pa_proplist_sets(pl, PA_PROP_MEDIA_ICON_NAME, "audio-x-generic");
|
|
BUG_ON(rc);
|
|
|
|
return pl;
|
|
}
|
|
|
|
static const char *pa_context_state_str(pa_context_state_t s)
|
|
{
|
|
switch (s) {
|
|
case PA_CONTEXT_AUTHORIZING:
|
|
return "PA_CONTEXT_AUTHORIZING";
|
|
case PA_CONTEXT_CONNECTING:
|
|
return "PA_CONTEXT_CONNECTING";
|
|
case PA_CONTEXT_FAILED:
|
|
return "PA_CONTEXT_FAILED";
|
|
case PA_CONTEXT_READY:
|
|
return "PA_CONTEXT_READY";
|
|
case PA_CONTEXT_SETTING_NAME:
|
|
return "PA_CONTEXT_SETTING_NAME";
|
|
case PA_CONTEXT_TERMINATED:
|
|
return "PA_CONTEXT_TERMINATED";
|
|
case PA_CONTEXT_UNCONNECTED:
|
|
return "PA_CONTEXT_UNCONNECTED";
|
|
}
|
|
|
|
return "unknown";
|
|
}
|
|
|
|
static void pulse_context_state_cb(pa_context *c, void *data)
|
|
{
|
|
const pa_context_state_t cs = pa_context_get_state(c);
|
|
|
|
d_print("context state has changed to %s\n", pa_context_state_str(cs));
|
|
|
|
switch (cs) {
|
|
case PA_CONTEXT_READY:
|
|
case PA_CONTEXT_FAILED:
|
|
case PA_CONTEXT_TERMINATED:
|
|
pa_threaded_mainloop_signal(mainloop, 0);
|
|
default:
|
|
return;
|
|
}
|
|
}
|
|
|
|
static const char *pa_stream_state_str(pa_stream_state_t s)
|
|
{
|
|
switch (s) {
|
|
case PA_STREAM_CREATING:
|
|
return "PA_STREAM_CREATING";
|
|
case PA_STREAM_FAILED:
|
|
return "PA_STREAM_FAILED";
|
|
case PA_STREAM_READY:
|
|
return "PA_STREAM_READY";
|
|
case PA_STREAM_TERMINATED:
|
|
return "PA_STREAM_TERMINATED";
|
|
case PA_STREAM_UNCONNECTED:
|
|
return "PA_STREAM_UNCONNECTED";
|
|
}
|
|
|
|
return "unknown";
|
|
}
|
|
|
|
static void pulse_stream_state_cb(pa_stream *s, void *data)
|
|
{
|
|
const pa_stream_state_t ss = pa_stream_get_state(s);
|
|
|
|
d_print("stream state has changed to %s\n", pa_stream_state_str(ss));
|
|
|
|
switch (ss) {
|
|
case PA_STREAM_READY:
|
|
case PA_STREAM_FAILED:
|
|
case PA_STREAM_TERMINATED:
|
|
pa_threaded_mainloop_signal(mainloop, 0);
|
|
default:
|
|
return;
|
|
}
|
|
}
|
|
|
|
static void pulse_sink_input_info_cb(pa_context *c,
|
|
const pa_sink_input_info *i, int eol, void *data)
|
|
{
|
|
if (!i)
|
|
return;
|
|
|
|
memcpy(&volume, &i->volume, sizeof(volume));
|
|
notify_via_pipe(mixer_notify_in);
|
|
|
|
if (last_output_idx != i->sink) {
|
|
if (last_output_idx != -1)
|
|
notify_via_pipe(mixer_notify_output_in);
|
|
last_output_idx = i->sink;
|
|
}
|
|
}
|
|
|
|
static void pulse_context_subscription_cb(pa_context *ctx,
|
|
pa_subscription_event_type_t type, uint32_t idx, void *data)
|
|
{
|
|
type &= PA_SUBSCRIPTION_EVENT_TYPE_MASK;
|
|
if (type != PA_SUBSCRIPTION_EVENT_CHANGE)
|
|
return;
|
|
|
|
if (stream && idx == pa_stream_get_index(stream)) {
|
|
pa_context_get_sink_input_info(ctx, idx,
|
|
pulse_sink_input_info_cb, NULL);
|
|
}
|
|
}
|
|
|
|
static int pulse_create_context(void)
|
|
{
|
|
pa_mainloop_api *api = pa_threaded_mainloop_get_api(mainloop);
|
|
BUG_ON(!api);
|
|
|
|
pa_proplist *pl = pulse_create_app_proplist();
|
|
|
|
pa_threaded_mainloop_lock(mainloop);
|
|
|
|
context = pa_context_new_with_proplist(api, "C* Music Player", pl);
|
|
pa_proplist_free(pl);
|
|
BUG_ON(!context);
|
|
|
|
pa_context_set_state_callback(context, pulse_context_state_cb, NULL);
|
|
|
|
int rc = pa_context_connect(context, NULL, PA_CONTEXT_NOFLAGS, NULL);
|
|
if (rc)
|
|
goto err_free_ctx;
|
|
|
|
for (;;) {
|
|
pa_context_state_t s = pa_context_get_state(context);
|
|
if (s == PA_CONTEXT_READY)
|
|
break;
|
|
if (s == PA_CONTEXT_TERMINATED || s == PA_CONTEXT_FAILED)
|
|
goto err_disconnect_ctx;
|
|
|
|
pa_threaded_mainloop_wait(mainloop);
|
|
}
|
|
|
|
pa_context_set_subscribe_callback(context,
|
|
pulse_context_subscription_cb, NULL);
|
|
pa_operation *op = pa_context_subscribe(context,
|
|
PA_SUBSCRIPTION_MASK_SINK_INPUT, NULL, NULL);
|
|
if (!op)
|
|
goto err_disconnect_ctx;
|
|
pa_operation_unref(op);
|
|
|
|
pa_threaded_mainloop_unlock(mainloop);
|
|
|
|
return OP_ERROR_SUCCESS;
|
|
|
|
err_disconnect_ctx:
|
|
pa_context_disconnect(context);
|
|
|
|
err_free_ctx:
|
|
pa_context_unref(context);
|
|
context = NULL;
|
|
|
|
pa_threaded_mainloop_unlock(mainloop);
|
|
RET_PA_LAST_ERROR();
|
|
}
|
|
|
|
static void pulse_stream_success_cb(pa_stream *s, int success, void *data)
|
|
{
|
|
pa_threaded_mainloop_signal(mainloop, 0);
|
|
}
|
|
|
|
static int op_pulse_init(void)
|
|
{
|
|
mainloop = pa_threaded_mainloop_new();
|
|
BUG_ON(!mainloop);
|
|
|
|
int rc = pa_threaded_mainloop_start(mainloop);
|
|
if (rc) {
|
|
pa_threaded_mainloop_free(mainloop);
|
|
RET_PA_ERROR(rc);
|
|
}
|
|
|
|
return OP_ERROR_SUCCESS;
|
|
}
|
|
|
|
static int op_pulse_exit(void)
|
|
{
|
|
if (mainloop) {
|
|
pa_threaded_mainloop_stop(mainloop);
|
|
pa_threaded_mainloop_free(mainloop);
|
|
mainloop = NULL;
|
|
}
|
|
|
|
return OP_ERROR_SUCCESS;
|
|
}
|
|
|
|
static int op_pulse_open(sample_format_t sf, const channel_position_t *cmap)
|
|
{
|
|
pa_proplist *pl;
|
|
int rc, i;
|
|
|
|
const pa_sample_spec ss = {
|
|
.format = convert_sample_format(sf),
|
|
.rate = sf_get_rate(sf),
|
|
.channels = sf_get_channels(sf)
|
|
};
|
|
|
|
if (!pa_sample_spec_valid(&ss))
|
|
return -OP_ERROR_SAMPLE_FORMAT;
|
|
|
|
sample_spec = ss;
|
|
if (cmap && channel_map_valid(cmap)) {
|
|
pa_channel_map_init(&channel_map);
|
|
channel_map.channels = ss.channels;
|
|
for (i = 0; i < channel_map.channels; i++)
|
|
channel_map.map[i] = convert_channel_position(cmap[i]);
|
|
} else {
|
|
pa_channel_map_init_auto(&channel_map, ss.channels,
|
|
PA_CHANNEL_MAP_ALSA);
|
|
}
|
|
|
|
last_output_idx = -1;
|
|
rc = pulse_create_context();
|
|
if (rc)
|
|
return rc;
|
|
|
|
pl = pulse_create_stream_proplist();
|
|
|
|
pa_threaded_mainloop_lock(mainloop);
|
|
|
|
stream = pa_stream_new_with_proplist(context, "playback",
|
|
&ss, &channel_map, pl);
|
|
pa_proplist_free(pl);
|
|
if (!stream)
|
|
goto err;
|
|
|
|
pa_stream_set_state_callback(stream, pulse_stream_state_cb, NULL);
|
|
|
|
rc = pa_stream_connect_playback(stream, NULL, NULL, PA_STREAM_NOFLAGS,
|
|
pa_restore_volume ? NULL : &volume, NULL);
|
|
if (rc)
|
|
goto err_free_stream;
|
|
|
|
for (;;) {
|
|
pa_stream_state_t s = pa_stream_get_state(stream);
|
|
if (s == PA_STREAM_READY)
|
|
break;
|
|
if (s == PA_STREAM_FAILED || s == PA_STREAM_TERMINATED)
|
|
goto err_free_stream;
|
|
|
|
pa_threaded_mainloop_wait(mainloop);
|
|
}
|
|
|
|
pa_context_get_sink_input_info(context, pa_stream_get_index(stream),
|
|
pulse_sink_input_info_cb, NULL);
|
|
|
|
pa_threaded_mainloop_unlock(mainloop);
|
|
|
|
return OP_ERROR_SUCCESS;
|
|
|
|
err_free_stream:
|
|
pa_stream_unref(stream);
|
|
err:
|
|
pa_threaded_mainloop_unlock(mainloop);
|
|
RET_PA_LAST_ERROR();
|
|
}
|
|
|
|
static void pulse_drain_if_playing(void)
|
|
{
|
|
if (!pa_stream_is_corked(stream)
|
|
&& !pa_stream_is_suspended(stream)
|
|
&& pa_stream_get_state(stream) == PA_STREAM_READY)
|
|
{
|
|
pa_operation *op = pa_stream_drain(stream,
|
|
pulse_stream_success_cb, NULL);
|
|
pulse_wait(op);
|
|
}
|
|
}
|
|
|
|
static int op_pulse_close(void)
|
|
{
|
|
pa_threaded_mainloop_lock(mainloop);
|
|
|
|
if (stream) {
|
|
pulse_drain_if_playing();
|
|
|
|
pa_stream_disconnect(stream);
|
|
pa_stream_unref(stream);
|
|
stream = NULL;
|
|
}
|
|
|
|
if (context) {
|
|
pa_context_disconnect(context);
|
|
pa_context_unref(context);
|
|
context = NULL;
|
|
}
|
|
|
|
pa_threaded_mainloop_unlock(mainloop);
|
|
|
|
return OP_ERROR_SUCCESS;
|
|
}
|
|
|
|
static int op_pulse_drop(void)
|
|
{
|
|
pa_threaded_mainloop_lock(mainloop);
|
|
|
|
pa_operation *op = pa_stream_flush(stream, pulse_stream_success_cb, NULL);
|
|
return pulse_wait_and_unlock(op);
|
|
}
|
|
|
|
static int op_pulse_write(const char *buf, int count)
|
|
{
|
|
pa_threaded_mainloop_lock(mainloop);
|
|
int rc = pa_stream_write(stream, buf, count, NULL, 0, PA_SEEK_RELATIVE);
|
|
pa_threaded_mainloop_unlock(mainloop);
|
|
|
|
if (rc)
|
|
RET_PA_ERROR(rc);
|
|
else
|
|
return count;
|
|
}
|
|
|
|
static int op_pulse_buffer_space(void)
|
|
{
|
|
pa_threaded_mainloop_lock(mainloop);
|
|
size_t s = pa_stream_writable_size(stream);
|
|
pa_threaded_mainloop_unlock(mainloop);
|
|
|
|
if (s == (size_t)-1)
|
|
RET_PA_LAST_ERROR();
|
|
else
|
|
return s;
|
|
}
|
|
|
|
static int pulse_stream_cork(int pause)
|
|
{
|
|
pa_threaded_mainloop_lock(mainloop);
|
|
|
|
pa_operation *op = pa_stream_cork(stream, pause,
|
|
pulse_stream_success_cb, NULL);
|
|
return pulse_wait_and_unlock(op);
|
|
}
|
|
|
|
static int op_pulse_pause(void)
|
|
{
|
|
return pulse_stream_cork(1);
|
|
}
|
|
|
|
static int op_pulse_unpause(void)
|
|
{
|
|
return pulse_stream_cork(0);
|
|
}
|
|
|
|
static int op_pulse_mixer_init(void)
|
|
{
|
|
if (!pa_channel_map_init_stereo(&channel_map))
|
|
RET_PA_LAST_ERROR();
|
|
|
|
pa_cvolume_reset(&volume, 2);
|
|
|
|
init_pipes(&mixer_notify_out, &mixer_notify_in);
|
|
init_pipes(&mixer_notify_output_out, &mixer_notify_output_in);
|
|
|
|
return OP_ERROR_SUCCESS;
|
|
}
|
|
|
|
static int op_pulse_mixer_exit(void)
|
|
{
|
|
close(mixer_notify_out);
|
|
close(mixer_notify_in);
|
|
|
|
close(mixer_notify_output_out);
|
|
close(mixer_notify_output_in);
|
|
|
|
return OP_ERROR_SUCCESS;
|
|
}
|
|
|
|
static int op_pulse_mixer_open(int *volume_max)
|
|
{
|
|
*volume_max = PA_VOLUME_NORM;
|
|
|
|
return OP_ERROR_SUCCESS;
|
|
}
|
|
|
|
static int op_pulse_mixer_close(void)
|
|
{
|
|
return OP_ERROR_SUCCESS;
|
|
}
|
|
|
|
static int op_pulse_mixer_get_fds(int what, int *fds)
|
|
{
|
|
switch (what) {
|
|
case MIXER_FDS_VOLUME:
|
|
fds[0] = mixer_notify_out;
|
|
return 1;
|
|
case MIXER_FDS_OUTPUT:
|
|
fds[0] = mixer_notify_output_out;
|
|
return 1;
|
|
default:
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
static int op_pulse_mixer_set_volume(int l, int r)
|
|
{
|
|
if (!stream && pa_restore_volume)
|
|
return -OP_ERROR_NOT_OPEN;
|
|
|
|
pa_cvolume_set(&volume, sample_spec.channels, (pa_volume_t)((l + r) / 2));
|
|
pa_cvolume_set_position(&volume, &channel_map,
|
|
PA_CHANNEL_POSITION_FRONT_LEFT, (pa_volume_t)l);
|
|
pa_cvolume_set_position(&volume, &channel_map,
|
|
PA_CHANNEL_POSITION_FRONT_RIGHT, (pa_volume_t)r);
|
|
|
|
if (!stream) {
|
|
return OP_ERROR_SUCCESS;
|
|
} else {
|
|
pa_threaded_mainloop_lock(mainloop);
|
|
|
|
pa_operation *op = pa_context_set_sink_input_volume(
|
|
context, pa_stream_get_index(stream),
|
|
&volume, NULL, NULL);
|
|
return pulse_nowait_and_unlock(op);
|
|
}
|
|
}
|
|
|
|
static int op_pulse_mixer_get_volume(int *l, int *r)
|
|
{
|
|
clear_pipe(mixer_notify_out, -1);
|
|
|
|
if (!stream && pa_restore_volume)
|
|
return -OP_ERROR_NOT_OPEN;
|
|
|
|
*l = pa_cvolume_get_position(&volume, &channel_map,
|
|
PA_CHANNEL_POSITION_FRONT_LEFT);
|
|
*r = pa_cvolume_get_position(&volume, &channel_map,
|
|
PA_CHANNEL_POSITION_FRONT_RIGHT);
|
|
|
|
return OP_ERROR_SUCCESS;
|
|
}
|
|
|
|
static int op_pulse_set_restore_volume(const char *val)
|
|
{
|
|
pa_restore_volume = is_freeform_true(val);
|
|
return 0;
|
|
}
|
|
|
|
static int op_pulse_get_restore_volume(char **val)
|
|
{
|
|
*val = xstrdup(pa_restore_volume ? "1" : "0");
|
|
return 0;
|
|
}
|
|
|
|
const struct output_plugin_ops op_pcm_ops = {
|
|
.init = op_pulse_init,
|
|
.exit = op_pulse_exit,
|
|
.open = op_pulse_open,
|
|
.close = op_pulse_close,
|
|
.drop = op_pulse_drop,
|
|
.write = op_pulse_write,
|
|
.buffer_space = op_pulse_buffer_space,
|
|
.pause = op_pulse_pause,
|
|
.unpause = op_pulse_unpause,
|
|
};
|
|
|
|
const struct mixer_plugin_ops op_mixer_ops = {
|
|
.init = op_pulse_mixer_init,
|
|
.exit = op_pulse_mixer_exit,
|
|
.open = op_pulse_mixer_open,
|
|
.close = op_pulse_mixer_close,
|
|
.get_fds.abi_2 = op_pulse_mixer_get_fds,
|
|
.set_volume = op_pulse_mixer_set_volume,
|
|
.get_volume = op_pulse_mixer_get_volume,
|
|
};
|
|
|
|
const struct output_plugin_opt op_pcm_options[] = {
|
|
{ NULL },
|
|
};
|
|
|
|
const struct mixer_plugin_opt op_mixer_options[] = {
|
|
OPT(op_pulse, restore_volume),
|
|
{ NULL },
|
|
};
|
|
|
|
const int op_priority = -2;
|
|
const unsigned op_abi_version = OP_ABI_VERSION;
|