#703

Noisy audio since 2.0.3: continuous-audio buffer turns WASAPI timestamp jitter into ~90 gaps per second

Openaliflt-laglopened 5d ago

Bug Symptoms

After upgrading from 2.0.1 to 2.0.4, the streamed audio is noisy and crackles constantly. Video is fine, and 2.0.1 does not have the problem. Client: Moonlight TV on webOS (LG 85QNED93A), stereo.

Cause (measured, details below): since 2.0.3, Vibepollo honours the continuousAudio=1 launch parameter, which Moonlight TV always sends. That enables continuous_buffer_t, which writes each WASAPI loopback packet at the frame derived from that packet's pu64QPCPosition. On this PC the per-packet QPC positions jitter by up to ±47 frames, while u64DevicePosition is perfectly contiguous. Each jittered packet either leaves a zero-filled hole or overwrites the end of the previous packet, which gives about 90 discontinuities per second.

Evidence 1: WASAPI loopback timestamps on the affected PC

I recorded 15 s with an event-driven, shared-mode loopback client on the endpoint Vibepollo captures. The endpoint is Steam Streaming Speakers (the default device during the stream), mix format float32 48 kHz stereo, device period 10 ms. The probe ran in the interactive session while a stream was playing audio.

MeasureValue
Packets1501 × 480 frames
Gaps in u64DevicePosition0
QPC step minus 480 framesmedian +0.05, p5 -26.0, p95 +26.2, min -46.8, max +47.5 frames
Drift of QPC position vs device position0.0 ms over 15 s
QPC position minus the time GetBuffer returnedmedian +24.9 ms (range +20.5 to +25.0)

Evidence 2: replay through the unmodified 2.0.4 buffer

I compiled audio_continuous_buffer.h from 2.0.4 unchanged (gcc 14, -std=c++20) and replayed the recorded packets. Each packet becomes available at its recorded read time, and the loop follows sample(): drain, take() if ready, otherwise wait for the next packet or wait_ms(). The sample values are a strictly increasing counter, so any break in the output is an artifact.

InputPacket sizeZero-filled holesForward skips
Recorded QPC positions240 frames692 in 14.95 s (46.3/s), 1.22% of output samples655 (43.8/s), 8764 samples lost
Recorded QPC positions480 frames691 (46.3/s), 1.22%655 (43.9/s), 8764 samples lost
Control: QPC positions rebuilt from the device position240 frames00

The buffer is correct for evenly spaced timestamps. The noise comes from treating per-packet QPC positions as sample-accurate.

Replay harness (replay.cpp)
// Replays measured WASAPI loopback packets through Vibepollo 2.0.4's continuous_buffer_t,
// mirroring mic_wasapi_t::sample(): drain, take if ready, else wait for the next event or wait_ms.
// Input CSV columns: read_time_100ns,frames,flags,device_position,qpc_position_100ns,peak
#include "audio_continuous_buffer.h"
#include <cstdio>
#include <fstream>
#include <sstream>
#include <string>
#include <vector>
struct Pkt { long long read, qpc; unsigned frames; };
int main(int argc, char **argv) {
  unsigned packet_frames = std::stoi(argv[2]);
  std::ifstream in(argv[1]); std::string line; std::vector<Pkt> p;
  while (std::getline(in, line)) {
    if (line.empty() || line[0] == '#' || line[0] == 'n' || line.find("timeout") != std::string::npos) continue;
    std::stringstream s(line); std::string f[6]; for (auto &x : f) std::getline(s, x, ',');
    p.push_back({std::stoll(f[0]), std::stoll(f[4]), (unsigned) std::stoul(f[1])});
  }
  const long long origin = p[0].read - 100000;
  platf::audio_detail::continuous_buffer_t buf(48000, 1, packet_frames, 480, origin);
  std::vector<float> out, pkt(packet_frames), data;
  float counter = 1;
  size_t idx = 0; long long t = origin;
  const long long end = p.back().read;
  while (t < end) {
    while (idx < p.size() && p[idx].read <= t) {  // _fill_buffer(0): drain available packets
      data.resize(p[idx].frames);
      for (auto &v : data) v = counter++;
      buf.put(p[idx].qpc, p[idx].frames, data.data(), false);
      ++idx;
    }
    buf.advance(t);
    if (buf.take(t, pkt)) { out.insert(out.end(), pkt.begin(), pkt.end()); continue; }
    long long wait = std::min<long long>(10, buf.wait_ms(t)) * 10000;
    long long next = idx < p.size() ? p[idx].read : t + wait;
    t = std::max(t + 1, std::min(t + wait, next));
  }
  size_t first = 0; while (first < out.size() && out[first] == 0) ++first;
  long long zeros = 0, jumps_fwd = 0, jumps_back = 0, holes = 0, dropped = 0;
  float prev = 0; bool in_hole = false;
  for (size_t i = first; i < out.size(); ++i) {
    float v = out[i];
    if (v == 0) { ++zeros; if (!in_hole) { ++holes; in_hole = true; } continue; }
    in_hole = false;
    if (prev != 0 && v != prev + 1) { if (v > prev + 1) { ++jumps_fwd; dropped += (long long)(v - prev - 1); } else { ++jumps_back; } }
    prev = v;
  }
  double secs = (out.size() - first) / 48000.0;
  std::printf("packet=%u: %.2fs | holes %lld (%.1f/s, %lld zero samples) | skips %lld (%.1f/s, %lld lost) | rewinds %lld\n",
    packet_frames, secs, holes, holes / secs, zeros, jumps_fwd, jumps_fwd / secs, dropped, jumps_back);
}

