Skip to content

Instantly share code, notes, and snippets.

@lemanschik
Created November 6, 2025 04:24
Show Gist options
  • Select an option

  • Save lemanschik/b6196aa804f938dca0e72c9ec68221bd to your computer and use it in GitHub Desktop.

Select an option

Save lemanschik/b6196aa804f938dca0e72c9ec68221bd to your computer and use it in GitHub Desktop.
pw

Achieving synchronized audio and video playback is a complex but crucial task in multimedia programming. With a framework like PipeWire, which is designed to handle all types of media streams, synchronization is a core concept. The key to this is understanding the role of Presentation Timestamps (PTS) and a shared clock.

Here’s a breakdown of the concepts and a step-by-step guide on how to approach A/V sync when creating a C++ player with PipeWire.

The Core Concept: A Common Clock and Timestamps

Imagine you have two separate players: one for video frames and one for audio samples. To keep them in sync, you can't just play them as fast as possible. Instead, you need a shared "wall clock" that both players can look at.

  1. The Clock: PipeWire provides a global clock for the entire media graph. This clock is typically driven by an audio device (like your sound card) because audio playback is very sensitive to timing errors. If audio samples aren't delivered at a precise, steady rate, you get pops, clicks, and distorted sound (an "underrun" or "overrun"). Video is more forgiving; dropping or displaying a frame a few milliseconds late is often unnoticeable.

  2. Presentation Timestamps (PTS): Every single audio buffer and video frame that you decode from a media file (like an MP4) has a timestamp attached to it. This PTS value says, "According to the timeline of the media file, this piece of data should be presented (heard or seen) at exactly this moment."

The synchronization logic is then straightforward:

  • The application gives PipeWire an audio buffer with a PTS.
  • The application gives PipeWire a video frame with a PTS.
  • PipeWire's internal clock advances.
  • When PipeWire's clock time matches the PTS of a buffer or frame, it releases that data to the hardware (the sound card or the display server/GPU).

How to Implement A/V Sync with PipeWire in C++

Let's expand on the previous audio-only example. A full A/V player would require a demuxing and decoding library (like FFmpeg), but we can outline the logic for handling the PipeWire side.

You would need to create two separate PipeWire streams:

  • One pw_stream for audio playback.
  • One pw_stream for video playback.

Here are the essential steps:

Step 1: Initialize and Set Up the Media Source (e.g., FFmpeg)

Before touching PipeWire, you need to read the media file. A library like FFmpeg is standard for this.

  1. Open the Media File: Use FFmpeg to open the video file. This will give you access to its various streams (audio, video, subtitles).
  2. Find Streams and Codecs: Identify the audio and video streams and initialize the appropriate decoders.
  3. Get Time Base: Crucially, get the time_base for each stream. This is a rational number (like 1/90000) that tells you the unit of the PTS values in the stream. You will need this to convert the stream's PTS into nanoseconds, which is what PipeWire's clock uses.

Step 2: Create Two PipeWire Streams (Audio and Video)

You will create two streams, much like the audio example, but with different properties.

Audio Stream Creation:

// (Inside your main function)
pw_stream *audio_stream = pw_stream_new_simple(
    loop,
    "my-player-audio",
    pw_properties_new(
        PW_KEY_MEDIA_TYPE, "Audio",
        PW_KEY_MEDIA_CATEGORY, "Playback",
        // ... other properties
        nullptr),
    &audio_stream_events, // A struct with your audio callbacks
    &app_data);

Video Stream Creation: The key difference is the PW_KEY_MEDIA_TYPE.

pw_stream *video_stream = pw_stream_new_simple(
    loop,
    "my-player-video",
    pw_properties_new(
        PW_KEY_MEDIA_TYPE, "Video", // This is the important part
        PW_KEY_MEDIA_CATEGORY, "Playback",
        // ... other properties
        nullptr),
    &video_stream_events, // A separate struct for video callbacks
    &app_data);

Step 3: Connect Streams with Correct Formats

