mirror of
https://github.com/gnif/LookingGlass.git
synced 2026-08-02 13:22:02 +00:00
Model playback latency from the device period, arrival jitter, source packet phase, and resampler delay. Treat audio:latencyOffset as an explicit addition to this minimum and align the first device pull to the next packet deadline. This starts playback near its steady-state target without unnecessary prefill or startup underruns. Use a 512-frame default period as a practical low-latency baseline. Replace the startup clock hold with a one-sided proportional acquisition controller, then hand off to source/device rate feed-forward and a slow phase loop. Calibrate the logical device timeline at handoff, discard correction that opposes the current error, and slew-limit rate changes. This prevents startup drain, integral wind-up, overshoot, and long convergence while preserving clock-drift compensation. Allow audio backends to expose a real-time resampler and use PipeWire's adaptive resampler when version 1.4 or newer supports it. Retain libsamplerate as the fallback and add audio:resampler to select the implementation. Wait for PipeWire stream setup to complete and propagate rate-control failures cleanly. Track PipeWire input-consumption and output-equivalent clocks separately. The input clock measures ring latency while the output clock drives feed-forward using the ratio that governed each request. This removes delayed self-feedback that made adaptive resampling oscillate between the correction limits. Reduce audio:debug output to useful latency, clock, jitter, and xrun values, and scale the playback graph from the startup estimate. Update the option names and documentation for the new latency model.
500 lines
13 KiB
C
500 lines
13 KiB
C
/**
|
|
* Looking Glass
|
|
* Copyright © 2017-2026 The Looking Glass Authors
|
|
* 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 <stdatomic.h>
|
|
|
|
#include "common/debug.h"
|
|
#include "common/time.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;
|
|
bool sinkStarting;
|
|
int sinkMaxPeriodFrames;
|
|
int sinkStartFrames;
|
|
int sinkSampleRate;
|
|
int sinkChannels;
|
|
int sinkStride;
|
|
LG_AudioPullFn sinkPullFn;
|
|
_Atomic(int64_t) sinkPresentationDeadline;
|
|
};
|
|
|
|
static struct PulseAudio pa = {0};
|
|
|
|
static void pulseaudio_unrefOperation(pa_operation * operation)
|
|
{
|
|
if (operation)
|
|
pa_operation_unref(operation);
|
|
}
|
|
|
|
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);
|
|
pulseaudio_unrefOperation(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;
|
|
}
|
|
|
|
static void pulseaudio_sink_close_nl(void)
|
|
{
|
|
if (!pa.sink)
|
|
return;
|
|
|
|
pa_stream_set_state_callback(pa.sink, NULL, NULL);
|
|
pa_stream_set_write_callback(pa.sink, NULL, NULL);
|
|
pa_stream_set_underflow_callback(pa.sink, NULL, NULL);
|
|
pa_stream_set_overflow_callback(pa.sink, NULL, NULL);
|
|
pulseaudio_unrefOperation(pa_stream_flush(pa.sink, NULL, NULL));
|
|
pa_stream_unref(pa.sink);
|
|
pa.sink = NULL;
|
|
atomic_store_explicit(
|
|
&pa.sinkPresentationDeadline, 0, memory_order_release);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
static void pulseaudio_state_cb(pa_stream * p, void * userdata)
|
|
{
|
|
if (pa.sinkStarting && pa_stream_get_state(pa.sink) == PA_STREAM_READY)
|
|
{
|
|
pulseaudio_unrefOperation(pa_stream_cork(pa.sink, 0, NULL, NULL));
|
|
pa.sinkCorked = false;
|
|
pa.sinkStarting = false;
|
|
}
|
|
|
|
pa_threaded_mainloop_signal(pa.loop, 0);
|
|
}
|
|
|
|
static void pulseaudio_write_cb(pa_stream * p, size_t nbytes, void * userdata)
|
|
{
|
|
// 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;
|
|
|
|
uint8_t * dst;
|
|
|
|
if (pa_stream_begin_write(p, (void **)&dst, &nbytes) < 0)
|
|
{
|
|
DEBUG_ERROR("pa_stream_begin_write failed: %s",
|
|
pa_strerror(pa_context_errno(pa.context)));
|
|
return;
|
|
}
|
|
|
|
int frames = nbytes / pa.sinkStride;
|
|
frames = pa.sinkPullFn(dst, frames);
|
|
if (frames <= 0)
|
|
{
|
|
pa_stream_cancel_write(p);
|
|
return;
|
|
}
|
|
|
|
if (pa_stream_write(
|
|
p, dst, frames * pa.sinkStride, NULL, 0, PA_SEEK_RELATIVE) < 0)
|
|
{
|
|
DEBUG_ERROR("pa_stream_write failed: %s",
|
|
pa_strerror(pa_context_errno(pa.context)));
|
|
pa_stream_cancel_write(p);
|
|
return;
|
|
}
|
|
|
|
pa_usec_t latency;
|
|
int negative;
|
|
if (pa_stream_get_latency(p, &latency, &negative) == 0)
|
|
{
|
|
const int64_t latencyNs = negative ? 0 : (int64_t)latency * 1000;
|
|
atomic_store_explicit(&pa.sinkPresentationDeadline,
|
|
(int64_t)nanotime() + latencyNs, memory_order_release);
|
|
}
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
static bool pulseaudio_setup(int channels, int sampleRate,
|
|
int requestedPeriodFrames, bool requestResampler,
|
|
bool * resamplerEnabled, int * maxPeriodFrames, int * startFrames,
|
|
LG_AudioPullFn pullFn)
|
|
{
|
|
*resamplerEnabled = false;
|
|
|
|
if (pa.sink && pa.sinkChannels == channels && pa.sinkSampleRate == sampleRate)
|
|
{
|
|
*maxPeriodFrames = pa.sinkMaxPeriodFrames;
|
|
*startFrames = pa.sinkStartFrames;
|
|
return true;
|
|
}
|
|
|
|
pa_sample_spec spec = {
|
|
.format = PA_SAMPLE_FLOAT32,
|
|
.rate = sampleRate,
|
|
.channels = channels
|
|
};
|
|
|
|
int stride = channels * sizeof(float);
|
|
int bufferSize = requestedPeriodFrames * 2 * stride;
|
|
pa_buffer_attr attribs =
|
|
{
|
|
.maxlength = -1,
|
|
.tlength = bufferSize,
|
|
.prebuf = 0,
|
|
.minreq = (uint32_t)-1
|
|
};
|
|
|
|
pa_threaded_mainloop_lock(pa.loop);
|
|
pulseaudio_sink_close_nl();
|
|
|
|
pa.sinkChannels = channels;
|
|
pa.sinkSampleRate = sampleRate;
|
|
pa.sinkStride = stride;
|
|
pa.sinkPullFn = pullFn;
|
|
pa.sinkCorked = true;
|
|
pa.sinkStarting = false;
|
|
|
|
pa.sink = pa_stream_new(pa.context, "Looking Glass", &spec, NULL);
|
|
if (!pa.sink)
|
|
{
|
|
DEBUG_ERROR("Failed to create PulseAudio stream: %s",
|
|
pa_strerror(pa_context_errno(pa.context)));
|
|
pa_threaded_mainloop_unlock(pa.loop);
|
|
return false;
|
|
}
|
|
|
|
pa_stream_set_state_callback (pa.sink, pulseaudio_state_cb , NULL);
|
|
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);
|
|
|
|
const pa_stream_flags_t flags =
|
|
PA_STREAM_START_CORKED |
|
|
PA_STREAM_ADJUST_LATENCY |
|
|
PA_STREAM_INTERPOLATE_TIMING |
|
|
PA_STREAM_AUTO_TIMING_UPDATE;
|
|
if (pa_stream_connect_playback(
|
|
pa.sink, NULL, &attribs, flags, NULL, NULL) < 0)
|
|
{
|
|
DEBUG_ERROR("Failed to connect PulseAudio stream: %s",
|
|
pa_strerror(pa_context_errno(pa.context)));
|
|
pulseaudio_sink_close_nl();
|
|
pa_threaded_mainloop_unlock(pa.loop);
|
|
return false;
|
|
}
|
|
|
|
while (pa_stream_get_state(pa.sink) == PA_STREAM_CREATING)
|
|
pa_threaded_mainloop_wait(pa.loop);
|
|
|
|
if (pa_stream_get_state(pa.sink) != PA_STREAM_READY)
|
|
{
|
|
DEBUG_ERROR("PulseAudio stream did not become ready: %s",
|
|
pa_strerror(pa_context_errno(pa.context)));
|
|
pulseaudio_sink_close_nl();
|
|
pa_threaded_mainloop_unlock(pa.loop);
|
|
return false;
|
|
}
|
|
|
|
const pa_buffer_attr * actual = pa_stream_get_buffer_attr(pa.sink);
|
|
const uint64_t minRequestFrames =
|
|
actual && actual->minreq != UINT32_MAX ?
|
|
actual->minreq / (uint64_t)stride : requestedPeriodFrames;
|
|
const uint64_t targetFrames =
|
|
actual && actual->tlength != UINT32_MAX ?
|
|
actual->tlength / (uint64_t)stride :
|
|
(uint64_t)requestedPeriodFrames * 2;
|
|
const int actualMinRequest =
|
|
clamp(minRequestFrames, UINT64_C(1), (uint64_t)INT_MAX);
|
|
const int actualTarget =
|
|
clamp(max(targetFrames, minRequestFrames),
|
|
UINT64_C(1), (uint64_t)INT_MAX);
|
|
|
|
pa.sinkMaxPeriodFrames = actualMinRequest;
|
|
pa.sinkStartFrames = actualTarget;
|
|
|
|
*maxPeriodFrames = pa.sinkMaxPeriodFrames;
|
|
*startFrames = pa.sinkStartFrames;
|
|
|
|
atomic_store_explicit(
|
|
&pa.sinkPresentationDeadline, 0, memory_order_release);
|
|
pa_threaded_mainloop_unlock(pa.loop);
|
|
return true;
|
|
}
|
|
|
|
static void pulseaudio_start(void)
|
|
{
|
|
if (!pa.sink)
|
|
return;
|
|
|
|
pa_threaded_mainloop_lock(pa.loop);
|
|
|
|
pa_stream_state_t state = pa_stream_get_state(pa.sink);
|
|
if (state == PA_STREAM_CREATING)
|
|
pa.sinkStarting = true;
|
|
else
|
|
{
|
|
pulseaudio_unrefOperation(pa_stream_cork(pa.sink, 0, NULL, NULL));
|
|
pa.sinkCorked = false;
|
|
}
|
|
|
|
pa_threaded_mainloop_unlock(pa.loop);
|
|
}
|
|
|
|
static void pulseaudio_stop(void)
|
|
{
|
|
if (!pa.sink)
|
|
return;
|
|
|
|
bool needLock = !pa_threaded_mainloop_in_thread(pa.loop);
|
|
if (needLock)
|
|
pa_threaded_mainloop_lock(pa.loop);
|
|
|
|
pulseaudio_unrefOperation(pa_stream_cork(pa.sink, 1, NULL, NULL));
|
|
pa.sinkCorked = true;
|
|
pa.sinkStarting = false;
|
|
atomic_store_explicit(
|
|
&pa.sinkPresentationDeadline, 0, memory_order_release);
|
|
|
|
if (needLock)
|
|
pa_threaded_mainloop_unlock(pa.loop);
|
|
}
|
|
|
|
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);
|
|
pulseaudio_unrefOperation(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);
|
|
pulseaudio_unrefOperation(pa_context_set_sink_input_mute(
|
|
pa.context, pa.sinkIndex, mute, NULL, NULL));
|
|
pa_threaded_mainloop_unlock(pa.loop);
|
|
}
|
|
|
|
static uint64_t pulseaudio_latency(void)
|
|
{
|
|
const int64_t deadline = atomic_load_explicit(
|
|
&pa.sinkPresentationDeadline, memory_order_acquire);
|
|
if (deadline <= 0)
|
|
return 0;
|
|
|
|
return max(INT64_C(0), deadline - (int64_t)nanotime()) / 1000;
|
|
}
|
|
|
|
struct LG_AudioDevOps LGAD_PulseAudio =
|
|
{
|
|
.name = "PulseAudio",
|
|
.init = pulseaudio_init,
|
|
.free = pulseaudio_free,
|
|
.playback =
|
|
{
|
|
.setup = pulseaudio_setup,
|
|
.start = pulseaudio_start,
|
|
.stop = pulseaudio_stop,
|
|
.volume = pulseaudio_volume,
|
|
.mute = pulseaudio_mute,
|
|
.latency = pulseaudio_latency
|
|
}
|
|
};
|