diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 3969b56f..02ea128c 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() diff --git a/include/video/hls/hls_unified_thread.h b/include/video/hls/hls_unified_thread.h index 4f90301e..2105b393 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 9210b451..843a926f 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 4a1b6ba7..5dedc693 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 6dff8240..17d71fa5 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 12c2e9c0..89dfb2e4 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);