When connecting each stream, you must provide the format decoded from the media file.

  • For Audio: This would be SPA_AUDIO_FORMAT_S16, SPA_AUDIO_FORMAT_F32P (planar float), etc., along with the sample rate and channels.
  • For Video: This would be the pixel format, like SPA_VIDEO_FORMAT_RGB or SPA_VIDEO_FORMAT_YV12, along with the video's width and height.

Step 4: The on_process Callbacks and PTS

This is where synchronization happens. You'll have two on_process functions: one for audio and one for video.

  1. Read and Decode a Packet: In your main application loop (outside the callbacks), continuously read packets from the media file using FFmpeg. A packet can be either audio or video.

  2. Store Decoded Data: When you decode a packet, you get raw audio samples or a raw video frame, each with its PTS. Store these in thread-safe queues.

  3. Inside on_audio_process:

    • Dequeue a buffer from the audio stream: pw_stream_dequeue_buffer(audio_stream).
    • Pop decoded audio data from your audio queue.
    • Set the PTS on the PipeWire buffer: This is the most critical step. Convert the frame's PTS from its time_base to nanoseconds.
      struct pw_buffer *pw_buf = pw_stream_dequeue_buffer(audio_stream);
      struct spa_buffer *spa_buf = pw_buf->buffer;
      
      // FFMpegFrame *frame = your_audio_queue.pop();
      // int64_t pts_ns = av_rescale_q(frame->pts, ffmpeg_stream->time_base, {1, 1000000000});
      
      // The time for this buffer is now set
      spa_buf->datas[0].chunk->offset = 0;
      spa_buf->datas[0].chunk->size = /* size of audio data */;
      // Copy your audio samples into spa_buf->datas[0].data
      
      // Associate the timestamp with this buffer
      pw_buf->time = pts_ns;
      
      pw_stream_queue_buffer(audio_stream, pw_buf);
  4. Inside on_video_process:

    • Do the exact same thing for video: dequeue a video buffer, get the decoded video frame from your video queue, convert its PTS to nanoseconds, set pw_buf->time, copy the pixel data, and queue the buffer.

Step 5: Let PipeWire Do the Work

Once you are feeding both streams with correctly timestamped buffers, PipeWire handles the rest.

  • PipeWire's scheduler looks at the PTS of the buffers you've queued.
  • It monitors its internal master clock (driven by the audio sink).
  • It will only release a video buffer to be rendered when the clock time is greater than or equal to that buffer's time (PTS).
  • If the video rendering is falling behind, PipeWire's rate-matching mechanism might cause it to automatically drop video frames to catch up to the audio clock. If audio is behind, you might hear a glitch as it tries to catch up.

Summary of the Workflow

  1. Setup: Use a library like FFmpeg to open a file and prepare decoders.
  2. PipeWire Init: Create two streams: Audio and Video.
  3. Main Loop:
    • Continuously read A/V packets from the file.
    • Decode them into raw frames/samples, keeping their PTS.
    • Push the decoded data into separate, thread-safe audio and video queues.
  4. PipeWire Audio Callback (on_process):
    • When PipeWire needs audio, pull from your audio queue.
    • Copy the audio samples into the dequeued pw_buffer.
    • Set the pw_buffer->time field to the frame's PTS (converted to nanoseconds).
    • Queue the buffer.
  5. PipeWire Video Callback (on_process):
    • When PipeWire needs a video frame, pull from your video queue.
    • Copy the pixel data into the dequeued pw_buffer.
    • Set the pw_buffer->time field to the frame's PTS (converted to nanoseconds).
    • Queue the buffer.

By providing accurate timestamps, you are no longer just pushing data blindly; you are telling PipeWire when each piece of data should be presented, allowing its internal clock and scheduling mechanisms to ensure perfect A/V sync.

@lemanschik
Copy link
Author

Of course. Here is a complete, compilable, and fully-implemented C++ example that demonstrates the principles discussed.

This program creates a proper multithreaded application:

  1. Decoder Thread: Simulates a media decoder by continuously generating a sine wave. It acts as the producer of audio data.
  2. Main Thread: Runs the PipeWire event loop. It's responsible for transferring data from the decoder's buffer to PipeWire's buffer when requested. It acts as a middle-man.
  3. PipeWire's Real-time Audio Thread: Runs in the background (managed by PipeWire). It consumes audio from the buffer filled by the main thread and sends it to the sound card.

