diff --git a/CLAUDE.md b/CLAUDE.md index a144e12..f61f834 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,13 +79,13 @@ Buffer regulation happens through playback speed only, asymmetric like IRLToolki ### Source files - **`src/plugin.c`**: OBS module entry point. Registers `irl_source_info` with callbacks. -- **`src/irl-source.c`**: Source lifecycle (create, destroy, update, tick, activate/deactivate/show/hide). Loads config, manages threads, registers `proc_handler` for stats. `update` diffs the new settings against the live config: URL, FFmpeg Options, Hardware Decode, and Low Latency Audio are latched at stream open and force a reconnect, everything else is swapped in place under `audio_state_lock` so a settings tweak neither drops the connection nor clears the stats counters. Retuning Target Buffer live goes through `audio_buffer_resize`, which grows the ring (never shrinks it) and moves the watermarks while keeping every queued sample. `tick` also runs the one-shot fit-to-canvas: a source created without a URL (so, freshly added rather than restored from a scene collection) applies the same `obs_transform_info` as the frontend's Fit to Screen action to every scene item referencing it, once, as soon as the source reports a non-zero size. When "Close Stream When Inactive" is enabled, the show/activate callbacks start the receiver and hide/deactivate stop it (and clear the frame to black); otherwise those callbacks are no-ops and the stream runs from create to destroy. +- **`src/irl-source.c`**: Source lifecycle (create, destroy, update, tick, activate/deactivate/show/hide). Loads config, manages threads, registers `proc_handler` for stats. `update` diffs the new settings against the live config: URL, FFmpeg Options, Hardware Decode, and Low Latency Audio are latched at stream open and force a reconnect, everything else is swapped in place under `audio_state_lock` so a settings tweak neither drops the connection nor clears the stats counters. Retuning Target Buffer live goes through `audio_buffer_resize`, which grows the ring (never shrinks it) and moves the watermarks while keeping every queued sample. `tick` also runs the one-shot fit-to-canvas: a source created without a URL (so, freshly added rather than restored from a scene collection) applies the same `obs_transform_info` as the frontend's Fit to Screen action to every scene item referencing it, once, as soon as the source reports a non-zero size. When "Close Stream When Inactive" is enabled, the show/activate callbacks start the receiver and hide/deactivate stop it; otherwise those callbacks are no-ops and the stream runs from create to destroy. Every "the stream stopped" clear — hide/deactivate, a restart-forcing settings edit, and the disconnect in `receiver-stream.c` — is gated on "Show Nothing When the Stream Ends" (`clear_on_disconnect`, on by default), the port of the media source's `clear_on_media_end`. Turning it off restores the old behavior of leaving the last decoded frame frozen on screen until the stream returns. - **`src/receiver.c`**: thread entry points. The receiver thread runs the `av_read_frame()` loop, the audio thread runs the output pump. - **`src/receiver-internal.h`**: internal declarations shared across the `receiver-*.c` translation units (stream open/close, packet/frame handlers, the audio pump, the video thread, timing-state resets). Not part of the public `include/` API. - **`src/receiver-stream.c`**: stream open/close, demuxer options, reconnection, disconnect fade out, periodic stats logging. - **`src/receiver-decode.c`**: packet to decoder plumbing with corruption burst handling and throttled decoder flushes. - **`src/receiver-audio.c`**: the audio core. Intake side (receiver thread): PTS repair, resample to interleaved float, write to the PTS aware jitter buffer. Pre-keyframe audio is discarded (not staged) to avoid decoder warm-up artifacts. Output side (audio thread): sample counter output clock, constant rate submission, swr based speed correction, dropout concealment, hidden backlog trims. -- **`src/receiver-video.c`**: decoded video frame handling, keyframe gate, resolution change detection. +- **`src/receiver-video.c`**: decoded video frame handling, keyframe gate, resolution change detection. Also owns `irl_video_request_clear`: the receiver thread drops the queue and raises a flag, and the *video* thread is what actually calls `obs_source_output_video(source, NULL)`. Clearing from the receiver thread instead would race a frame already inside the format conversion, which would repaint the frozen frame right after the clear. - **`src/audio-buffer.c`**: thread safe ring buffer sized in milliseconds with a parallel PTS chunk queue. Mutex protected. Supports fade-out reads. - **`src/video-handler.c`**: converts AVFrames to OBS video. Maps pixel formats (I420, NV12, I010, P010, etc.), handles HW frame transfer, falls back to swscale for unsupported formats. Maps video PTS through the audio playout offset for lip sync. - **`src/pts-repair.c`**: three tier PTS discontinuity repair. Small gaps interpolated, medium gaps get silence, large gaps trigger full reset. diff --git a/README.md b/README.md index 9bbce24..2d4184c 100644 --- a/README.md +++ b/README.md @@ -84,9 +84,10 @@ A source you just added sizes itself to the canvas when its first frame arrives, | Hardware Decode | Auto | Let the GPU decode video. Auto picks whatever your machine supports, Off forces the CPU | | Wait for Keyframe | On | Hold video back until a clean frame arrives, so you never see blocky garbage on join | | Low Latency Audio | Off | Play audio the moment it arrives, with no cushion. Lowest delay, least tolerant of a wobbly connection | -| Close Stream When Inactive | Off | Stop pulling the stream when the source is neither showing nor active (the last frame goes black), and reconnect when it becomes visible again | +| Show Nothing When the Stream Ends | On | Blank the source as soon as the stream drops, instead of leaving the last frame frozen on screen until it reconnects. Same idea as the media source's "Show nothing when playback ends" | +| Close Stream When Inactive | Off | Stop pulling the stream when the source is neither showing nor active (the last frame goes black if Show Nothing When the Stream Ends is on), and reconnect when it becomes visible again | -Target Buffer, Reconnect Delay, Adaptive Latency Control, Wait for Keyframe and Close Stream When Inactive can be changed while the stream is running. The connection stays up and the stats counters keep counting. Changing Target Buffer mid-stream keeps every buffered sample and walks the latency to the new value at up to +5% or -2% speed, so you should not hear a seam. Changing URL, FFmpeg Options, Hardware Decode or Low Latency Audio reconnects, because those are set when the stream is opened. +Target Buffer, Reconnect Delay, Adaptive Latency Control, Wait for Keyframe, Show Nothing When the Stream Ends and Close Stream When Inactive can be changed while the stream is running. The connection stays up and the stats counters keep counting. The one exception is turning Close Stream When Inactive on while the source is already hidden, which is a request to stop receiving: that drops the connection and resets the stats counters, as it would on any later hide. Changing Target Buffer mid-stream keeps every buffered sample and walks the latency to the new value at up to +5% or -2% speed, so you should not hear a seam. Changing URL, FFmpeg Options, Hardware Decode or Low Latency Audio reconnects, because those are set when the stream is opened. Earlier versions exposed Min/Max Buffer, PTS gap thresholds, Network Buffer and Decoupled Audio. Those are now fixed or derived internally, so old scene collections keep working and ignore the stored values. diff --git a/include/irl-source.h b/include/irl-source.h index 18e877e..a591173 100644 --- a/include/irl-source.h +++ b/include/irl-source.h @@ -57,6 +57,7 @@ struct irl_source; #define IRL_DEFAULT_WAIT_KEYFRAME true #define IRL_DEFAULT_LOW_LATENCY_AUDIO false #define IRL_DEFAULT_CLOSE_WHEN_INACTIVE false +#define IRL_DEFAULT_CLEAR_ON_DISCONNECT true /* Min/max buffer are derived from the target rather than exposed as * settings: min is the speed controller's low watermark, max is where @@ -126,6 +127,9 @@ struct irl_config { volatile bool wait_for_keyframe; /* hot */ bool low_latency_audio; bool close_when_inactive; /* hot, but OBS-thread only */ + /* OBS's media source calls this clear_on_media_end and defaults it + * on; same meaning here, minus the local-file cases. */ + volatile bool clear_on_disconnect; /* hot */ }; /* ── Main source context ──────────────────────────────────── */ @@ -157,6 +161,11 @@ struct irl_source { int video_queue_head; int video_queue_count; uint64_t video_queue_drops; + /* Set by the receiver thread on disconnect, consumed by the video + * thread. Guarded by video_queue_lock. The clear has to run on the + * video thread so it cannot be undone by a frame that was already + * mid-conversion when the disconnect was noticed. */ + bool video_clear_pending; /* FFmpeg state (owned by receiver thread) */ AVFormatContext *fmt_ctx; diff --git a/src/irl-source.c b/src/irl-source.c index 8e84ca9..4966bec 100644 --- a/src/irl-source.c +++ b/src/irl-source.c @@ -62,6 +62,8 @@ static void config_load(struct irl_config *cfg, obs_data_t *settings) obs_data_get_bool(settings, "low_latency_audio"); cfg->close_when_inactive = obs_data_get_bool(settings, "close_when_inactive"); + cfg->clear_on_disconnect = + obs_data_get_bool(settings, "clear_on_disconnect"); } static bool str_differs(const char *a, const char *b) @@ -122,6 +124,8 @@ static void config_apply_hot(struct irl_source *ctx, next->adaptive_speed); os_atomic_store_bool(&ctx->config.wait_for_keyframe, next->wait_for_keyframe); + os_atomic_store_bool(&ctx->config.clear_on_disconnect, + next->clear_on_disconnect); ctx->config.close_when_inactive = next->close_when_inactive; irl_mutex_unlock(&ctx->audio_state_lock); @@ -193,6 +197,11 @@ static void reset_runtime_state(struct irl_source *ctx) ctx->first_keyframe_received = false; ctx->video_pkt_gate_open = false; ctx->video_pkt_gate_start_us = 0; + /* Only reachable with the worker threads stopped, so this is the one + * place the flag is touched without video_queue_lock. Dropping a clear + * the video thread never got to is correct: the stop path decides for + * itself whether the frame stays. */ + ctx->video_clear_pending = false; os_atomic_store_bool(&ctx->reconnecting, false); irl_mutex_lock(&ctx->audio_state_lock); audio_buffer_flush(&ctx->audio_buf); @@ -268,11 +277,15 @@ static void start_receiver(struct irl_source *ctx) } } +/* clear_video asks for the frame to be dropped because the stream stopped, + * so it is subject to clear_on_disconnect. Callers that stop the source + * outright (no URL, teardown) decide for themselves. */ static void stop_receiver(struct irl_source *ctx, bool clear_video) { irl_receiver_stop(ctx); reset_runtime_state(ctx); - if (clear_video) + if (clear_video && + os_atomic_load_bool(&ctx->config.clear_on_disconnect)) clear_async_video(ctx); } @@ -498,9 +511,22 @@ void irl_source_update(void *data, obs_data_t *settings) ctx->config = next; /* takes ownership of the loaded strings */ apply_async_audio_mode(ctx); - start_receiver(ctx); - if (!should_run_receiver(ctx)) + /* Either the source is not going to run at all, or a restart-forcing + * edit just dropped the connection: both leave a frame on screen that + * belongs to a stream that is gone. Clearing is decided against the + * config that was just installed, not the one being replaced. + * + * Ordering matters: this has to happen before the receiver restarts, + * or the NULL frame could land after the new stream delivered its + * first one and blank a live picture. Safe to do directly rather than + * via irl_video_request_clear() because the threads are stopped here + * — the video thread drains the queue as it exits, and there is no + * frame in flight to repaint over the clear. */ + if (!should_run_receiver(ctx) || + os_atomic_load_bool(&ctx->config.clear_on_disconnect)) clear_async_video(ctx); + + start_receiver(ctx); } void irl_source_activate(void *data) diff --git a/src/receiver-internal.h b/src/receiver-internal.h index 378ead0..482a754 100644 --- a/src/receiver-internal.h +++ b/src/receiver-internal.h @@ -22,5 +22,6 @@ void irl_handle_audio_frame(struct irl_source *ctx, AVFrame *frame); void irl_handle_video_frame(struct irl_source *ctx, AVFrame *frame); void irl_video_queue_push(struct irl_source *ctx, AVFrame *frame, int64_t pts_ns); +void irl_video_request_clear(struct irl_source *ctx); void *irl_video_thread(void *data); void irl_log_receiver_stats(struct irl_source *ctx); diff --git a/src/receiver-stream.c b/src/receiver-stream.c index 09fc0a5..0998bfc 100644 --- a/src/receiver-stream.c +++ b/src/receiver-stream.c @@ -423,6 +423,14 @@ void irl_handle_stream_read_error(struct irl_source *ctx, int read_ret) irl_close_ffmpeg(ctx); pts_repair_reset(&ctx->pts_state); + + /* Blank the source instead of leaving the last decoded frame frozen + * on screen, matching what OBS's own media source does on media end + * (its clear_on_media_end, likewise on by default). The audio fade-out + * below is the same idea for the other half of the stream. */ + if (os_atomic_load_bool(&ctx->config.clear_on_disconnect)) + irl_video_request_clear(ctx); + irl_mutex_lock(&ctx->audio_state_lock); fade_out_buffered_audio(ctx); audio_buffer_flush(&ctx->audio_buf); diff --git a/src/receiver-video.c b/src/receiver-video.c index b6734c2..20c38c8 100644 --- a/src/receiver-video.c +++ b/src/receiver-video.c @@ -22,6 +22,19 @@ static void video_queue_drain_locked(struct irl_source *ctx) } } +/* Ask the video thread to blank the source. Queued frames are dropped + * here so nothing decoded before the disconnect can repaint after the + * clear; a frame already being converted is handled by the ordering in + * irl_video_thread(), which re-checks the flag after each output. */ +void irl_video_request_clear(struct irl_source *ctx) +{ + irl_mutex_lock(&ctx->video_queue_lock); + video_queue_drain_locked(ctx); + ctx->video_clear_pending = true; + irl_cond_signal(&ctx->video_queue_cond); + irl_mutex_unlock(&ctx->video_queue_lock); +} + void irl_video_queue_push(struct irl_source *ctx, AVFrame *frame, int64_t pts_ns) { @@ -60,6 +73,13 @@ void *irl_video_thread(void *data) irl_mutex_lock(&ctx->video_queue_lock); while (os_atomic_load_bool(&ctx->thread_active)) { + if (ctx->video_clear_pending) { + ctx->video_clear_pending = false; + irl_mutex_unlock(&ctx->video_queue_lock); + obs_source_output_video(ctx->source, NULL); + irl_mutex_lock(&ctx->video_queue_lock); + continue; + } if (ctx->video_queue_count == 0) { irl_cond_wait(&ctx->video_queue_cond, &ctx->video_queue_lock); diff --git a/src/settings.c b/src/settings.c index e143bea..7432508 100644 --- a/src/settings.c +++ b/src/settings.c @@ -33,6 +33,8 @@ void irl_source_get_defaults(obs_data_t *settings) IRL_DEFAULT_LOW_LATENCY_AUDIO); obs_data_set_default_bool(settings, "close_when_inactive", IRL_DEFAULT_CLOSE_WHEN_INACTIVE); + obs_data_set_default_bool(settings, "clear_on_disconnect", + IRL_DEFAULT_CLEAR_ON_DISCONNECT); } /* ── Properties ───────────────────────────────────────────── */ @@ -88,6 +90,9 @@ obs_properties_t *irl_source_get_properties(void *data) obs_properties_add_bool( props, "low_latency_audio", obs_module_text("Use OBS Low-Latency Async Audio Mode")); + obs_properties_add_bool( + props, "clear_on_disconnect", + obs_module_text("Show Nothing When the Stream Ends")); obs_properties_add_bool(props, "close_when_inactive", obs_module_text("Close Stream When Inactive")); obs_properties_add_text( @@ -98,9 +103,13 @@ obs_properties_t *irl_source_get_properties(void *data) "low-latency option enables OBS async unbuffered audio mode " "for this source and makes the plugin drain immediately " "instead of building the normal jitter cushion, so it is " - "faster but less tolerant of jitter. Close Stream " - "When Inactive stops receiving when the source is not active " - "and clears the last frame to black. FFmpeg Options can " + "faster but less tolerant of jitter. Show Nothing When " + "the Stream Ends blanks the source as soon as the stream " + "drops, instead of leaving the last frame frozen on screen " + "until it reconnects. Close Stream " + "When Inactive stops receiving when the source is not " + "active, clearing the frame if the option above is on. " + "FFmpeg Options can " "override any demuxer option, for example buffer_size or " "the SRT latency."), OBS_TEXT_INFO);