Where it comes from

  • Packets are placed by QPC position in put(), called from audio.cpp#L897-L900.
  • The buffer was added in e7499d55d8a169e40de42568ffb373a195446292 ("fix(audio): Keep continuous loopback audio on one output clock"). It shipped together with the continuousAudio parsing, so every release that honours continuous audio (2.0.3, 2.0.4) has it.
  • Before that, as in 2.0.1 and in upstream Sunshine's sample(), captured packets are appended contiguously and silence is only inserted on a capture timeout, so timestamp jitter cannot create gaps.
  • Moonlight TV always requests continuous audio (client.c#L559), and Vibepollo has no host setting to ignore it, so webOS users cannot avoid this path.

Possible fixes (not tested)

  • Place captured audio by u64DevicePosition, which had no gaps here, and resync to the QPC clock only on AUDCLNT_BUFFERFLAGS_DATA_DISCONTINUITY, a device-position gap, or an offset well above one device period.
  • Or append packets contiguously as before, and use the QPC clock only to decide when to synthesize silence.
  • As a stopgap, a host option to ignore the client's continuous-audio request.

Expected Behavior

Clean audio as in 2.0.1, while still sending silence packets when the host is quiet.

Windows Version

Windows 11 Pro 25H2 (build 26200.8524)

Package

Windows - exe installer

GPU Type

NVIDIA

GPU Model

RTX 3070 Ti

GPU Driver Version

621.01

Capture Method

Not relevant (audio issue)

Apps

undefined

Logs (Optional but Recommended)

Audio lines from a 2.0.4 session with the noise (no audio warnings or errors were logged):

[2026-11-02 17:25:02.455]: Info: Client requested continuous audio
[2026-11-02 17:25:02.936]: Info: Selected audio sink: virtual-Stereo{0.0.0.00000000}.{a1f50aad-c061-4b47-9233-74ec9afab2aa}
[2026-11-02 17:25:02.938]: Info: Virtual audio device will use 16-bit to match default device
[2026-11-02 17:25:02.952]: Info: Changed virtual audio sink format to [S16 48000 2.0]
[2026-11-02 17:25:03.059]: Info: Audio mixer format is 32-bit, 48000 Hz, no resampling needed
[2026-11-02 17:25:03.074]: Info: Audio capture format is [F32 48000 2.0]
[2026-11-02 17:25:03.093]: Info: Opus initialized: 48 kHz, 2 channels, 512 kbps (total), LOWDELAY

Screenshot (Optional)

undefined

Activity

3
rhoquinn82174d ago

I see the same on my PC, with the same cause.

Setup: Vibepollo 2.0.4 (18400719), Windows 11 26H2 (build 26300), RTX 4090. The stream's sink is Steam Streaming Speakers (float32, 48 kHz, stereo, 10 ms device period). The client is a fork of Moonlight TV on an LG C1 (webOS 6), stereo, which also sends continuousAudio=1. 2.0.1 was clean with the same PC and client.

Measured with an event-driven, shared-mode loopback client of my own on that endpoint, beside Vibepollo's capture, while a video played (the same method as above):

MeasureValue
Packets1501 × 480 frames in 15 s
Gaps in u64DevicePosition0
QPC step minus 480 framesmedian +2.5, min -46.8, max +47.6 frames; 1461 of 1500 steps a frame or more off
QPC error per packet, around a straight line fitted to the device positionsd 14.7 frames (0.31 ms), from -21.6 to +28.8 frames (a second run, 11 s of sound)
Drift between the device clock and QPC, from the same fit+6 ppm (rough: 11 s of sound)
Replay through 2.0.4's continuous_buffer_t (ported line by line, a counter as the sample value)981 holes (65.6/s) and 498 skips (33.3/s) in 15 s; 1.36% of the output is inserted silence
Control: the same replay with timestamps rebuilt from the device position0 holes, 0 skips

Client side: once the client stopped sending continuousAudio=1, 2.0.4 took the contiguous path and the sound was clean by ear. So the noise comes from the continuous path alone.

Attached: vibepollo-703-timings.csv, 1163 packets (1124 with sound), in the columns of the replay harness above: read_time_100ns,frames,flags,device_position,qpc_position_100ns,peak. There is no audio in it.

Analysis assisted by Claude Code.

vibepollo-703-timings.csv

caioavidal3d ago

Same issue here

Argyriss3d ago

Same issue here after upgrading from 2.0.1