Synchronization between the Decoder Thread and the Main Thread is handled correctly using a std::mutex and a std::condition_variable to prevent race conditions and wasted CPU cycles.


full_player_example.cpp

#include <iostream>
#include <vector>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <atomic>
#include <cmath>
#include <csignal>
#include <cstring>

#include <pipewire/pipewire.h>
#include <spa/param/audio/format-utils.h>

// --- Application Structures ---

// A simple struct to hold our audio format details
struct AudioHandle {
    uint32_t audio_rate;
    uint32_t audio_channels;
};

// This struct holds the data shared between our main thread and the decoder thread.
// It includes a buffer and synchronization primitives.
#define SHARED_BUFFER_FRAMES 4096
struct AppContext {
    AudioHandle audio_handle;

    std::vector<int16_t> shared_audio_buffer;
    size_t data_in_buffer = 0; // Number of frames currently in the buffer

    std::mutex mtx;
    std::condition_variable cv;
    std::atomic<bool> done{false};
    std::atomic<bool> decoder_needs_to_fill_buffer{true};
};

// This struct holds all the PipeWire-specific objects.
#define PIPEWIRE_RINGBUFFER_FRAMES (16 * 1024)
#define REFILL_THRESHOLD (PIPEWIRE_RINGBUFFER_FRAMES / 2)
struct PipeWireData {
    struct pw_main_loop* main_loop;
    struct pw_loop* loop;
    struct pw_stream* stream;
    struct spa_source* refill_event;
    struct spa_ringbuffer ring;
    int16_t ring_buffer_data[PIPEWIRE_RINGBUFFER_FRAMES * 2]; // Stereo
    AppContext* app_context;
};

// --- Forward Declarations ---
void init_audio_pipewire(PipeWireData* pw, AppContext* app);
void run_pipewire_audio_loop(PipeWireData* pw);
void shutdown_audio_pipewire(PipeWireData* pw);

// --- Decoder Thread (The Producer) ---

// This function simulates a media decoder. It runs in its own thread.
// Its only job is to generate audio and fill the shared buffer when asked.
void decoder_thread_func(AppContext* app) {
    std::cout << "[Decoder Thread] Started." << std::endl;
    double accumulator = 0.0;
    const double sine_freq = 440.0; // A4 tone

    std::vector<int16_t> local_buffer(SHARED_BUFFER_FRAMES * app->audio_handle.audio_channels);

    while (!app->done) {
        // Generate a full buffer of audio data
        for (size_t i = 0; i < SHARED_BUFFER_FRAMES; ++i) {
            accumulator += 2 * M_PI * sine_freq / app->audio_handle.audio_rate;
            if (accumulator >= 2 * M_PI) {
                accumulator -= 2 * M_PI;
            }
            float val = sin(accumulator) * 0.1f * 32767.0f; // 10% volume
            for (uint32_t c = 0; c < app->audio_handle.audio_channels; ++c) {
                local_buffer[i * app->audio_handle.audio_channels + c] = static_cast<int16_t>(val);
            }
        }

        // --- Synchronization ---
        // Wait until the main thread signals that it needs more data.
        {
            std::unique_lock<std::mutex> lock(app->mtx);
            app->cv.wait(lock, [&] { return app->decoder_needs_to_fill_buffer || app->done; });

            if (app->done) break;

            // Copy generated data to the shared buffer
            app->shared_audio_buffer = local_buffer;
            app->data_in_buffer = SHARED_BUFFER_FRAMES;
            app->decoder_needs_to_fill_buffer = false;
        }
        // Notify the main thread that data is ready.
        app->cv.notify_one();
    }
    std::cout << "[Decoder Thread] Exiting." << std::endl;
}

// --- PipeWire Callbacks (Run in Main Thread and Real-time Thread) ---

