2021-12-26 07:21:21 +00:00
|
|
|
/**
|
|
|
|
* Looking Glass
|
2022-01-05 08:42:46 +00:00
|
|
|
* Copyright © 2017-2022 The Looking Glass Authors
|
2021-12-26 07:21:21 +00:00
|
|
|
* https://looking-glass.io
|
|
|
|
*
|
|
|
|
* 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, write to the Free Software Foundation, Inc., 59
|
|
|
|
* Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
|
|
|
*/
|
|
|
|
|
|
|
|
#include "interface/audiodev.h"
|
|
|
|
|
|
|
|
#include <pulse/pulseaudio.h>
|
|
|
|
#include <string.h>
|
|
|
|
#include <math.h>
|
|
|
|
|
|
|
|
#include "common/debug.h"
|
|
|
|
|
|
|
|
struct PulseAudio
|
|
|
|
{
|
|
|
|
pa_threaded_mainloop * loop;
|
|
|
|
pa_mainloop_api * api;
|
|
|
|
pa_context * context;
|
|
|
|
pa_operation * contextSub;
|
|
|
|
|
|
|
|
pa_stream * sink;
|
|
|
|
int sinkIndex;
|
|
|
|
bool sinkCorked;
|
|
|
|
bool sinkMuted;
|
2022-02-20 18:50:30 +00:00
|
|
|
bool sinkStarting;
|
2022-02-05 09:53:06 +00:00
|
|
|
int sinkMaxPeriodFrames;
|
2021-12-26 07:21:21 +00:00
|
|
|
int sinkSampleRate;
|
|
|
|
int sinkChannels;
|
|
|
|
int sinkStride;
|
2022-01-17 22:02:44 +00:00
|
|
|
LG_AudioPullFn sinkPullFn;
|
2021-12-26 07:21:21 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
static struct PulseAudio pa = {0};
|
|
|
|
|
|
|
|
static void pulseaudio_sink_input_cb(pa_context *c, const pa_sink_input_info *i,
|
|
|
|
int eol, void *userdata)
|
|
|
|
{
|
|
|
|
if (eol < 0 || eol == 1)
|
|
|
|
return;
|
|
|
|
|
|
|
|
pa.sinkIndex = i->index;
|
|
|
|
}
|
|
|
|
|
|
|
|
static void pulseaudio_subscribe_cb(pa_context *c,
|
|
|
|
pa_subscription_event_type_t t, uint32_t index, void *userdata)
|
|
|
|
{
|
|
|
|
switch (t & PA_SUBSCRIPTION_EVENT_FACILITY_MASK)
|
|
|
|
{
|
|
|
|
case PA_SUBSCRIPTION_EVENT_SINK_INPUT:
|
|
|
|
if ((t & PA_SUBSCRIPTION_EVENT_TYPE_MASK) == PA_SUBSCRIPTION_EVENT_REMOVE)
|
|
|
|
pa.sinkIndex = 0;
|
|
|
|
else
|
|
|
|
{
|
|
|
|
pa_operation *o = pa_context_get_sink_input_info(c, index,
|
|
|
|
pulseaudio_sink_input_cb, NULL);
|
|
|
|
pa_operation_unref(o);
|
|
|
|
}
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
static void pulseaudio_ctx_state_change_cb(pa_context * c, void * userdata)
|
|
|
|
{
|
|
|
|
switch (pa_context_get_state(c))
|
|
|
|
{
|
|
|
|
case PA_CONTEXT_CONNECTING:
|
|
|
|
case PA_CONTEXT_AUTHORIZING:
|
|
|
|
case PA_CONTEXT_SETTING_NAME:
|
|
|
|
break;
|
|
|
|
|
|
|
|
case PA_CONTEXT_READY:
|
|
|
|
DEBUG_INFO("Connected to PulseAudio server");
|
|
|
|
pa_context_set_subscribe_callback(c, pulseaudio_subscribe_cb, NULL);
|
|
|
|
pa_context_subscribe(c, PA_SUBSCRIPTION_MASK_SINK_INPUT, NULL, NULL);
|
|
|
|
pa_threaded_mainloop_signal(pa.loop, 0);
|
|
|
|
break;
|
|
|
|
|
|
|
|
case PA_CONTEXT_TERMINATED:
|
|
|
|
if (pa.contextSub)
|
|
|
|
{
|
|
|
|
pa_operation_unref(pa.contextSub);
|
|
|
|
pa.contextSub = NULL;
|
|
|
|
}
|
|
|
|
break;
|
|
|
|
|
|
|
|
case PA_CONTEXT_FAILED:
|
|
|
|
default:
|
|
|
|
DEBUG_ERROR("context error: %s", pa_strerror(pa_context_errno(c)));
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
static bool pulseaudio_init(void)
|
|
|
|
{
|
|
|
|
pa.loop = pa_threaded_mainloop_new();
|
|
|
|
if (!pa.loop)
|
|
|
|
{
|
|
|
|
DEBUG_ERROR("Failed to create the main loop");
|
|
|
|
goto err;
|
|
|
|
}
|
|
|
|
|
|
|
|
pa.api = pa_threaded_mainloop_get_api(pa.loop);
|
|
|
|
if (pa_signal_init(pa.api) != 0)
|
|
|
|
{
|
|
|
|
DEBUG_ERROR("Failed to init signals");
|
|
|
|
goto err_loop;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (pa_threaded_mainloop_start(pa.loop) < 0)
|
|
|
|
{
|
|
|
|
DEBUG_ERROR("Failed to start the main loop");
|
|
|
|
goto err_loop;
|
|
|
|
}
|
|
|
|
|
|
|
|
pa_proplist * propList = pa_proplist_new();
|
|
|
|
if (!propList)
|
|
|
|
{
|
|
|
|
DEBUG_ERROR("Failed to create the proplist");
|
|
|
|
goto err_thread;
|
|
|
|
}
|
|
|
|
pa_proplist_sets(propList, PA_PROP_MEDIA_ROLE, "video");
|
|
|
|
|
|
|
|
pa_threaded_mainloop_lock(pa.loop);
|
|
|
|
pa.context = pa_context_new_with_proplist(
|
|
|
|
pa.api,
|
|
|
|
"Looking Glass",
|
|
|
|
propList);
|
|
|
|
if (!pa.context)
|
|
|
|
{
|
|
|
|
DEBUG_ERROR("Failed to create the context");
|
|
|
|
goto err_context;
|
|
|
|
}
|
|
|
|
|
|
|
|
pa_context_set_state_callback(pa.context,
|
|
|
|
pulseaudio_ctx_state_change_cb, NULL);
|
|
|
|
|
|
|
|
if (pa_context_connect(pa.context, NULL, PA_CONTEXT_NOAUTOSPAWN, NULL) < 0)
|
|
|
|
{
|
|
|
|
DEBUG_ERROR("Failed to connect to the context server");
|
|
|
|
goto err_context;
|
|
|
|
}
|
|
|
|
|
|
|
|
for(;;)
|
|
|
|
{
|
|
|
|
pa_context_state_t state = pa_context_get_state(pa.context);
|
|
|
|
if(!PA_CONTEXT_IS_GOOD(state))
|
|
|
|
{
|
|
|
|
DEBUG_ERROR("Context is bad");
|
|
|
|
goto err_context;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (state == PA_CONTEXT_READY)
|
|
|
|
break;
|
|
|
|
|
|
|
|
pa_threaded_mainloop_wait(pa.loop);
|
|
|
|
}
|
|
|
|
|
|
|
|
pa_threaded_mainloop_unlock(pa.loop);
|
|
|
|
pa_proplist_free(propList);
|
|
|
|
return true;
|
|
|
|
|
|
|
|
err_context:
|
|
|
|
pa_threaded_mainloop_unlock(pa.loop);
|
|
|
|
pa_proplist_free(propList);
|
|
|
|
|
|
|
|
err_thread:
|
|
|
|
pa_threaded_mainloop_stop(pa.loop);
|
|
|
|
|
|
|
|
err_loop:
|
|
|
|
pa_threaded_mainloop_free(pa.loop);
|
|
|
|
|
|
|
|
err:
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
2022-02-20 16:38:58 +00:00
|
|
|
static void pulseaudio_sink_close_nl(void)
|
2021-12-26 07:21:21 +00:00
|
|
|
{
|
|
|
|
if (!pa.sink)
|
|
|
|
return;
|
|
|
|
|
|
|
|
pa_stream_set_write_callback(pa.sink, NULL, NULL);
|
|
|
|
pa_stream_flush(pa.sink, NULL, NULL);
|
|
|
|
pa_stream_unref(pa.sink);
|
|
|
|
pa.sink = NULL;
|
|
|
|
}
|
|
|
|
|
|
|
|
static void pulseaudio_free(void)
|
|
|
|
{
|
|
|
|
pa_threaded_mainloop_lock(pa.loop);
|
|
|
|
|
|
|
|
pulseaudio_sink_close_nl();
|
|
|
|
|
|
|
|
pa_context_set_state_callback(pa.context, NULL, NULL);
|
|
|
|
pa_context_set_subscribe_callback(pa.context, NULL, NULL);
|
|
|
|
pa_context_disconnect(pa.context);
|
|
|
|
pa_context_unref(pa.context);
|
|
|
|
|
|
|
|
if (pa.contextSub)
|
|
|
|
{
|
|
|
|
pa_operation_unref(pa.contextSub);
|
|
|
|
pa.contextSub = NULL;
|
|
|
|
}
|
|
|
|
|
|
|
|
pa_threaded_mainloop_unlock(pa.loop);
|
|
|
|
}
|
|
|
|
|
2022-02-20 18:50:30 +00:00
|
|
|
static void pulseaudio_state_cb(pa_stream * p, void * userdata)
|
|
|
|
{
|
|
|
|
if (pa.sinkStarting && pa_stream_get_state(pa.sink) == PA_STREAM_READY)
|
|
|
|
{
|
|
|
|
pa_stream_cork(pa.sink, 0, NULL, NULL);
|
|
|
|
pa.sinkCorked = false;
|
|
|
|
pa.sinkStarting = false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-12-26 07:21:21 +00:00
|
|
|
static void pulseaudio_write_cb(pa_stream * p, size_t nbytes, void * userdata)
|
|
|
|
{
|
[client] audio: adjust playback speed to match audio device clock
This change is based on the techniques described in [1] and [2].
The input audio stream from Spice is not synchronised to the audio playback
device. While the input and output may be both nominally running at 48 kHz,
when compared against each other, they will differ by a tiny fraction of a
percent. Given enough time (typically on the order of a few hours), this
will result in the ring buffer becoming completely full or completely
empty. It will stay in this state permanently, periodically resulting in
glitches as the buffer repeatedly underruns or overruns.
To address this, adjust the speed of the received data to match the rate at
which it is being consumed by the audio device. This will result in a
slight pitch shift, but the changes should be small and smooth enough that
this is unnoticeable to the user.
The process works roughly as follows:
1. Every time audio data is received from Spice, or consumed by the audio
device, sample the current time. These are fed into a pair of delay
locked loops to produce smoothed approximations of the two clocks.
2. Compute the difference between the two clocks and compare this against
the target latency to produce an error value. This error value will be
quite stable during normal operation, but can change quite rapidly due
to external factors, particularly at the start of playback. To smooth
out any sudden changes in playback speed, which would be noticeable to
the user, this value is also filtered through another delay locked loop.
3. Feed this error value into a PI controller to produce a ratio value.
This is the target playback speed in order to bring the error value
towards zero.
4. Resample the input audio using the computed ratio to apply the speed
change. The output of the resampler is what is ultimately inserted into
the ring buffer for consumption by the audio device.
Since this process targets a specific latency value, rather than simply
trying to rate match the input and output, it also has the effect of
'correcting' latency issues. If a high latency application (such as a media
player) is already running, the time between requesting the start of
playback and the audio device actually starting to consume samples can be
very high, easily in the hundreds of milliseconds. The changes here will
automatically adjust the playback speed over the course of a few minutes to
bring the latency back down to the target value.
[1] https://kokkinizita.linuxaudio.org/papers/adapt-resamp.pdf
[2] https://kokkinizita.linuxaudio.org/papers/usingdll.pdf
2022-01-26 20:55:24 +00:00
|
|
|
// PulseAudio tries to pull data from the stream as soon as it is created for
|
|
|
|
// some reason, even though it is corked
|
|
|
|
if (pa.sinkCorked)
|
|
|
|
return;
|
|
|
|
|
2022-01-18 23:29:49 +00:00
|
|
|
uint8_t * dst;
|
2021-12-26 07:21:21 +00:00
|
|
|
|
|
|
|
pa_stream_begin_write(p, (void **)&dst, &nbytes);
|
|
|
|
|
2022-01-17 22:02:44 +00:00
|
|
|
int frames = nbytes / pa.sinkStride;
|
2022-01-18 23:29:49 +00:00
|
|
|
frames = pa.sinkPullFn(dst, frames);
|
2021-12-26 07:21:21 +00:00
|
|
|
|
|
|
|
pa_stream_write(p, dst, frames * pa.sinkStride, NULL, 0, PA_SEEK_RELATIVE);
|
|
|
|
}
|
|
|
|
|
|
|
|
static void pulseaudio_underflow_cb(pa_stream * p, void * userdata)
|
|
|
|
{
|
|
|
|
DEBUG_WARN("Underflow");
|
|
|
|
}
|
|
|
|
|
|
|
|
static void pulseaudio_overflow_cb(pa_stream * p, void * userdata)
|
|
|
|
{
|
|
|
|
DEBUG_WARN("Overflow");
|
|
|
|
}
|
|
|
|
|
2022-01-17 22:02:44 +00:00
|
|
|
static void pulseaudio_setup(int channels, int sampleRate,
|
2022-01-29 19:23:28 +00:00
|
|
|
int * maxPeriodFrames, LG_AudioPullFn pullFn)
|
2021-12-26 07:21:21 +00:00
|
|
|
{
|
|
|
|
if (pa.sink && pa.sinkChannels == channels && pa.sinkSampleRate == sampleRate)
|
2022-01-29 19:23:28 +00:00
|
|
|
{
|
2022-02-05 09:53:06 +00:00
|
|
|
*maxPeriodFrames = pa.sinkMaxPeriodFrames;
|
2021-12-26 07:21:21 +00:00
|
|
|
return;
|
2022-01-29 19:23:28 +00:00
|
|
|
}
|
2021-12-26 07:21:21 +00:00
|
|
|
|
|
|
|
//TODO: be smarter about this
|
|
|
|
const int PERIOD_LEN = 80;
|
|
|
|
|
|
|
|
pa_sample_spec spec = {
|
[client] audio: adjust playback speed to match audio device clock
This change is based on the techniques described in [1] and [2].
The input audio stream from Spice is not synchronised to the audio playback
device. While the input and output may be both nominally running at 48 kHz,
when compared against each other, they will differ by a tiny fraction of a
percent. Given enough time (typically on the order of a few hours), this
will result in the ring buffer becoming completely full or completely
empty. It will stay in this state permanently, periodically resulting in
glitches as the buffer repeatedly underruns or overruns.
To address this, adjust the speed of the received data to match the rate at
which it is being consumed by the audio device. This will result in a
slight pitch shift, but the changes should be small and smooth enough that
this is unnoticeable to the user.
The process works roughly as follows:
1. Every time audio data is received from Spice, or consumed by the audio
device, sample the current time. These are fed into a pair of delay
locked loops to produce smoothed approximations of the two clocks.
2. Compute the difference between the two clocks and compare this against
the target latency to produce an error value. This error value will be
quite stable during normal operation, but can change quite rapidly due
to external factors, particularly at the start of playback. To smooth
out any sudden changes in playback speed, which would be noticeable to
the user, this value is also filtered through another delay locked loop.
3. Feed this error value into a PI controller to produce a ratio value.
This is the target playback speed in order to bring the error value
towards zero.
4. Resample the input audio using the computed ratio to apply the speed
change. The output of the resampler is what is ultimately inserted into
the ring buffer for consumption by the audio device.
Since this process targets a specific latency value, rather than simply
trying to rate match the input and output, it also has the effect of
'correcting' latency issues. If a high latency application (such as a media
player) is already running, the time between requesting the start of
playback and the audio device actually starting to consume samples can be
very high, easily in the hundreds of milliseconds. The changes here will
automatically adjust the playback speed over the course of a few minutes to
bring the latency back down to the target value.
[1] https://kokkinizita.linuxaudio.org/papers/adapt-resamp.pdf
[2] https://kokkinizita.linuxaudio.org/papers/usingdll.pdf
2022-01-26 20:55:24 +00:00
|
|
|
.format = PA_SAMPLE_FLOAT32,
|
2021-12-26 07:21:21 +00:00
|
|
|
.rate = sampleRate,
|
|
|
|
.channels = channels
|
|
|
|
};
|
|
|
|
|
|
|
|
pa_buffer_attr attribs =
|
|
|
|
{
|
|
|
|
.maxlength = pa_usec_to_bytes((PERIOD_LEN * 2) * PA_USEC_PER_MSEC, &spec),
|
|
|
|
.tlength = pa_usec_to_bytes(PERIOD_LEN * PA_USEC_PER_MSEC, &spec),
|
|
|
|
.prebuf = 0,
|
|
|
|
.fragsize = pa_usec_to_bytes(PERIOD_LEN * PA_USEC_PER_MSEC, &spec),
|
|
|
|
.minreq = (uint32_t)-1
|
|
|
|
};
|
|
|
|
|
|
|
|
pa_threaded_mainloop_lock(pa.loop);
|
|
|
|
pulseaudio_sink_close_nl();
|
|
|
|
|
|
|
|
pa.sinkChannels = channels;
|
|
|
|
pa.sinkSampleRate = sampleRate;
|
|
|
|
|
|
|
|
pa.sink = pa_stream_new(pa.context, "Looking Glass", &spec, NULL);
|
2022-02-20 18:50:30 +00:00
|
|
|
pa_stream_set_state_callback (pa.sink, pulseaudio_state_cb , NULL);
|
2021-12-26 07:21:21 +00:00
|
|
|
pa_stream_set_write_callback (pa.sink, pulseaudio_write_cb , NULL);
|
|
|
|
pa_stream_set_underflow_callback(pa.sink, pulseaudio_underflow_cb, NULL);
|
|
|
|
pa_stream_set_overflow_callback (pa.sink, pulseaudio_overflow_cb , NULL);
|
|
|
|
|
|
|
|
pa_stream_connect_playback(pa.sink, NULL, &attribs,
|
|
|
|
PA_STREAM_START_CORKED | PA_STREAM_ADJUST_LATENCY,
|
|
|
|
NULL, NULL);
|
|
|
|
|
2022-02-05 09:53:06 +00:00
|
|
|
pa.sinkStride = channels * sizeof(float);
|
|
|
|
pa.sinkPullFn = pullFn;
|
|
|
|
pa.sinkMaxPeriodFrames = attribs.tlength / pa.sinkStride;
|
|
|
|
pa.sinkCorked = true;
|
2022-02-20 18:50:30 +00:00
|
|
|
pa.sinkStarting = false;
|
2021-12-26 07:21:21 +00:00
|
|
|
|
2022-02-05 09:53:06 +00:00
|
|
|
*maxPeriodFrames = pa.sinkMaxPeriodFrames;
|
2022-01-29 19:23:28 +00:00
|
|
|
|
2021-12-26 07:21:21 +00:00
|
|
|
pa_threaded_mainloop_unlock(pa.loop);
|
|
|
|
}
|
|
|
|
|
2022-02-05 09:53:06 +00:00
|
|
|
static void pulseaudio_start(void)
|
2021-12-26 07:21:21 +00:00
|
|
|
{
|
|
|
|
if (!pa.sink)
|
2022-02-05 09:53:06 +00:00
|
|
|
return;
|
2021-12-26 07:21:21 +00:00
|
|
|
|
2022-01-17 22:02:44 +00:00
|
|
|
pa_threaded_mainloop_lock(pa.loop);
|
2022-02-20 18:50:30 +00:00
|
|
|
|
|
|
|
pa_stream_state_t state = pa_stream_get_state(pa.sink);
|
|
|
|
if (state == PA_STREAM_CREATING)
|
|
|
|
pa.sinkStarting = true;
|
|
|
|
else
|
|
|
|
{
|
|
|
|
pa_stream_cork(pa.sink, 0, NULL, NULL);
|
|
|
|
pa.sinkCorked = false;
|
|
|
|
}
|
|
|
|
|
2022-01-17 22:02:44 +00:00
|
|
|
pa_threaded_mainloop_unlock(pa.loop);
|
2021-12-26 07:21:21 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
static void pulseaudio_stop(void)
|
|
|
|
{
|
|
|
|
if (!pa.sink)
|
|
|
|
return;
|
|
|
|
|
2022-02-20 16:51:33 +00:00
|
|
|
bool needLock = !pa_threaded_mainloop_in_thread(pa.loop);
|
|
|
|
if (needLock)
|
|
|
|
pa_threaded_mainloop_lock(pa.loop);
|
|
|
|
|
2021-12-26 07:21:21 +00:00
|
|
|
pa_stream_cork(pa.sink, 1, NULL, NULL);
|
2022-02-20 18:50:30 +00:00
|
|
|
pa.sinkCorked = true;
|
|
|
|
pa.sinkStarting = false;
|
2022-02-20 16:51:33 +00:00
|
|
|
|
|
|
|
if (needLock)
|
|
|
|
pa_threaded_mainloop_unlock(pa.loop);
|
2021-12-26 07:21:21 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
static void pulseaudio_volume(int channels, const uint16_t volume[])
|
|
|
|
{
|
|
|
|
if (!pa.sink || !pa.sinkIndex)
|
|
|
|
return;
|
|
|
|
|
|
|
|
struct pa_cvolume v = { .channels = channels };
|
|
|
|
for(int i = 0; i < channels; ++i)
|
|
|
|
v.values[i] = pa_sw_volume_from_linear(
|
|
|
|
9.3234e-7 * pow(1.000211902, volume[i]) - 0.000172787);
|
|
|
|
|
|
|
|
pa_threaded_mainloop_lock(pa.loop);
|
|
|
|
pa_context_set_sink_input_volume(pa.context, pa.sinkIndex, &v, NULL, NULL);
|
|
|
|
pa_threaded_mainloop_unlock(pa.loop);
|
|
|
|
}
|
|
|
|
|
|
|
|
static void pulseaudio_mute(bool mute)
|
|
|
|
{
|
|
|
|
if (!pa.sink || !pa.sinkIndex || pa.sinkMuted == mute)
|
|
|
|
return;
|
|
|
|
|
|
|
|
pa.sinkMuted = mute;
|
|
|
|
pa_threaded_mainloop_lock(pa.loop);
|
|
|
|
pa_context_set_sink_input_mute(pa.context, pa.sinkIndex, mute, NULL, NULL);
|
|
|
|
pa_threaded_mainloop_unlock(pa.loop);
|
|
|
|
}
|
|
|
|
|
|
|
|
struct LG_AudioDevOps LGAD_PulseAudio =
|
|
|
|
{
|
|
|
|
.name = "PulseAudio",
|
|
|
|
.init = pulseaudio_init,
|
|
|
|
.free = pulseaudio_free,
|
2022-01-06 11:47:22 +00:00
|
|
|
.playback =
|
|
|
|
{
|
2022-01-17 22:02:44 +00:00
|
|
|
.setup = pulseaudio_setup,
|
2022-01-06 11:47:22 +00:00
|
|
|
.start = pulseaudio_start,
|
|
|
|
.stop = pulseaudio_stop,
|
|
|
|
.volume = pulseaudio_volume,
|
|
|
|
.mute = pulseaudio_mute
|
|
|
|
}
|
2021-12-26 07:21:21 +00:00
|
|
|
};
|