Skip to content

Commit a4b4b8d

Browse files
committed
feat(obs-irl-source): dedicated video output thread
move the gpu-to-cpu frame transfer, format conversion, and obs_source_output_video off the receiver thread onto a dedicated video thread fed by a small frame queue, so a gpu or delivery stall can never starve audio decode. queue overflow drops the oldest frame (counted as vq_drops in stats) and never blocks the receiver. the receiver converts video pts to nanoseconds before queueing because it can close fmt_ctx mid-reconnect while frames are still in flight; sws_ctx ownership moves to the video thread and is no longer freed on stream close. the decoder gets extra_hw_frames matching the queue depth so held frames cannot exhaust the hw surface pool.
1 parent 363439f commit a4b4b8d

8 files changed

Lines changed: 140 additions & 13 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,8 @@ Buffer regulation happens through playback speed only, asymmetric like IRLToolki
8484
### Threading model
8585

8686
- **Main/OBS thread**: calls create, destroy, update, tick, get_properties
87-
- **Receiver thread**: owns all FFmpeg state. Writes to the audio buffer (mutex protected). Outputs video frames directly to OBS via `obs_source_output_video`.
87+
- **Receiver thread**: owns demux/decode FFmpeg state. Writes to the audio buffer (mutex protected) and pushes decoded video frames (PTS pre-converted to nanoseconds) onto the video queue. Never blocks on GPU or OBS video delivery.
88+
- **Video thread**: pops the video queue, does the HW frame transfer and format conversion (owns sws_ctx), and calls `obs_source_output_video`. Queue overflow drops the oldest frame (`video_queue_drops`).
8889
- **Audio thread**: drains the jitter buffer and submits audio to OBS via `obs_source_output_audio`, paced against the sample counter output clock. Shared timing state is protected by `audio_state_lock` (lock order: `audio_state_lock` before the buffer mutex).
8990

9091
### OBS API conventions