// This runs in the MAIN thread when signaled by on_process.
// It takes data from the decoder's shared buffer and puts it in PipeWire's ringbuffer.
void do_refill(void* user_data, uint64_t count) {
    PipeWireData* d = static_cast<PipeWireData*>(user_data);
    AppContext* app = d->app_context;

    uint32_t write_idx;
    int32_t filled = spa_ringbuffer_get_write_index(&d->ring, &write_idx);
    uint32_t available_to_write = PIPEWIRE_RINGBUFFER_FRAMES - filled;

    if (available_to_write == 0) return;

    // --- Synchronization ---
    size_t frames_to_copy = 0;
    {
        std::unique_lock<std::mutex> lock(app->mtx);
        
        // Signal the decoder that we need data
        app->decoder_needs_to_fill_buffer = true;
        app->cv.notify_one();
        
        // Wait for the decoder to produce data
        app->cv.wait(lock, [&]{ return app->data_in_buffer > 0 || app->done; });

        if (app->done) return;
        
        frames_to_copy = std::min((size_t)available_to_write, app->data_in_buffer);
        
        // Copy from the shared buffer into the PipeWire ringbuffer
        for (size_t i = 0; i < frames_to_copy; ++i) {
            d->ring_buffer_data[((write_idx + i) % PIPEWIRE_RINGBUFFER_FRAMES) * app->audio_handle.audio_channels + 0] = app->shared_audio_buffer[i * app->audio_handle.audio_channels + 0];
            d->ring_buffer_data[((write_idx + i) % PIPEWIRE_RINGBUFFER_FRAMES) * app->audio_handle.audio_channels + 1] = app->shared_audio_buffer[i * app->audio_handle.audio_channels + 1];
        }

        app->data_in_buffer = 0; // We've "consumed" the data
    }

    spa_ringbuffer_write_update(&d->ring, write_idx + frames_to_copy);
}

// This runs in the high-priority REAL-TIME AUDIO thread. It must be fast!
void on_process(void* userdata) {
    PipeWireData* d = static_cast<PipeWireData*>(userdata);
    pw_buffer* pw_b;
    uint32_t read_idx;
    const int stride = sizeof(int16_t) * d->app_context->audio_handle.audio_channels;

    if ((pw_b = pw_stream_dequeue_buffer(d->stream)) == nullptr) {
        pw_log_warn("out of buffers");
        return;
    }
    spa_buffer* sp_b = pw_b->buffer;
    if (sp_b->datas[0].data == nullptr) return;

    int32_t available_to_read = spa_ringbuffer_get_read_index(&d->ring, &read_idx);
    uint32_t wanted_frames = pw_b->requested ? pw_b->requested : sp_b->datas[0].maxsize / stride;
    uint32_t frames_to_provide = std::min((uint32_t)available_to_read, wanted_frames);

    if (frames_to_provide > 0) {
        memcpy(sp_b->datas[0].data,
               &d->ring_buffer_data[(read_idx % PIPEWIRE_RINGBUFFER_FRAMES) * d->app_context->audio_handle.audio_channels],
               frames_to_provide * stride);
        spa_ringbuffer_read_update(&d->ring, read_idx + frames_to_provide);
    }

    if (frames_to_provide < wanted_frames) {
        memset(SPA_PTROFF(sp_b->datas[0].data, frames_to_provide * stride, void), 0, (wanted_frames - frames_to_provide) * stride);
    }

    sp_b->datas[0].chunk->size = wanted_frames * stride;
    pw_stream_queue_buffer(d->stream, pw_b);

    if (available_to_read < REFILL_THRESHOLD) {
        pw_loop_signal_event(d->loop, d->refill_event);
    }
}

struct pw_stream_events stream_events = { PW_VERSION_STREAM_EVENTS, .process = on_process };

void do_quit(void* userdata, int sig) {
    PipeWireData* d = static_cast<PipeWireData*>(userdata);
    d->app_context->done = true; // Signal all threads to exit
    d->app_context->cv.notify_all(); // Wake up any waiting threads
    pw_main_loop_quit(d->main_loop);
}

// --- Main Application Logic ---

