From 4e936b96a80e22a27e132a221e4c09693c7ed0d1 Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Sat, 24 Oct 2026 00:04:43 -0400 Subject: [PATCH 1/2] Include AAC audio in the native HLS fallback writer When go2rtc native HLS is unavailable, the FFmpeg HLS writer now muxes the source's audio track alongside video, but only when audio is enabled for the stream (record_audio && !audio_disabled). With audio disabled the output stays video only, exactly as before. - AAC with codec config is stream-copied; other codecs (G.711 etc.) are skipped with a single log_info and video output is unaffected. - Packets are routed to output streams by media type rather than forced to stream 0, so audio-before-video RTSP sources work. - Audio is rescaled from its own timebase and has its own monotonic DTS tracker; video PTS/DTS fixups are unchanged. - Only video refreshes the thread's liveness timestamp, and max_interleave_delta is capped at 1s when audio is muxed. - Writers recreated after reconnection are initialized with the audio stream before their header is written. Closes #651 Co-Authored-By: Claude Opus 5.5 --- include/video/hls/hls_unified_thread.h | 3 + include/video/hls_writer.h | 22 ++- src/video/hls/hls_unified_thread.c | 56 ++++-- src/video/hls_writer.c | 115 ++++++++++--- tests/unit/test_hls_cleanup.c | 230 +++++++++++++++++++++++++ 5 files changed, 386 insertions(+), 40 deletions(-) diff --git a/include/video/hls/hls_unified_thread.h b/include/video/hls/hls_unified_thread.h index 4f90301e3..2105b393d 100644 --- a/include/video/hls/hls_unified_thread.h +++ b/include/video/hls/hls_unified_thread.h @@ -36,6 +36,9 @@ typedef struct { // Stream configuration int protocol; // STREAM_PROTOCOL_TCP or STREAM_PROTOCOL_UDP int segment_duration; + // Effective audio setting (record_audio && !audio_disabled); when 0 no + // audio is ever muxed into the HLS output + int audio_enabled; // HLS writer (embedded directly instead of pointer) hls_writer_t *writer; diff --git a/include/video/hls_writer.h b/include/video/hls_writer.h index 9210b451e..843a926f4 100644 --- a/include/video/hls_writer.h +++ b/include/video/hls_writer.h @@ -32,6 +32,11 @@ typedef struct hls_writer_t { // Per-stream DTS tracking stream_dts_info_t dts_tracker; + stream_dts_info_t audio_dts_tracker; + + // Output stream indices; audio_out_index is -1 when the output is video only + int video_out_index; + int audio_out_index; // Counter for DTS jumps to detect stream issues int dts_jump_count; @@ -57,7 +62,22 @@ hls_writer_t *hls_writer_create(const char *output_dir, const char *stream_name, int hls_writer_initialize(hls_writer_t *writer, const AVStream *input_stream); /** - * Write packet to HLS stream + * Initialize HLS writer with a video stream and an optional audio stream. + * Only AAC audio is muxed (stream copy); any other codec, or a NULL + * audio_stream, yields video-only output. Callers must pass NULL when audio + * is disabled for the stream so no audio reaches the segments. + */ +int hls_writer_initialize_av(hls_writer_t *writer, const AVStream *video_stream, + const AVStream *audio_stream); + +/** + * Whether hls_writer_initialize_av() can mux audio with these parameters (AAC only) + */ +int hls_writer_audio_supported(const AVCodecParameters *par); + +/** + * Write packet to HLS stream. The packet is routed by the input stream's media + * type; audio packets are dropped when the writer has no audio output. */ int hls_writer_write_packet(hls_writer_t *writer, const AVPacket *pkt, const AVStream *input_stream); diff --git a/src/video/hls/hls_unified_thread.c b/src/video/hls/hls_unified_thread.c index 4a1b6ba72..5dedc6936 100644 --- a/src/video/hls/hls_unified_thread.c +++ b/src/video/hls/hls_unified_thread.c @@ -786,6 +786,7 @@ void *hls_unified_thread_func(void *arg) { AVFormatContext *input_ctx = NULL; AVPacket *pkt = NULL; int video_stream_idx = -1; + int audio_stream_idx = -1; int ret; hls_thread_state_t thread_state = HLS_THREAD_INITIALIZING; int reconnect_attempt = 0; @@ -1111,8 +1112,9 @@ void *hls_unified_thread_func(void *arg) { break; } - // Find video stream + // Find video stream (and audio, only when audio is enabled for the stream) video_stream_idx = find_video_stream_index(input_ctx); + audio_stream_idx = ctx->audio_enabled ? find_audio_stream_index(input_ctx) : -1; if (video_stream_idx == -1) { log_error("No video stream found for stream %s", stream_name); @@ -1168,7 +1170,8 @@ void *hls_unified_thread_func(void *arg) { if (!is_shutdown_initiated() && input_ctx->streams[video_stream_idx]) { // CRITICAL FIX: Check if context is still valid before accessing if (ctx && !is_context_pending_deletion(ctx) && !is_context_already_freed(ctx) && ctx->writer) { - ret = hls_writer_initialize(ctx->writer, input_ctx->streams[video_stream_idx]); + ret = hls_writer_initialize_av(ctx->writer, input_ctx->streams[video_stream_idx], + audio_stream_idx >= 0 ? input_ctx->streams[audio_stream_idx] : NULL); } else { log_warn("Context for stream %s is no longer valid or writer is NULL, skipping HLS writer initialization", stream_name); // Set thread state to stopping to exit gracefully @@ -1284,13 +1287,15 @@ void *hls_unified_thread_func(void *arg) { continue; } - // Process packets based on stream type - if (pkt->stream_index == video_stream_idx) { + // Process packets based on stream type. audio_stream_idx is -1 + // when audio is disabled, so audio packets never reach the writer. + const bool is_audio_pkt = audio_stream_idx >= 0 && pkt->stream_index == audio_stream_idx; + if (pkt->stream_index == video_stream_idx || is_audio_pkt) { // Record frame for telemetry metrics - metrics_record_frame_from_source(stream_name, pkt->size, true, - METRICS_SOURCE_HLS); - - // This is a video packet - process it + if (!is_audio_pkt) { + metrics_record_frame_from_source(stream_name, pkt->size, true, + METRICS_SOURCE_HLS); + } // CRITICAL FIX: Check if context is still valid before accessing if (!ctx || is_context_pending_deletion(ctx) || is_context_already_freed(ctx)) { @@ -1330,10 +1335,17 @@ void *hls_unified_thread_func(void *arg) { continue; } + // A writer recreated after reconnection is initialized here so + // that its audio output is set up before the header is written + if (!writer->initialized) { + hls_writer_initialize_av(writer, input_ctx->streams[video_stream_idx], + audio_stream_idx >= 0 ? input_ctx->streams[audio_stream_idx] : NULL); + } + ret = hls_writer_write_packet(writer, pkt, input_stream); // Log key frames for debugging - bool is_key_frame = (pkt->flags & AV_PKT_FLAG_KEY) != 0; + bool is_key_frame = !is_audio_pkt && (pkt->flags & AV_PKT_FLAG_KEY) != 0; if (is_key_frame && ret >= 0) { log_debug("Processed video key frame for stream %s", stream_name); } @@ -1342,13 +1354,17 @@ void *hls_unified_thread_func(void *arg) { if (ret < 0) { char error_buf[AV_ERROR_MAX_STRING_SIZE] = {0}; av_strerror(ret, error_buf, AV_ERROR_MAX_STRING_SIZE); - log_warn("Error writing video packet to HLS for stream %s: %s", stream_name, error_buf); + log_warn("Error writing %s packet to HLS for stream %s: %s", + is_audio_pkt ? "audio" : "video", stream_name, error_buf); } else { - // Successfully processed a packet - last_packet_time = time(NULL); - atomic_store(&ctx->last_packet_time, (int_fast64_t)last_packet_time); - atomic_store(&ctx->consecutive_failures, 0); - atomic_store(&ctx->connection_valid, 1); + // Successfully processed a packet. Only video refreshes the + // liveness timestamp so flowing audio cannot mask a video stall. + if (!is_audio_pkt) { + last_packet_time = time(NULL); + atomic_store(&ctx->last_packet_time, (int_fast64_t)last_packet_time); + atomic_store(&ctx->consecutive_failures, 0); + atomic_store(&ctx->connection_valid, 1); + } // Mark that we've successfully written packets // This is used to determine if we need to recreate the writer on reconnection @@ -1358,9 +1374,7 @@ void *hls_unified_thread_func(void *arg) { // Unlock the writer mutex pthread_mutex_unlock(&writer->mutex); } else { - // This is a non-video packet (likely audio) - // For now, we'll just log it and skip processing - // This prevents the "Invalid packet stream index" errors + // Non-video packet that is not muxed (audio disabled, extra tracks, data) if (pkt->stream_index >= 0 && pkt->stream_index < input_ctx->nb_streams) { const AVStream *stream = input_ctx->streams[pkt->stream_index]; const AVCodecParameters *codecpar = stream->codecpar; @@ -1515,8 +1529,9 @@ void *hls_unified_thread_func(void *arg) { break; } - // Find video stream + // Find video stream (and audio, only when audio is enabled for the stream) video_stream_idx = find_video_stream_index(input_ctx); + audio_stream_idx = ctx->audio_enabled ? find_audio_stream_index(input_ctx) : -1; if (video_stream_idx == -1) { log_error("No video stream found for stream %s during reconnection", stream_name); @@ -2214,6 +2229,9 @@ int start_hls_unified_stream(const char *stream_name) { // Set protocol information in the context ctx->protocol = config.protocol; + // Respect per-stream and instance-wide audio settings + ctx->audio_enabled = config.record_audio && !g_config.audio_disabled; + // Set HLS segment duration. // config.segment_duration is the MP4/recording segment duration (often 900s = 15 min). // For HLS, we need short segments (1-10s). Use 2s default unless the configured diff --git a/src/video/hls_writer.c b/src/video/hls_writer.c index 6dff8240b..17d71fa55 100644 --- a/src/video/hls_writer.c +++ b/src/video/hls_writer.c @@ -175,6 +175,8 @@ hls_writer_t *hls_writer_create(const char *output_dir, const char *stream_name, // Copy output directory and stream name safe_strcpy(writer->output_dir, output_dir, MAX_PATH_LENGTH, 0); safe_strcpy(writer->stream_name, stream_name, MAX_STREAM_NAME, 0); + writer->video_out_index = -1; + writer->audio_out_index = -1; // Ensure segment duration is reasonable but allow lower values for lower latency if (segment_duration < 1) { @@ -306,7 +308,20 @@ hls_writer_t *hls_writer_create(const char *output_dir, const char *stream_name, return writer; } +int hls_writer_audio_supported(const AVCodecParameters *par) { + // Raw AAC from RTSP needs its AudioSpecificConfig so the MPEG-TS muxer can + // emit ADTS headers; without it avformat_write_header() would fail and + // take the video output down with it. + return par && par->codec_type == AVMEDIA_TYPE_AUDIO && + par->codec_id == AV_CODEC_ID_AAC && par->extradata_size > 0; +} + int hls_writer_initialize(hls_writer_t *writer, const AVStream *input_stream) { + return hls_writer_initialize_av(writer, input_stream, NULL); +} + +int hls_writer_initialize_av(hls_writer_t *writer, const AVStream *input_stream, + const AVStream *audio_stream) { if (!writer || !input_stream) { log_error("Invalid parameters for hls_writer_initialize"); return -1; @@ -317,6 +332,11 @@ int hls_writer_initialize(hls_writer_t *writer, const AVStream *input_stream) { return -1; } + // The header is already written; streams cannot be added any more + if (writer->initialized) { + return 0; + } + // Create output stream AVStream *out_stream = avformat_new_stream(writer->output_ctx, NULL); if (!out_stream) { @@ -337,6 +357,7 @@ int hls_writer_initialize(hls_writer_t *writer, const AVStream *input_stream) { // Set stream time base out_stream->time_base = input_stream->time_base; + writer->video_out_index = out_stream->index; // For HLS streaming, we need to set the correct codec parameters // The issue is not with the bitstream filter but with how we're configuring the output @@ -361,6 +382,35 @@ int hls_writer_initialize(hls_writer_t *writer, const AVStream *input_stream) { log_info("Stream %s is not H.264, using default codec parameters", writer->stream_name); } + // Optional audio output: AAC is stream-copied, anything else is skipped + writer->audio_out_index = -1; + if (audio_stream && hls_writer_audio_supported(audio_stream->codecpar)) { + AVStream *audio_out = avformat_new_stream(writer->output_ctx, NULL); + if (!audio_out) { + log_error("Failed to create HLS audio output stream for %s", writer->stream_name); + return -1; + } + ret = avcodec_parameters_copy(audio_out->codecpar, audio_stream->codecpar); + if (ret < 0) { + log_error("Failed to copy audio codec parameters for HLS stream %s", writer->stream_name); + return ret; + } + audio_out->codecpar->codec_tag = 0; + audio_out->time_base = audio_stream->time_base; + writer->audio_out_index = audio_out->index; + + // Audio and video arrive interleaved from RTSP; don't let the muxer + // hold back live video for long if the audio track stalls. + writer->output_ctx->max_interleave_delta = AV_TIME_BASE; + + log_info("HLS stream %s: muxing AAC audio (%d Hz, %d ch) as output stream %d", + writer->stream_name, audio_stream->codecpar->sample_rate, + audio_stream->codecpar->ch_layout.nb_channels, writer->audio_out_index); + } else if (audio_stream) { + log_info("HLS stream %s: skipping unsupported audio codec %s (only AAC with codec config is passed through)", + writer->stream_name, avcodec_get_name(audio_stream->codecpar->codec_id)); + } + // Write the header AVDictionary *options = NULL; ret = avformat_write_header(writer->output_ctx, &options); @@ -381,6 +431,13 @@ int hls_writer_initialize(hls_writer_t *writer, const AVStream *input_stream) { writer->dts_tracker.first_dts = 0; writer->dts_tracker.last_dts = 0; writer->dts_tracker.time_base = out_stream->time_base; + writer->audio_dts_tracker.initialized = 0; + writer->audio_dts_tracker.first_dts = 0; + writer->audio_dts_tracker.last_dts = 0; + if (writer->audio_out_index >= 0) { + writer->audio_dts_tracker.time_base = + writer->output_ctx->streams[writer->audio_out_index]->time_base; + } writer->dts_jump_count = 0; // Let FFmpeg handle manifest file creation @@ -431,14 +488,27 @@ int hls_writer_write_packet(hls_writer_t *writer, const AVPacket *pkt, const AVS writer->last_cleanup_time = now; } - // Lazy initialization of output stream with additional safety checks + const int is_audio = input_stream->codecpar && + input_stream->codecpar->codec_type == AVMEDIA_TYPE_AUDIO; + + // Lazy initialization of output stream with additional safety checks. + // Only a video stream can initialize the writer; callers that want audio + // must use hls_writer_initialize_av() before the first packet. if (!writer->initialized) { + if (is_audio) { + return 0; + } int ret = hls_writer_initialize(writer, input_stream); if (ret < 0) { return ret; } } + // No audio output (audio disabled, absent or unsupported): drop the packet + if (is_audio && writer->audio_out_index < 0) { + return 0; + } + // Double-check if writer has been closed after initialization if (!writer->output_ctx) { log_warn("hls_writer_write_packet: Writer for stream %s has been closed after initialization", writer->stream_name); @@ -575,20 +645,24 @@ int hls_writer_write_packet(hls_writer_t *writer, const AVPacket *pkt, const AVS out_pkt_ptr->pts = out_pkt_ptr->dts; } + /* RTSP may advertise audio before video, so input indices need not + * match the output: route by media type instead. */ + int out_index = is_audio ? writer->audio_out_index : writer->video_out_index; + stream_dts_info_t *dts_tracker = is_audio ? &writer->audio_dts_tracker : &writer->dts_tracker; + // Validate writer context before rescaling - if (!writer->output_ctx || !writer->output_ctx->streams || !writer->output_ctx->streams[0]) { + if (!writer->output_ctx || !writer->output_ctx->streams || out_index < 0 || + out_index >= (int)writer->output_ctx->nb_streams || !writer->output_ctx->streams[out_index]) { log_warn("hls_writer_write_packet: Writer context invalid for stream %s", writer->stream_name); av_packet_free(&out_pkt_ptr); return -1; } - /* This writer emits video only. RTSP may advertise audio before video, - * so the input video index need not match the sole output stream. */ - out_pkt_ptr->stream_index = writer->output_ctx->streams[0]->index; + AVStream *out_stream = writer->output_ctx->streams[out_index]; + out_pkt_ptr->stream_index = out_stream->index; - // Rescale timestamps to output timebase - av_packet_rescale_ts(out_pkt_ptr, input_stream->time_base, - writer->output_ctx->streams[0]->time_base); + // Rescale timestamps from this packet's own input timebase to the output timebase + av_packet_rescale_ts(out_pkt_ptr, input_stream->time_base, out_stream->time_base); // CRITICAL FIX: Ensure PTS >= DTS after rescaling if (out_pkt_ptr->pts < out_pkt_ptr->dts) { @@ -605,22 +679,22 @@ int hls_writer_write_packet(hls_writer_t *writer, const AVPacket *pkt, const AVS } } - // CRITICAL FIX: Enforce monotonically increasing DTS + // CRITICAL FIX: Enforce monotonically increasing DTS (per output stream) // The HLS muxer requires DTS to be strictly increasing - if (!writer->dts_tracker.initialized) { + if (!dts_tracker->initialized) { // First packet - initialize the tracker - writer->dts_tracker.first_dts = out_pkt_ptr->dts; - writer->dts_tracker.last_dts = out_pkt_ptr->dts; - writer->dts_tracker.time_base = writer->output_ctx->streams[0]->time_base; - writer->dts_tracker.initialized = 1; + dts_tracker->first_dts = out_pkt_ptr->dts; + dts_tracker->last_dts = out_pkt_ptr->dts; + dts_tracker->time_base = out_stream->time_base; + dts_tracker->initialized = 1; log_debug("Initialized DTS tracker for stream %s: first_dts=%lld", writer->stream_name, (long long)out_pkt_ptr->dts); } else { // Check if DTS is monotonically increasing - if (out_pkt_ptr->dts <= writer->dts_tracker.last_dts) { + if (out_pkt_ptr->dts <= dts_tracker->last_dts) { // DTS is not increasing - fix it int64_t old_dts = out_pkt_ptr->dts; - out_pkt_ptr->dts = writer->dts_tracker.last_dts + 1; + out_pkt_ptr->dts = dts_tracker->last_dts + 1; // Also adjust PTS to maintain the relationship if (out_pkt_ptr->pts < out_pkt_ptr->dts) { @@ -629,7 +703,7 @@ int hls_writer_write_packet(hls_writer_t *writer, const AVPacket *pkt, const AVS log_debug("Fixed non-monotonic DTS for stream %s: old_dts=%lld, new_dts=%lld, last_dts=%lld", writer->stream_name, (long long)old_dts, (long long)out_pkt_ptr->dts, - (long long)writer->dts_tracker.last_dts); + (long long)dts_tracker->last_dts); writer->dts_jump_count++; if (writer->dts_jump_count % 10 == 0) { @@ -639,11 +713,11 @@ int hls_writer_write_packet(hls_writer_t *writer, const AVPacket *pkt, const AVS } // Update last DTS - writer->dts_tracker.last_dts = out_pkt_ptr->dts; + dts_tracker->last_dts = out_pkt_ptr->dts; } // Log key frames for diagnostics - bool is_key_frame = (out_pkt_ptr->flags & AV_PKT_FLAG_KEY) != 0; + bool is_key_frame = !is_audio && (out_pkt_ptr->flags & AV_PKT_FLAG_KEY) != 0; if (is_key_frame) { log_debug("Writing key frame to HLS for stream %s: pts=%lld, dts=%lld, size=%d", writer->stream_name, (long long)out_pkt_ptr->pts, (long long)out_pkt_ptr->dts, out_pkt_ptr->size); @@ -1085,10 +1159,11 @@ void hls_writer_close(hls_writer_t *writer) { writer->bsf_ctx = NULL; } - // Reset DTS tracker + // Reset DTS trackers writer->dts_tracker.first_dts = 0; writer->dts_tracker.last_dts = 0; writer->dts_tracker.initialized = 0; + writer->audio_dts_tracker.initialized = 0; // Unregister the writer from global tracking unregister_hls_writer(writer); diff --git a/tests/unit/test_hls_cleanup.c b/tests/unit/test_hls_cleanup.c index 12c2e9c0f..89dfb2e4f 100644 --- a/tests/unit/test_hls_cleanup.c +++ b/tests/unit/test_hls_cleanup.c @@ -13,7 +13,11 @@ #include #include +#include + +#include #include +#include #include "unity.h" #include "core/config.h" @@ -288,11 +292,237 @@ void test_parallel_unified_stream_shutdown_and_restart(void) { TEST_ASSERT_EQUAL_INT(0, atomic_load(&disposition_changes)); } +typedef struct { + AVCodecParameters *params; + AVRational time_base; + AVPacket **packets; + int count; +} audio_fixture_t; + +static void audio_fixture_append(audio_fixture_t *fixture, const AVPacket *packet) { + fixture->packets = realloc(fixture->packets, sizeof(AVPacket *) * (fixture->count + 1)); + TEST_ASSERT_NOT_NULL(fixture->packets); + fixture->packets[fixture->count] = av_packet_clone(packet); + TEST_ASSERT_NOT_NULL(fixture->packets[fixture->count]); + fixture->count++; +} + +static void audio_fixture_free(audio_fixture_t *fixture) { + for (int i = 0; i < fixture->count; ++i) av_packet_free(&fixture->packets[i]); + free(fixture->packets); + avcodec_parameters_free(&fixture->params); +} + +/* Encode a sine tone with FFmpeg's native AAC encoder, as an RTSP AAC track + * would deliver it: raw access units plus an AudioSpecificConfig. */ +static void make_aac_fixture(audio_fixture_t *fixture, int seconds) { + const AVCodec *codec = avcodec_find_encoder(AV_CODEC_ID_AAC); + TEST_ASSERT_NOT_NULL(codec); + AVCodecContext *encoder = avcodec_alloc_context3(codec); + TEST_ASSERT_NOT_NULL(encoder); + encoder->sample_rate = 16000; + encoder->sample_fmt = AV_SAMPLE_FMT_FLTP; + encoder->bit_rate = 32000; + encoder->time_base = (AVRational){1, encoder->sample_rate}; + encoder->flags |= AV_CODEC_FLAG_GLOBAL_HEADER; + av_channel_layout_default(&encoder->ch_layout, 1); + TEST_ASSERT_EQUAL_INT(0, avcodec_open2(encoder, codec, NULL)); + + AVFrame *frame = av_frame_alloc(); + AVPacket *packet = av_packet_alloc(); + TEST_ASSERT_NOT_NULL(frame); + TEST_ASSERT_NOT_NULL(packet); + frame->nb_samples = encoder->frame_size; + frame->format = encoder->sample_fmt; + frame->sample_rate = encoder->sample_rate; + TEST_ASSERT_EQUAL_INT(0, av_channel_layout_copy(&frame->ch_layout, &encoder->ch_layout)); + TEST_ASSERT_EQUAL_INT(0, av_frame_get_buffer(frame, 0)); + + for (int64_t pts = 0; ; pts += frame->nb_samples) { + AVFrame *input = NULL; + if (pts < (int64_t)seconds * encoder->sample_rate) { + TEST_ASSERT_EQUAL_INT(0, av_frame_make_writable(frame)); + float *samples = (float *)frame->data[0]; + for (int i = 0; i < frame->nb_samples; ++i) + samples[i] = 0.5f * sinf(2.0f * (float)M_PI * 440.0f * (float)(pts + i) / 16000.0f); + frame->pts = pts; + input = frame; + } + TEST_ASSERT_EQUAL_INT(0, avcodec_send_frame(encoder, input)); + while (avcodec_receive_packet(encoder, packet) == 0) { + audio_fixture_append(fixture, packet); + av_packet_unref(packet); + } + if (!input) break; + } + + fixture->params = avcodec_parameters_alloc(); + TEST_ASSERT_NOT_NULL(fixture->params); + TEST_ASSERT_EQUAL_INT(0, avcodec_parameters_from_context(fixture->params, encoder)); + fixture->time_base = encoder->time_base; + av_packet_free(&packet); + av_frame_free(&frame); + avcodec_free_context(&encoder); +} + +/* G.711 u-law, which the HLS fallback does not transcode */ +static void make_pcmu_fixture(audio_fixture_t *fixture, int seconds) { + fixture->params = avcodec_parameters_alloc(); + TEST_ASSERT_NOT_NULL(fixture->params); + fixture->params->codec_type = AVMEDIA_TYPE_AUDIO; + fixture->params->codec_id = AV_CODEC_ID_PCM_MULAW; + fixture->params->sample_rate = 8000; + av_channel_layout_default(&fixture->params->ch_layout, 1); + fixture->time_base = (AVRational){1, 8000}; + AVPacket *packet = av_packet_alloc(); + TEST_ASSERT_NOT_NULL(packet); + for (int64_t pts = 0; pts < (int64_t)seconds * 8000; pts += 160) { + TEST_ASSERT_EQUAL_INT(0, av_new_packet(packet, 160)); + memset(packet->data, 0xff, packet->size); + packet->pts = packet->dts = pts; + packet->duration = 160; + audio_fixture_append(fixture, packet); + av_packet_unref(packet); + } + av_packet_free(&packet); +} + +typedef struct { + int video_streams; + int audio_streams; + int audio_packets; + enum AVCodecID audio_codec; + double first_video_s; + double first_audio_s; +} hls_output_summary_t; + +/* Feed fixture video plus the given audio track (listed before video, as + * some RTSP cameras do) through the native HLS writer and summarize the + * resulting playlist. mux_audio = 0 mirrors a stream with audio disabled: + * the writer gets no audio stream but still receives audio packets. */ +static hls_output_summary_t write_av_hls(const char *name, const audio_fixture_t *audio_fixture, + int mux_audio) { + AVFormatContext *fixture = NULL; + TEST_ASSERT_EQUAL_INT(0, avformat_open_input(&fixture, TEST_RECORDING_PATH, NULL, NULL)); + TEST_ASSERT_GREATER_OR_EQUAL(0, avformat_find_stream_info(fixture, NULL)); + + AVFormatContext *input = avformat_alloc_context(); + TEST_ASSERT_NOT_NULL(input); + AVStream *audio = avformat_new_stream(input, NULL); + AVStream *video = avformat_new_stream(input, NULL); + TEST_ASSERT_NOT_NULL(audio); + TEST_ASSERT_NOT_NULL(video); + TEST_ASSERT_EQUAL_INT(0, avcodec_parameters_copy(audio->codecpar, audio_fixture->params)); + audio->time_base = audio_fixture->time_base; + TEST_ASSERT_EQUAL_INT(0, avcodec_parameters_copy(video->codecpar, fixture->streams[0]->codecpar)); + video->time_base = fixture->streams[0]->time_base; + + hls_writer_t *writer = hls_writer_create(test_dir, name, 1); + TEST_ASSERT_NOT_NULL(writer); + TEST_ASSERT_EQUAL_INT(0, hls_writer_initialize_av(writer, video, mux_audio ? audio : NULL)); + + AVPacket *packet = av_packet_alloc(); + TEST_ASSERT_NOT_NULL(packet); + int next_audio = 0; + for (;;) { + TEST_ASSERT_EQUAL_INT(0, av_read_frame(fixture, packet)); + if (av_compare_ts(packet->dts, video->time_base, 3, (AVRational){1, 1}) >= 0) { + av_packet_unref(packet); + break; + } + // Interleave audio up to this video packet's timestamp + while (next_audio < audio_fixture->count && + av_compare_ts(audio_fixture->packets[next_audio]->dts, audio->time_base, + packet->dts, video->time_base) <= 0) { + AVPacket *audio_packet = audio_fixture->packets[next_audio++]; + audio_packet->stream_index = audio->index; + TEST_ASSERT_GREATER_OR_EQUAL(0, hls_writer_write_packet(writer, audio_packet, audio)); + } + packet->stream_index = video->index; + TEST_ASSERT_GREATER_OR_EQUAL(0, hls_writer_write_packet(writer, packet, video)); + av_packet_unref(packet); + } + + char playlist[MAX_PATH_LENGTH + 32]; + snprintf(playlist, sizeof(playlist), "%s/index.m3u8", writer->output_dir); + hls_writer_close(writer); + + hls_output_summary_t summary = {0}; + summary.first_video_s = summary.first_audio_s = -1; + AVFormatContext *recorded = NULL; + TEST_ASSERT_EQUAL_INT(0, avformat_open_input(&recorded, playlist, NULL, NULL)); + TEST_ASSERT_GREATER_OR_EQUAL(0, avformat_find_stream_info(recorded, NULL)); + for (unsigned int i = 0; i < recorded->nb_streams; ++i) { + const AVCodecParameters *par = recorded->streams[i]->codecpar; + if (par->codec_type == AVMEDIA_TYPE_VIDEO) summary.video_streams++; + if (par->codec_type == AVMEDIA_TYPE_AUDIO) { + summary.audio_streams++; + summary.audio_codec = par->codec_id; + } + } + while (av_read_frame(recorded, packet) == 0) { + const AVStream *stream = recorded->streams[packet->stream_index]; + double seconds = packet->pts * av_q2d(stream->time_base); + if (stream->codecpar->codec_type == AVMEDIA_TYPE_AUDIO) { + summary.audio_packets++; + if (summary.first_audio_s < 0) summary.first_audio_s = seconds; + } else if (stream->codecpar->codec_type == AVMEDIA_TYPE_VIDEO && + summary.first_video_s < 0) { + summary.first_video_s = seconds; + } + av_packet_unref(packet); + } + avformat_close_input(&recorded); + av_packet_free(&packet); + avformat_free_context(input); + avformat_close_input(&fixture); + return summary; +} + +void test_writer_muxes_aac_audio_listed_before_video(void) { + audio_fixture_t aac = {0}; + make_aac_fixture(&aac, 4); + TEST_ASSERT_TRUE(hls_writer_audio_supported(aac.params)); + + hls_output_summary_t summary = write_av_hls("aac-audio", &aac, 1); + TEST_ASSERT_EQUAL_INT(1, summary.video_streams); + TEST_ASSERT_EQUAL_INT(1, summary.audio_streams); + TEST_ASSERT_EQUAL_INT(AV_CODEC_ID_AAC, summary.audio_codec); + TEST_ASSERT_GREATER_THAN(0, summary.audio_packets); + // Both tracks keep their own timebase origin, so they start together + TEST_ASSERT_TRUE(fabs(summary.first_audio_s - summary.first_video_s) < 0.2); + audio_fixture_free(&aac); +} + +void test_writer_emits_video_only_when_audio_disabled(void) { + audio_fixture_t aac = {0}; + make_aac_fixture(&aac, 4); + hls_output_summary_t summary = write_av_hls("audio-disabled", &aac, 0); + TEST_ASSERT_EQUAL_INT(1, summary.video_streams); + TEST_ASSERT_EQUAL_INT(0, summary.audio_streams); + TEST_ASSERT_EQUAL_INT(0, summary.audio_packets); + audio_fixture_free(&aac); +} + +void test_writer_skips_unsupported_audio_codec(void) { + audio_fixture_t pcmu = {0}; + make_pcmu_fixture(&pcmu, 4); + TEST_ASSERT_FALSE(hls_writer_audio_supported(pcmu.params)); + hls_output_summary_t summary = write_av_hls("pcmu-audio", &pcmu, 1); + TEST_ASSERT_EQUAL_INT(1, summary.video_streams); + TEST_ASSERT_EQUAL_INT(0, summary.audio_streams); + TEST_ASSERT_EQUAL_INT(0, summary.audio_packets); + audio_fixture_free(&pcmu); +} + int main(void) { init_logger(); UNITY_BEGIN(); RUN_TEST(test_cache_maintenance_uses_dedicated_path_then_default_path); RUN_TEST(test_writer_remaps_input_video_after_audio_to_output_stream_zero); + RUN_TEST(test_writer_muxes_aac_audio_listed_before_video); + RUN_TEST(test_writer_emits_video_only_when_audio_disabled); + RUN_TEST(test_writer_skips_unsupported_audio_codec); RUN_TEST(test_parallel_writer_teardown_and_recreation_preserves_signals); RUN_TEST(test_parallel_unified_stream_shutdown_and_restart); RUN_TEST(test_unified_system_cleanup_preserves_signals); From 813fdafada1e5a25902875a6020f333927312ae9 Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Sat, 24 Oct 2026 05:22:34 -0400 Subject: [PATCH 2/2] ci: run test_hls_cleanup in the Unity coverage job The native HLS writer tests were built by ctest but not by the coverage job, so codecov saw no hits for the HLS fallback audio changes. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/integration-test.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 3969b56fe..02ea128c2 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -118,6 +118,7 @@ jobs: test_onvif_soap_fault \ test_faac_encoder \ test_audio_transcoder \ + test_hls_cleanup \ test_stream_manager \ test_stream_state \ test_packet_buffer \ @@ -132,7 +133,7 @@ jobs: run: | cd build ctest --output-on-failure -V \ - -R "test_storage_pressure$|test_storage_pressure_extended|test_detection_result_structures|test_storage_retention_sqlite|test_storage_manager_retention|test_storage_target_pressure_cleanup|test_storage_migration|test_storage_archive_s3|test_storage_archive_operator|test_authorization|test_api_handlers_recordings_playback|test_db_storage_targets|test_db_storage_policies|test_api_handlers_storage_targets|test_api_handlers_storage_policies|test_config|test_logger|test_strings|test_memory|test_request_response|test_shutdown_coordinator|test_detection_config|test_detection_model_motion|test_db_streams|test_db_camera_favorites|test_api_handlers_camera_favorites|test_db_backup|test_mp4_failed_open_memory|test_db_recordings_extended|test_db_detections|test_db_zones|test_db_events|test_db_auth|test_db_transactions|test_db_maintenance|test_db_query_builder|test_logger_json|test_batch_delete_progress|test_db_recordings_sync|test_httpd_utils|test_zone_filter|test_onvif_soap_fault|test_faac_encoder|test_audio_transcoder|test_stream_manager|test_stream_state|test_packet_buffer|test_timestamp_manager|test_motion_trigger_parse|test_motion_max_area|test_url_utils" + -R "test_storage_pressure$|test_storage_pressure_extended|test_detection_result_structures|test_storage_retention_sqlite|test_storage_manager_retention|test_storage_target_pressure_cleanup|test_storage_migration|test_storage_archive_s3|test_storage_archive_operator|test_authorization|test_api_handlers_recordings_playback|test_db_storage_targets|test_db_storage_policies|test_api_handlers_storage_targets|test_api_handlers_storage_policies|test_config|test_logger|test_strings|test_memory|test_request_response|test_shutdown_coordinator|test_detection_config|test_detection_model_motion|test_db_streams|test_db_camera_favorites|test_api_handlers_camera_favorites|test_db_backup|test_mp4_failed_open_memory|test_db_recordings_extended|test_db_detections|test_db_zones|test_db_events|test_db_auth|test_db_transactions|test_db_maintenance|test_db_query_builder|test_logger_json|test_batch_delete_progress|test_db_recordings_sync|test_httpd_utils|test_zone_filter|test_onvif_soap_fault|test_faac_encoder|test_audio_transcoder|test_hls_cleanup|test_stream_manager|test_stream_state|test_packet_buffer|test_timestamp_manager|test_motion_trigger_parse|test_motion_max_area|test_url_utils" - name: Generate coverage report if: always()