include/irl-source.h

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,10 +116,27 @@ struct irl_source {
116116
/* Receiver / demux thread */
117117
pthread_t receiver_thread;
118118
pthread_t audio_thread;
119+
pthread_t video_thread;
119120
pthread_mutex_t audio_state_lock;
120121
volatile bool thread_active;
121122
volatile bool reconnecting;
122123

124+
/* Video output queue (receiver thread → video thread).
125+
* Decouples the GPU→CPU frame transfer and format conversion
126+
* from the receiver thread so a GPU stall cannot starve audio
127+
* decode. Depth stays small because queued HW frames pin
128+
* decoder surface-pool entries (matched by extra_hw_frames at
129+
* decoder open). Queued frame->pts is in nanoseconds; the
130+
* receiver converts before queueing because it may close
131+
* fmt_ctx while frames are still in flight. */
132+
#define IRL_VIDEO_QUEUE_SIZE 4
133+
pthread_mutex_t video_queue_lock;
134+
pthread_cond_t video_queue_cond;
135+
AVFrame *video_queue[IRL_VIDEO_QUEUE_SIZE];
136+
int video_queue_head;
137+
int video_queue_count;
138+
uint64_t video_queue_drops;
139+
123140
/* FFmpeg state (owned by receiver thread) */
124141
AVFormatContext *fmt_ctx;
125142
/* Armed before each blocking FFmpeg I/O call; interrupt_cb

src/irl-source.c

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,11 +131,23 @@ static void start_receiver(struct irl_source *ctx)
131131
os_atomic_store_bool(&ctx->thread_active, false);
132132
return;
133133
}
134+
if (pthread_create(&ctx->video_thread, NULL, irl_video_thread, ctx) !=
135+
0) {
136+
blog(LOG_ERROR,
137+
"[irl-source] Failed to create video thread");
138+
os_atomic_store_bool(&ctx->thread_active, false);
139+
pthread_join(ctx->audio_thread, NULL);
140+
return;
141+
}
134142
if (pthread_create(&ctx->receiver_thread, NULL, irl_receiver_thread,
135143
ctx) != 0) {
136144
blog(LOG_ERROR,
137145
"[irl-source] Failed to create receiver thread");
138146
os_atomic_store_bool(&ctx->thread_active, false);
147+
pthread_mutex_lock(&ctx->video_queue_lock);
148+
pthread_cond_broadcast(&ctx->video_queue_cond);
149+
pthread_mutex_unlock(&ctx->video_queue_lock);
150+
pthread_join(ctx->video_thread, NULL);
139151
pthread_join(ctx->audio_thread, NULL);
140152
}
141153
}
@@ -257,6 +269,8 @@ void *irl_source_create(obs_data_t *settings, obs_source_t *source)
257269
ctx->source = source;
258270
ctx->current_speed = 1.0f;
259271
pthread_mutex_init(&ctx->audio_state_lock, NULL);
272+
pthread_mutex_init(&ctx->video_queue_lock, NULL);
273+
pthread_cond_init(&ctx->video_queue_cond, NULL);
260274

261275
config_load(&ctx->config, settings);
262276
apply_async_audio_mode(ctx);
@@ -304,6 +318,8 @@ void irl_source_destroy(void *data)
304318
stop_receiver(ctx, false);
305319
audio_buffer_free(&ctx->audio_buf);
306320
pthread_mutex_destroy(&ctx->audio_state_lock);
321+
pthread_cond_destroy(&ctx->video_queue_cond);
322+
pthread_mutex_destroy(&ctx->video_queue_lock);
307323

308324
free(ctx->audio_pump_scratch);
309325
free(ctx->audio_resample_scratch);

src/receiver-internal.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,7 @@ void irl_handle_video_packet(struct irl_source *ctx, AVPacket *pkt,
2020
AVFrame *frame);
2121
void irl_handle_audio_frame(struct irl_source *ctx, AVFrame *frame);
2222
void irl_handle_video_frame(struct irl_source *ctx, AVFrame *frame);
23+
void irl_video_queue_push(struct irl_source *ctx, AVFrame *frame,
24+
int64_t pts_ns);
25+
void *irl_video_thread(void *data);
2326
void irl_log_receiver_stats(struct irl_source *ctx);

src/receiver-stream.c

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,11 @@ static AVCodecContext *open_decoder(struct irl_source *src, AVStream *stream,
106106
* decode ignores both settings. */
107107
ctx->flags |= AV_CODEC_FLAG_LOW_DELAY;
108108
ctx->thread_count = 4;
109+
/* The video output queue holds decoded HW frames, each
110+
* pinning a decoder surface; give the pool matching
111+
* headroom or the decoder can stall waiting for a
112+
* surface the queue is sitting on. */
113+
ctx->extra_hw_frames = IRL_VIDEO_QUEUE_SIZE;
109114
}
110115