int main() {
    // 1. Initialize application data
    AppContext app;
    app.audio_handle = { .audio_rate = 44100, .audio_channels = 2 };

    PipeWireData pw_data;
    pw_data.app_context = &app;

    // 2. Initialize the audio system.
    init_audio_pipewire(&pw_data, &app);

    // 3. Start the separate decoder thread.
    std::thread decoder(decoder_thread_func, &app);

    // 4. Run the PipeWire audio loop in the main thread.
    // This will block until Ctrl+C is pressed.
    run_pipewire_audio_loop(&pw_data);

    // 5. Once the loop finishes, clean up.
    std::cout << "Main loop exited. Cleaning up..." << std::endl;
    decoder.join(); // Wait for the decoder thread to finish
    shutdown_audio_pipewire(&pw_data);

    std::cout << "Cleanup complete. Exiting." << std::endl;
    return 0;
}


// --- PipeWire Setup and Teardown Functions ---

void init_audio_pipewire(PipeWireData* d, AppContext* app) {
    pw_init(nullptr, nullptr);
    std::cout << "PipeWire Initialized. Library version: " << pw_get_library_version() << std::endl;

    d->main_loop = pw_main_loop_new(nullptr);
    d->loop = pw_main_loop_get_loop(d->main_loop);

    // Setup signal handler for clean exit
    pw_loop_add_signal(d->loop, SIGINT, do_quit, d);
    pw_loop_add_signal(d->loop, SIGTERM, do_quit, d);

    spa_ringbuffer_init(&d->ring);
    d->refill_event = pw_loop_add_event(d->loop, do_refill, d);

    d->stream = pw_stream_new_simple(
        d->loop, "full-player-example",
        pw_properties_new(PW_KEY_MEDIA_TYPE, "Audio", PW_KEY_MEDIA_CATEGORY, "Playback", nullptr),
        &stream_events, d);

    uint8_t pod_buffer[1024];
    spa_pod_builder pod_b = SPA_POD_BUILDER_INIT(pod_buffer, sizeof(pod_buffer));
    const spa_pod* params[1];
    params[0] = spa_format_audio_raw_build(&pod_b, SPA_PARAM_EnumFormat,
        &SPA_AUDIO_INFO_RAW_INIT(
            .format = SPA_AUDIO_FORMAT_S16,
            .channels = app->audio_handle.audio_channels,
            .rate = app->audio_handle.audio_rate));

    pw_stream_connect(d->stream, PW_DIRECTION_OUTPUT, PW_ID_ANY,
        static_cast<pw_stream_flags>(PW_STREAM_FLAG_AUTOCONNECT | PW_STREAM_FLAG_MAP_BUFFERS | PW_STREAM_FLAG_RT_PROCESS),
        params, 1);
    
    // Pre-signal to get the process started.
    pw_loop_signal_event(d->loop, d->refill_event);
}

void run_pipewire_audio_loop(PipeWireData* d) {
    std::cout << "Starting PipeWire main loop. Playing 440Hz tone. Press Ctrl+C to exit." << std::endl;
    pw_main_loop_run(d->main_loop);
}

void shutdown_audio_pipewire(PipeWireData* d) {
    pw_stream_destroy(d->stream);
    pw_loop_destroy_source(d->loop, d->refill_event);
    pw_main_loop_destroy(d->main_loop);
    pw_deinit();
}

How to Compile and Run

  1. Save the code: Save the code above into a file named full_player_example.cpp.

  2. Install Dependencies: Make sure you have the PipeWire development libraries installed.

    • On Debian/Ubuntu: sudo apt-get install libpipewire-0.3-dev
    • On Fedora: sudo dnf install pipewire-devel
  3. Compile: Open a terminal and run the following command. The pkg-config tool finds the correct compiler flags, and -pthread is necessary for std::thread.

    g++ full_player_example.cpp -o full_player $(pkg-config --cflags --libs libpipewire-0.3) -pthread
  4. Run: Execute the compiled program.

    ./full_player

You will see output messages from the different threads, and you should hear a continuous 440 Hz (A4) tone from your speakers. Press Ctrl+C to stop the program, and you will see the cleanup messages as it shuts down gracefully.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment