diff --git a/client/audiodevs/PipeWire/pipewire.c b/client/audiodevs/PipeWire/pipewire.c
index be121e3c..660a8509 100644
--- a/client/audiodevs/PipeWire/pipewire.c
+++ b/client/audiodevs/PipeWire/pipewire.c
@@ -26,7 +26,6 @@
 #include <math.h>
 
 #include "common/debug.h"
-#include "common/ringbuffer.h"
 #include "common/stringutils.h"
 #include "common/util.h"
 
@@ -50,11 +49,11 @@ struct PipeWire
     struct pw_stream * stream;
     struct spa_io_rate_match * rateMatch;
 
-    int    channels;
-    int    sampleRate;
-    int    stride;
+    int            channels;
+    int            sampleRate;
+    int            stride;
+    LG_AudioPullFn pullFn;
 
-    RingBuffer buffer;
     StreamState state;
   }
   playback;
@@ -63,10 +62,10 @@ struct PipeWire
   {
     struct pw_stream * stream;
 
-    int    channels;
-    int    sampleRate;
-    int    stride;
-    void (*dataFn)(uint8_t * data, size_t size);
+    int            channels;
+    int            sampleRate;
+    int            stride;
+    LG_AudioPushFn pushFn;
 
     bool   active;
   }
@@ -90,19 +89,6 @@ static void pipewire_onPlaybackProcess(void * userdata)
 {
   struct pw_buffer * pbuf;
 
-  if (!ringbuffer_getCount(pw.playback.buffer))
-  {
-    if (pw.playback.state == STREAM_STATE_FLUSHING)
-    {
-      pw_thread_loop_lock(pw.thread);
-      pw_stream_flush(pw.playback.stream, true);
-      pw.playback.state = STREAM_STATE_DRAINING;
-      pw_thread_loop_unlock(pw.thread);
-    }
-
-    return;
-  }
-
   if (!(pbuf = pw_stream_dequeue_buffer(pw.playback.stream)))
   {
     DEBUG_WARN("out of buffers");
@@ -119,8 +105,24 @@ static void pipewire_onPlaybackProcess(void * userdata)
   if (pw.playback.rateMatch && pw.playback.rateMatch->size > 0)
     frames = min(frames, pw.playback.rateMatch->size);
 
-  void * values = ringbuffer_consume(pw.playback.buffer, &frames);
-  memcpy(dst, values, frames * pw.playback.stride);
+  uint8_t * data;
+  frames = pw.playback.pullFn(&data, frames);
+  if (!frames)
+  {
+    if (pw.playback.state == STREAM_STATE_FLUSHING)
+    {
+      pw_thread_loop_lock(pw.thread);
+      pw_stream_flush(pw.playback.stream, true);
+      pw.playback.state = STREAM_STATE_DRAINING;
+      pw_thread_loop_unlock(pw.thread);
+    }
+
+    sbuf->datas[0].chunk->size = 0;
+    pw_stream_queue_buffer(pw.playback.stream, pbuf);
+    return;
+  }
+
+  memcpy(dst, data, frames * pw.playback.stride);
 
   sbuf->datas[0].chunk->offset = 0;
   sbuf->datas[0].chunk->stride = pw.playback.stride;
@@ -197,11 +199,11 @@ static void pipewire_playbackStopStream(void)
   pw_stream_destroy(pw.playback.stream);
   pw.playback.stream    = NULL;
   pw.playback.rateMatch = NULL;
-  ringbuffer_free(&pw.playback.buffer);
   pw_thread_loop_unlock(pw.thread);
 }
 
-static void pipewire_playbackStart(int channels, int sampleRate)
+static void pipewire_playbackSetup(int channels, int sampleRate,
+   LG_AudioPullFn pullFn)
 {
   const struct spa_pod * params[1];
   uint8_t buffer[1024];
@@ -226,8 +228,7 @@ static void pipewire_playbackStart(int channels, int sampleRate)
   pw.playback.channels   = channels;
   pw.playback.sampleRate = sampleRate;
   pw.playback.stride     = sizeof(uint16_t) * channels;
-  pw.playback.buffer     = ringbuffer_new(bufferFrames,
-      channels * sizeof(uint16_t));
+  pw.playback.pullFn     = pullFn;
 
   int maxLatencyFrames = bufferFrames / 2;
   char maxLatency[32];
@@ -277,28 +278,21 @@ static void pipewire_playbackStart(int channels, int sampleRate)
   pw_thread_loop_unlock(pw.thread);
 }
 
-static void pipewire_playbackPlay(uint8_t * data, size_t size)
+static void pipewire_playbackStart(void)
 {
   if (!pw.playback.stream)
     return;
 
-  ringbuffer_append(pw.playback.buffer, data, size / pw.playback.stride);
-
   if (pw.playback.state != STREAM_STATE_ACTIVE &&
     pw.playback.state != STREAM_STATE_RESTARTING)
   {
     pw_thread_loop_lock(pw.thread);
 
-    switch (pw.playback.state) {
+    switch (pw.playback.state)
+    {
       case STREAM_STATE_INACTIVE:
-        // Don't start playback until the buffer is sufficiently full to avoid
-        // glitches
-        if (ringbuffer_getCount(pw.playback.buffer) >=
-          ringbuffer_getLength(pw.playback.buffer) / 4)
-        {
-          pw_stream_set_active(pw.playback.stream, true);
-          pw.playback.state = STREAM_STATE_ACTIVE;
-        }
+        pw_stream_set_active(pw.playback.stream, true);
+        pw.playback.state = STREAM_STATE_ACTIVE;
         break;
 
       case STREAM_STATE_FLUSHING:
@@ -370,14 +364,9 @@ static void pipewire_playbackMute(bool mute)
   pw_thread_loop_unlock(pw.thread);
 }
 
-static uint64_t pipewire_playbackLatency(void)
+static size_t pipewire_playbackLatency(void)
 {
-  const int frames = ringbuffer_getCount(pw.playback.buffer);
-  if (frames == 0)
-    return 0;
-
-  // TODO: we should really include the hw latency here too
-  return (uint64_t)pw.playback.sampleRate * 1000ULL / frames;
+  return 0;
 }
 
 static void pipewire_recordStopStream(void)
@@ -408,17 +397,17 @@ static void pipewire_onRecordProcess(void * userdata)
     return;
 
   dst += sbuf->datas[0].chunk->offset;
-  pw.record.dataFn(dst,
+  pw.record.pushFn(dst,
     min(
       sbuf->datas[0].chunk->size,
-      sbuf->datas[0].maxsize - sbuf->datas[0].chunk->offset)
+      sbuf->datas[0].maxsize - sbuf->datas[0].chunk->offset) / pw.record.stride
     );
 
   pw_stream_queue_buffer(pw.record.stream, pbuf);
 }
 
 static void pipewire_recordStart(int channels, int sampleRate,
-  void (*dataFn)(uint8_t * data, size_t size))
+    LG_AudioPushFn pushFn)
 {
   const struct spa_pod * params[1];
   uint8_t buffer[1024];
@@ -439,7 +428,7 @@ static void pipewire_recordStart(int channels, int sampleRate,
   pw.record.channels   = channels;
   pw.record.sampleRate = sampleRate;
   pw.record.stride     = sizeof(uint16_t) * channels;
-  pw.record.dataFn     = dataFn;
+  pw.record.pushFn     = pushFn;
 
   pw_thread_loop_lock(pw.thread);
   pw.record.stream = pw_stream_new_simple(
@@ -526,7 +515,6 @@ static void pipewire_free(void)
   pw.loop   = NULL;
   pw.thread = NULL;
 
-  ringbuffer_free(&pw.playback.buffer);
   pw_deinit();
 }
 
@@ -537,8 +525,8 @@ struct LG_AudioDevOps LGAD_PipeWire =
   .free   = pipewire_free,
   .playback =
   {
+    .setup   = pipewire_playbackSetup,
     .start   = pipewire_playbackStart,
-    .play    = pipewire_playbackPlay,
     .stop    = pipewire_playbackStop,
     .volume  = pipewire_playbackVolume,
     .mute    = pipewire_playbackMute,
diff --git a/client/audiodevs/PulseAudio/pulseaudio.c b/client/audiodevs/PulseAudio/pulseaudio.c
index 474d9120..1721c9e4 100644
--- a/client/audiodevs/PulseAudio/pulseaudio.c
+++ b/client/audiodevs/PulseAudio/pulseaudio.c
@@ -25,7 +25,6 @@
 #include <math.h>
 
 #include "common/debug.h"
-#include "common/ringbuffer.h"
 
 struct PulseAudio
 {
@@ -42,7 +41,7 @@ struct PulseAudio
   int                    sinkSampleRate;
   int                    sinkChannels;
   int                    sinkStride;
-  RingBuffer             sinkBuffer;
+  LG_AudioPullFn         sinkPullFn;
 };
 
 static struct PulseAudio pa = {0};
@@ -221,14 +220,14 @@ static void pulseaudio_free(void)
 
 static void pulseaudio_write_cb(pa_stream * p, size_t nbytes, void * userdata)
 {
-  uint8_t * dst;
+  uint8_t * dst, * src;
 
   pa_stream_begin_write(p, (void **)&dst, &nbytes);
 
-  int frames    = nbytes / pa.sinkStride;
-  void * values = ringbuffer_consume(pa.sinkBuffer, &frames);
+  int frames = nbytes / pa.sinkStride;
+  frames = pa.sinkPullFn(&src, frames);
 
-  memcpy(dst, values, frames * pa.sinkStride);
+  memcpy(dst, src, frames * pa.sinkStride);
   pa_stream_write(p, dst, frames * pa.sinkStride, NULL, 0, PA_SEEK_RELATIVE);
 }
 
@@ -242,7 +241,8 @@ static void pulseaudio_overflow_cb(pa_stream * p, void * userdata)
   DEBUG_WARN("Overflow");
 }
 
-static void pulseaudio_start(int channels, int sampleRate)
+static void pulseaudio_setup(int channels, int sampleRate,
+    LG_AudioPullFn pullFn)
 {
   if (pa.sink && pa.sinkChannels == channels && pa.sinkSampleRate == sampleRate)
     return;
@@ -281,27 +281,22 @@ static void pulseaudio_start(int channels, int sampleRate)
     NULL, NULL);
 
   pa.sinkStride = channels * sizeof(uint16_t);
+  pa.sinkPullFn = pullFn;
   pa.sinkStart  = attribs.tlength / pa.sinkStride;
-  pa.sinkBuffer = ringbuffer_new(pa.sinkStart * 2, pa.sinkStride);
   pa.sinkCorked = true;
 
   pa_threaded_mainloop_unlock(pa.loop);
 }
 
-static void pulseaudio_play(uint8_t * data, size_t size)
+static void pulseaudio_start(void)
 {
   if (!pa.sink)
     return;
 
-  ringbuffer_append(pa.sinkBuffer, data, size / pa.sinkStride);
-
-  if (pa.sinkCorked && ringbuffer_getCount(pa.sinkBuffer) >= pa.sinkStart)
-  {
-    pa_threaded_mainloop_lock(pa.loop);
-    pa_stream_cork(pa.sink, 0, NULL, NULL);
-    pa.sinkCorked = false;
-    pa_threaded_mainloop_unlock(pa.loop);
-  }
+  pa_threaded_mainloop_lock(pa.loop);
+  pa_stream_cork(pa.sink, 0, NULL, NULL);
+  pa.sinkCorked = false;
+  pa_threaded_mainloop_unlock(pa.loop);
 }
 
 static void pulseaudio_stop(void)
@@ -348,8 +343,8 @@ struct LG_AudioDevOps LGAD_PulseAudio =
   .free   = pulseaudio_free,
   .playback =
   {
+    .setup  = pulseaudio_setup,
     .start  = pulseaudio_start,
-    .play   = pulseaudio_play,
     .stop   = pulseaudio_stop,
     .volume = pulseaudio_volume,
     .mute   = pulseaudio_mute
diff --git a/client/include/interface/audiodev.h b/client/include/interface/audiodev.h
index fb2d423b..496e3962 100644
--- a/client/include/interface/audiodev.h
+++ b/client/include/interface/audiodev.h
@@ -25,6 +25,9 @@
 #include <stdint.h>
 #include <stddef.h>
 
+typedef int  (*LG_AudioPullFn)(uint8_t ** data, int frames);
+typedef void (*LG_AudioPushFn)(uint8_t *  data, int frames);
+
 struct LG_AudioDevOps
 {
   /* internal name of the audio for debugging */
@@ -41,15 +44,13 @@ struct LG_AudioDevOps
 
   struct
   {
-    /* start the playback audio stream
+    /* setup the stream for playback but don't start it yet
      * Note: currently SPICE only supports S16 samples so always assume so
      */
-    void (*start)(int channels, int sampleRate);
+    void (*setup)(int channels, int sampleRate, LG_AudioPullFn pullFn);
 
-    /* called for each packet of output audio to play
-     * Note: size is the size of data in bytes, not frames/samples
-     */
-    void (*play)(uint8_t * data, size_t size);
+    /* called when playback is about to start */
+    void (*start)(void);
 
     /* called when SPICE reports the audio stream has stopped */
     void (*stop)(void);
@@ -70,8 +71,7 @@ struct LG_AudioDevOps
     /* start the record stream
      * Note: currently SPICE only supports S16 samples so always assume so
      */
-    void (*start)(int channels, int sampleRate,
-        void (*dataFn)(uint8_t * data, size_t size));
+    void (*start)(int channels, int sampleRate, LG_AudioPushFn pushFn);
 
     /* called when SPICE reports the audio stream has stopped */
     void (*stop)(void);
diff --git a/client/src/audio.c b/client/src/audio.c
index 53c18845..a9c5844f 100644
--- a/client/src/audio.c
+++ b/client/src/audio.c
@@ -22,6 +22,7 @@
 #include "main.h"
 #include "common/array.h"
 #include "common/util.h"
+#include "common/ringbuffer.h"
 
 #include "dynamic/audiodev.h"
 
@@ -33,10 +34,14 @@ typedef struct
 
   struct
   {
-    bool     started;
-    int      volumeChannels;
-    uint16_t volume[8];
-    bool     mute;
+    bool       setup;
+    bool       started;
+    int        volumeChannels;
+    uint16_t   volume[8];
+    bool       mute;
+    int        sampleRate;
+    int        stride;
+    RingBuffer buffer;
 
     LG_Lock     lock;
     RingBuffer  timings;
@@ -50,6 +55,7 @@ typedef struct
     int      volumeChannels;
     uint16_t volume[8];
     bool     mute;
+    int      stride;
     uint32_t time;
   }
   record;
@@ -101,6 +107,18 @@ static const char * audioGraphFormatFn(const char * name,
   return title;
 }
 
+static int playbackPullFrames(uint8_t ** data, int frames)
+{
+  LG_LOCK(audio.playback.lock);
+  if (audio.playback.buffer)
+    *data = ringbuffer_consume(audio.playback.buffer, &frames);
+  else
+    frames = 0;
+  LG_UNLOCK(audio.playback.lock);
+
+  return frames;
+}
+
 void audio_playbackStart(int channels, int sampleRate, PSAudioFormat format,
   uint32_t time)
 {
@@ -110,7 +128,7 @@ void audio_playbackStart(int channels, int sampleRate, PSAudioFormat format,
   static int lastChannels   = 0;
   static int lastSampleRate = 0;
 
-  if (audio.playback.started)
+  if (audio.playback.setup)
   {
     if (channels != lastChannels || sampleRate != lastSampleRate)
       audio.audioDev->playback.stop();
@@ -120,11 +138,18 @@ void audio_playbackStart(int channels, int sampleRate, PSAudioFormat format,
 
   LG_LOCK(audio.playback.lock);
 
+  const int bufferFrames = sampleRate / 10;
+  audio.playback.buffer = ringbuffer_new(bufferFrames,
+      channels * sizeof(uint16_t));
+
   lastChannels   = channels;
   lastSampleRate = sampleRate;
-  audio.playback.started = true;
 
-  audio.audioDev->playback.start(channels, sampleRate);
+  audio.playback.sampleRate = sampleRate;
+  audio.playback.stride     = channels * sizeof(uint16_t);
+  audio.playback.setup      = true;
+
+  audio.audioDev->playback.setup(channels, sampleRate, playbackPullFrames);
 
   // if a volume level was stored, set it before we return
   if (audio.playback.volumeChannels)
@@ -137,12 +162,9 @@ void audio_playbackStart(int channels, int sampleRate, PSAudioFormat format,
     audio.audioDev->playback.mute(audio.playback.mute);
 
   // if the audio dev can report it's latency setup a timing graph
-  if (audio.audioDev->playback.latency)
-  {
-    audio.playback.timings = ringbuffer_new(1200, sizeof(float));
-    audio.playback.graph   = app_registerGraph("PLAYBACK",
-        audio.playback.timings, 0.0f, 100.0f, audioGraphFormatFn);
-  }
+  audio.playback.timings = ringbuffer_new(1200, sizeof(float));
+  audio.playback.graph   = app_registerGraph("PLAYBACK",
+      audio.playback.timings, 0.0f, 100.0f, audioGraphFormatFn);
 
   LG_UNLOCK(audio.playback.lock);
 }
@@ -155,7 +177,9 @@ void audio_playbackStop(void)
   LG_LOCK(audio.playback.lock);
 
   audio.audioDev->playback.stop();
+  audio.playback.setup   = false;
   audio.playback.started = false;
+  ringbuffer_free(&audio.playback.buffer);
 
   if (audio.playback.timings)
   {
@@ -176,7 +200,7 @@ void audio_playbackVolume(int channels, const uint16_t volume[])
   memcpy(audio.playback.volume, volume, sizeof(uint16_t) * channels);
   audio.playback.volumeChannels = channels;
 
-  if (!audio.playback.started)
+  if (!audio.playback.setup)
     return;
 
   audio.audioDev->playback.volume(channels, volume);
@@ -189,7 +213,7 @@ void audio_playbackMute(bool mute)
 
   // store the value so we can restore it if the stream is restarted
   audio.playback.mute = mute;
-  if (!audio.playback.started)
+  if (!audio.playback.setup)
     return;
 
   audio.audioDev->playback.mute(mute);
@@ -197,10 +221,20 @@ void audio_playbackMute(bool mute)
 
 void audio_playbackData(uint8_t * data, size_t size)
 {
-  if (!audio.audioDev || !audio.playback.started)
+  if (!audio.audioDev || !audio.playback.setup)
     return;
 
-  audio.audioDev->playback.play(data, size);
+  const int frames = size / audio.playback.stride;
+  ringbuffer_append(audio.playback.buffer, data, frames);
+
+  // don't start playback until the buffer is sifficiently full to avoid
+  // glitches
+  if (!audio.playback.started && ringbuffer_getCount(audio.playback.buffer) >=
+      ringbuffer_getLength(audio.playback.buffer) / 4)
+  {
+    audio.playback.started = true;
+    audio.audioDev->playback.start();
+  }
 }
 
 bool audio_supportsRecord(void)
@@ -208,9 +242,9 @@ bool audio_supportsRecord(void)
   return audio.audioDev && audio.audioDev->record.start;
 }
 
-static void recordData(uint8_t * data, size_t size)
+static void recordPushFrames(uint8_t * data, int frames)
 {
-  purespice_writeAudio(data, size, 0);
+  purespice_writeAudio(data, frames * audio.record.stride, 0);
 }
 
 void audio_recordStart(int channels, int sampleRate, PSAudioFormat format)
@@ -232,8 +266,9 @@ void audio_recordStart(int channels, int sampleRate, PSAudioFormat format)
   lastChannels   = channels;
   lastSampleRate = sampleRate;
   audio.record.started = true;
+  audio.record.stride  = channels * sizeof(uint16_t);
 
-  audio.audioDev->record.start(channels, sampleRate, recordData);
+  audio.audioDev->record.start(channels, sampleRate, recordPushFrames);
 
   // if a volume level was stored, set it before we return
   if (audio.record.volumeChannels)
@@ -287,15 +322,21 @@ void audio_recordMute(bool mute)
 void audio_tick(unsigned long long tickCount)
 {
   LG_LOCK(audio.playback.lock);
-  if (!audio.playback.timings)
+  if (!audio.playback.buffer)
   {
     LG_UNLOCK(audio.playback.lock);
     return;
   }
 
-  const uint64_t latency  = audio.audioDev->playback.latency();
-  const float    flatency = latency > 0 ? (float)latency / 1000.0f : 0.0f;
-  ringbuffer_push(audio.playback.timings, &flatency);
+  int frames = ringbuffer_getCount(audio.playback.buffer);
+  if (audio.audioDev->playback.latency)
+    frames += audio.audioDev->playback.latency();
+
+  const float latency = frames > 0
+    ? audio.playback.sampleRate / (float)frames
+    : 0.0f;
+
+  ringbuffer_push(audio.playback.timings, &latency);
 
   LG_UNLOCK(audio.playback.lock);