111116
if (try_hw && stream->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {
@@ -164,10 +169,9 @@ void irl_close_ffmpeg(struct irl_source *ctx)
164169
ctx->swr_in_rate = 0;
165170
ctx->swr_in_channels = 0;
166171
ctx->swr_in_format = AV_SAMPLE_FMT_NONE;
167-
if (ctx->sws_ctx) {
168-
sws_freeContext(ctx->sws_ctx);
169-
ctx->sws_ctx = NULL;
170-
}
172+
/* sws_ctx is owned by the video thread (it converts queued
173+
* frames that may outlive this connection); it is recreated on
174+
* parameter change and freed at source destroy. */
171175

172176
if (ctx->audio_dec_ctx) {
173177
avcodec_free_context(&ctx->audio_dec_ctx);
@@ -442,7 +446,7 @@ void irl_log_receiver_stats(struct irl_source *ctx)
442446
"norm=%llu interp=%llu silence=%llu resets=%llu "
443447
"last_gap=%dms max_gap=%dms underruns=%llu resync_skips=%llu "
444448
"hidden_trims=%llu quality_events=%llu "
445-
"audio_flushes=%llu video_flushes=%llu "
449+
"audio_flushes=%llu video_flushes=%llu vq_drops=%llu "
446450
"obs_lead=%lldms chunk=%u@%u "
447451
"stream_chunk=%llums obs_chunk=%llums "
448452
"restarts=%llu res=%dx%d",
@@ -464,6 +468,7 @@ void irl_log_receiver_stats(struct irl_source *ctx)
464468
(unsigned long long)ctx->audio_quality_events,
465469
(unsigned long long)ctx->audio_decoder_flushes,
466470
(unsigned long long)ctx->video_decoder_flushes,
471+
(unsigned long long)ctx->video_queue_drops,
467472
(long long)(ctx->audio_last_obs_lead_ns / 1000000LL),
468473
ctx->audio_last_frames_out, ctx->audio_last_samples_per_sec,
469474
(unsigned long long)(ctx->audio_last_chunk_stream_duration_ns /

src/receiver-video.c

Lines changed: 84 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,82 @@
88

99
#include "receiver-internal.h"
1010

11+
/* ── Video output queue ───────────────────────────────────── */
12+
13+
static void video_queue_drain_locked(struct irl_source *ctx)
14+
{
15+
while (ctx->video_queue_count > 0) {
16+
AVFrame *f = ctx->video_queue[ctx->video_queue_head];
17+
ctx->video_queue[ctx->video_queue_head] = NULL;
18+
ctx->video_queue_head =
19+
(ctx->video_queue_head + 1) % IRL_VIDEO_QUEUE_SIZE;
20+
ctx->video_queue_count--;
21+
av_frame_free(&f);
22+
}
23+
}
24+
25+
void irl_video_queue_push(struct irl_source *ctx, AVFrame *frame,
26+
int64_t pts_ns)
27+
{
28+
AVFrame *clone = av_frame_alloc();
29+
if (!clone)
30+
return;
31+
if (av_frame_ref(clone, frame) < 0) {
32+
av_frame_free(&clone);
33+
return;
34+
}
35+
clone->pts = pts_ns;
36+
37+
pthread_mutex_lock(&ctx->video_queue_lock);
38+
if (ctx->video_queue_count >= IRL_VIDEO_QUEUE_SIZE) {
39+
/* Video thread is stalled; keep the freshest frames and
40+
* never make the receiver (and therefore audio) wait. */
41+
AVFrame *oldest = ctx->video_queue[ctx->video_queue_head];
42+
ctx->video_queue[ctx->video_queue_head] = NULL;
43+
ctx->video_queue_head =
44+
(ctx->video_queue_head + 1) % IRL_VIDEO_QUEUE_SIZE;
45+
ctx->video_queue_count--;
46+
ctx->video_queue_drops++;
47+
av_frame_free(&oldest);
48+
}
49+
int tail = (ctx->video_queue_head + ctx->video_queue_count) %
50+
IRL_VIDEO_QUEUE_SIZE;
51+
ctx->video_queue[tail] = clone;
52+
ctx->video_queue_count++;
53+
pthread_cond_signal(&ctx->video_queue_cond);
54+
pthread_mutex_unlock(&ctx->video_queue_lock);
55+
}
56+
57+
void *irl_video_thread(void *data)
58+
{
59+
struct irl_source *ctx = data;
60+
61+
pthread_mutex_lock(&ctx->video_queue_lock);
62+
while (os_atomic_load_bool(&ctx->thread_active)) {
63+
if (ctx->video_queue_count == 0) {
64+
pthread_cond_wait(&ctx->video_queue_cond,
65+
&ctx->video_queue_lock);
66+
continue;
67+
}
68+
AVFrame *f = ctx->video_queue[ctx->video_queue_head];
69+
ctx->video_queue[ctx->video_queue_head] = NULL;
70+
ctx->video_queue_head =
71+
(ctx->video_queue_head + 1) % IRL_VIDEO_QUEUE_SIZE;
72+
ctx->video_queue_count--;
73+
pthread_mutex_unlock(&ctx->video_queue_lock);
74+
75+
irl_video_output_frame(ctx, f);
76+
av_frame_free(&f);
77+
78+
pthread_mutex_lock(&ctx->video_queue_lock);
79+
}
80+
video_queue_drain_locked(ctx);
81+
pthread_mutex_unlock(&ctx->video_queue_lock);
82+
return NULL;
83+
}
84+
85+
/* ── Decoded frame handling (receiver thread) ─────────────── */
86+
1187
static int64_t video_frame_pts(const AVFrame *frame)
1288
{
1389
if (frame->best_effort_timestamp != AV_NOPTS_VALUE)
@@ -76,18 +152,22 @@ void irl_handle_video_frame(struct irl_source *ctx, AVFrame *frame)
76152
ctx->last_video_width = frame->width;
77153
ctx->last_video_height = frame->height;
78154

155+
/* Convert PTS to nanoseconds here: the video thread must not
156+
* touch fmt_ctx, which this thread frees on reconnect while
157+
* queued frames may still be in flight. */
158+
int64_t pts_ns = 0;
79159
if (ctx->fmt_ctx && ctx->video_stream_idx >= 0) {
80160
AVStream *vs =
81161
ctx->fmt_ctx->streams[ctx->video_stream_idx];
82-
int64_t pts_ns = av_rescale_q(frame->pts, vs->time_base,
83-
(AVRational){1, 1000000000});
162+
pts_ns = av_rescale_q(frame->pts, vs->time_base,
163+
(AVRational){1, 1000000000});
84164
pthread_mutex_lock(&ctx->audio_state_lock);
85165
ctx->latest_video_stream_pts_ns = pts_ns;
86166
pthread_mutex_unlock(&ctx->audio_state_lock);
87167
}
88168

89-
irl_video_output_frame(ctx, frame);
169+
irl_video_queue_push(ctx, frame, pts_ns);
90170
ctx->total_video_frames++;
91171
if (ctx->total_video_frames == 1)
92-
blog(LOG_INFO, "[irl-source] First video frame output");
172+
blog(LOG_INFO, "[irl-source] First video frame queued");
93173
}

src/receiver.c

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,10 @@ void irl_receiver_stop(struct irl_source *ctx)
121121
return;
122122

123123
os_atomic_store_bool(&ctx->thread_active, false);
124+
pthread_mutex_lock(&ctx->video_queue_lock);
125+
pthread_cond_broadcast(&ctx->video_queue_cond);
126+
pthread_mutex_unlock(&ctx->video_queue_lock);
127+
pthread_join(ctx->video_thread, NULL);
124128
pthread_join(ctx->audio_thread, NULL);
125129
pthread_join(ctx->receiver_thread, NULL);
126130
}

src/video-handler.c

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,10 @@ static void setup_color_params(struct obs_source_frame *obs_frame,
128128
* video-only wall-clock anchor. */
129129
static uint64_t frame_timestamp(struct irl_source *ctx, const AVFrame *frame)
130130
{
131-
AVStream *vs = ctx->fmt_ctx->streams[ctx->video_stream_idx];
132-
int64_t pts_ns = av_rescale_q(frame->pts, vs->time_base,
133-
(AVRational){1, 1000000000});
131+
/* frame->pts is pre-converted to nanoseconds by the receiver
132+
* thread (see irl_video_queue_push); fmt_ctx must not be
133+
* touched here, it can be freed mid-reconnect. */
134+
int64_t pts_ns = frame->pts;
134135
uint64_t now = os_gettime_ns();
135136

136137
/* Snapshot audio-thread-owned fields under the lock. */

0 commit comments

Comments
 (0)