diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index e4e250e3..67eddc09 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -35,7 +35,7 @@ jobs: apt-get update apt-get install -y \ git build-essential cmake pkg-config \ - libavcodec-dev libavformat-dev libavutil-dev libswscale-dev \ + libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libavfilter-dev \ libsqlite3-dev libuv1-dev libllhttp-dev libcurl4-openssl-dev \ libcjson-dev libmbedtls-dev libmosquitto-dev diff --git a/.github/workflows/debian-package.yml b/.github/workflows/debian-package.yml index 7976437c..f6125041 100644 --- a/.github/workflows/debian-package.yml +++ b/.github/workflows/debian-package.yml @@ -210,12 +210,12 @@ jobs: # ── Suite-specific FFmpeg dependency names ───────────────────────── case "${DEBIAN_SUITE}" in sid) - FFMPEG_DEPS="libavcodec62 | libavcodec-extra, libavformat62, libavutil60, libswscale9" + FFMPEG_DEPS="libavcodec62 | libavcodec-extra, libavformat62, libavutil60, libswscale9, libavfilter11" LLHTTP_DEP="libllhttp9.3" SUITE_NOTE="Note: This package targets Debian sid (unstable)." ;; trixie) - FFMPEG_DEPS="libavcodec61 | libavcodec-extra, libavformat61, libavutil59, libswscale8" + FFMPEG_DEPS="libavcodec61 | libavcodec-extra, libavformat61, libavutil59, libswscale8, libavfilter10" LLHTTP_DEP="libllhttp9.2" SUITE_NOTE="Note: This package targets Debian 13 trixie (stable)." ;; diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 02ea128c..42b0df18 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -34,6 +34,7 @@ jobs: libavformat-dev \ libavutil-dev \ libswscale-dev \ + libavfilter-dev \ libsqlite3-dev \ libuv1-dev \ libllhttp-dev \ @@ -118,6 +119,11 @@ jobs: test_onvif_soap_fault \ test_faac_encoder \ test_audio_transcoder \ + test_audio_voice_enhancement \ + test_audio_voice_filter_faults \ + test_udt_recording \ + test_detection_current_frame \ + test_detection_io_recovery \ test_hls_cleanup \ test_stream_manager \ test_stream_state \ @@ -133,7 +139,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_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" + -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_audio_voice_enhancement|test_audio_voice_filter_faults|test_udt_recording|test_detection_current_frame|test_detection_io_recovery|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() @@ -213,6 +219,7 @@ jobs: libavformat-dev \ libavutil-dev \ libswscale-dev \ + libavfilter-dev \ libswresample-dev \ libsqlite3-dev \ libuv1-dev \ @@ -378,6 +385,7 @@ jobs: libavformat-dev \ libavutil-dev \ libswscale-dev \ + libavfilter-dev \ libsqlite3-dev \ libuv1-dev \ libllhttp-dev \ @@ -508,6 +516,7 @@ jobs: libavformat-dev \ libavutil-dev \ libswscale-dev \ + libavfilter-dev \ libsqlite3-dev \ libuv1-dev \ libllhttp-dev \ diff --git a/.github/workflows/sanitizer.yml b/.github/workflows/sanitizer.yml index 93b3bf7a..7488d1aa 100644 --- a/.github/workflows/sanitizer.yml +++ b/.github/workflows/sanitizer.yml @@ -27,7 +27,7 @@ jobs: apt-get update apt-get install -y \ git build-essential cmake pkg-config python3 \ - libavcodec-dev libavformat-dev libavutil-dev libswscale-dev \ + libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libavfilter-dev \ libsqlite3-dev libuv1-dev libllhttp-dev libcurl4-openssl-dev \ libcjson-dev libmbedtls-dev @@ -60,7 +60,7 @@ jobs: cd build echo "Running tests with ASan + UBSan enabled..." ctest --output-on-failure -V \ - -R "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_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_faac_encoder|test_audio_transcoder|test_stream_manager|test_stream_state|test_packet_buffer|test_timestamp_manager|test_storage_pressure$|test_storage_pressure_extended|test_detection_result_structures|test_storage_retention_sqlite|test_audit_log|test_audit_summary" + -R "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_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_faac_encoder|test_audio_transcoder|test_audio_voice_enhancement|test_audio_voice_filter_faults|test_udt_recording|test_detection_current_frame|test_detection_io_recovery|test_stream_manager|test_stream_state|test_packet_buffer|test_timestamp_manager|test_storage_pressure$|test_storage_pressure_extended|test_detection_result_structures|test_storage_retention_sqlite|test_audit_log|test_audit_summary" - name: Smoke test - start and stop service run: | diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index 64ea6cbe..d56351f6 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -41,7 +41,7 @@ jobs: apt-get update apt-get install -y \ git build-essential cmake pkg-config clang-tidy \ - libavcodec-dev libavformat-dev libavutil-dev libswscale-dev \ + libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libavfilter-dev \ libsqlite3-dev libuv1-dev libllhttp-dev libcurl4-openssl-dev \ libcjson-dev libmbedtls-dev libmosquitto-dev diff --git a/CMakeLists.txt b/CMakeLists.txt index bf343894..048be76e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -163,6 +163,24 @@ else() message(WARNING "libyaml (yaml-0.1) not found. YAML validation and go2rtc override base_path detection will be disabled. Install libyaml-dev / yaml-dev to enable.") endif() +# libavfilter (optional) — per-stream voice-enhancement filter chain applied +# while transcoding PCM audio to AAC (issue #652). Without it the opt-in is +# accepted but logs that it is unavailable. The afftdn denoiser is the costly +# stage, so the embedded profile leaves it out by default. +option(ENABLE_AUDIO_FILTERS "Enable libavfilter audio filters (voice enhancement)" ON) +option(ENABLE_AUDIO_DENOISE "Include the afftdn denoiser in voice enhancement" ${LIGHTNVR_OPTIONAL_FEATURE_DEFAULT}) +if(ENABLE_AUDIO_FILTERS) + pkg_check_modules(AVFILTER QUIET libavfilter>=10) + if(AVFILTER_FOUND) + message(STATUS "Found libavfilter: ${AVFILTER_VERSION} (denoise: ${ENABLE_AUDIO_DENOISE})") + add_compile_definitions(LIGHTNVR_HAVE_AVFILTER=1 + LIGHTNVR_AUDIO_DENOISE=$) + else() + message(WARNING "libavfilter >= 10 not found. Voice enhancement will be unavailable. Install libavfilter-dev to enable.") + set(ENABLE_AUDIO_FILTERS OFF) + endif() +endif() + # MQTT support (optional, uses libmosquitto) option(ENABLE_MQTT "Enable MQTT support for detection event streaming" ${LIGHTNVR_OPTIONAL_FEATURE_DEFAULT}) if(ENABLE_MQTT) @@ -760,6 +778,15 @@ if(YAML_FOUND) target_link_libraries(lightnvr ${YAML_LIBRARIES}) endif() +# Link libavfilter if enabled (voice enhancement) +if(ENABLE_AUDIO_FILTERS AND AVFILTER_FOUND) + if(AVFILTER_LIBRARY_DIRS) + target_link_directories(lightnvr_lib PUBLIC ${AVFILTER_LIBRARY_DIRS}) + endif() + target_link_libraries(lightnvr_lib PUBLIC ${AVFILTER_LIBRARIES}) + target_include_directories(lightnvr_lib PUBLIC ${AVFILTER_INCLUDE_DIRS}) +endif() + # Link MQTT library if enabled if(ENABLE_MQTT AND MOSQUITTO_FOUND) if(MOSQUITTO_LIBRARY_DIRS) diff --git a/Dockerfile b/Dockerfile index 912eb5e9..cf9206dc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,7 +27,7 @@ RUN if [ "$TARGETARCH/$TARGETVARIANT" = "arm/v7" ]; then \ test -n "$GCC_MAJOR" && \ apt-get install -y --no-install-recommends \ "libstdc++-${GCC_MAJOR}-dev" \ - libavcodec-dev libavformat-dev libavutil-dev libswscale-dev \ + libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libavfilter-dev \ libcurl4-openssl-dev libmbedtls-dev libcjson-dev \ libmosquitto-dev libyaml-dev && \ if [ "$DEB_BUILD" = "true" ]; then \ @@ -104,7 +104,7 @@ RUN if [ "$TARGETARCH/$TARGETVARIANT" = "arm/v7" ]; then \ rm /tmp/armv7-toolchain-check; \ else \ apt-get install -y --no-install-recommends \ - libavcodec-dev libavformat-dev libavutil-dev libswscale-dev \ + libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libavfilter-dev \ libcurl4-openssl-dev libmbedtls-dev libcjson-dev \ libmosquitto-dev libyaml-dev; \ fi && \ diff --git a/README.md b/README.md index ba9bb7b0..f5714580 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,8 @@ Powerful object detection using modern ONNX and TFLite models with zone-aware fi Prerequisites (in addition to `build-essential`, `cmake`, `pkg-config`): - **FFmpeg ≥ 7.1** (`libavcodec ≥ 61`, `libavformat ≥ 61`, `libavutil ≥ 59`, - `libswscale ≥ 8`, `libswresample ≥ 5`). Debian 12 (bookworm) and Ubuntu + `libswscale ≥ 8`, `libswresample ≥ 5`; optional `libavfilter ≥ 10` for + per-stream voice enhancement). Debian 12 (bookworm) and Ubuntu 22.04 ship FFmpeg 5.x/6.x, which is too old — build FFmpeg from source or use a newer distro release (Debian 13 / Ubuntu 24.04+ ship a compatible version). diff --git a/docs/BUILD.md b/docs/BUILD.md index 363189c4..abce72a7 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -20,6 +20,7 @@ sudo apt-get install -y \ libavformat-dev \ libavutil-dev \ libswscale-dev \ + libavfilter-dev \ libcurl4-openssl-dev \ libmbedtls-dev \ curl \ @@ -104,6 +105,25 @@ When built without SOD, LightNVR will still function normally but will not have For more information about SOD integration, see [SOD Integration](SOD_INTEGRATION.md). +### Voice enhancement (libavfilter) + +The per-stream **Voice Enhancement** option filters recorded audio while it is +transcoded from PCM (G.711 μ-law/A-law, raw PCM) to AAC: two cascaded +`highpass=f=100` sections for hum and rumble, `lowpass=f=8000` when the source +rate carries content above 8 kHz, and the `afftdn` adaptive denoiser. It needs +`libavfilter` (`libavfilter-dev`): + +- `ENABLE_AUDIO_FILTERS` (default `ON`) links libavfilter when pkg-config finds + it. When it is missing or disabled, the build still succeeds and turning the + option on logs that it is unavailable; audio records unfiltered. +- `ENABLE_AUDIO_DENOISE` (default `ON`, `OFF` in the embedded profile) includes + `afftdn`, the CPU-heavy stage. With it off, only the highpass/lowpass run. + +Audio that the camera already sends in an MP4-compatible codec (AAC, MP3, +Opus, AC-3) is passed through to the recording without re-encoding, and is +**not** filtered. Live audio is never filtered. If the filter graph cannot be +built, LightNVR logs a warning once and keeps recording unfiltered audio. + ### Embedded build profile Resource-constrained targets can select the embedded profile without changing @@ -117,9 +137,10 @@ cmake --build build/embedded -- -j$(nproc) ``` The embedded profile caps compile-time stream storage at 16 streams, optimizes -for size, and defaults SOD, LiteRT, and MQTT off. The individual feature -switches remain available when an embedded target needs one of them. Full -builds remain the default and retain the 1024-stream ceiling. +for size, and defaults SOD, LiteRT, MQTT, and the voice-enhancement denoiser +(`ENABLE_AUDIO_DENOISE`) off. The individual feature switches remain available +when an embedded target needs one of them. Full builds remain the default and +retain the 1024-stream ceiling. `LIGHTNVR_MAX_STREAMS` can override the full-profile ceiling. Embedded builds accept values from 1 through 16 only. The older `EMBEDDED_A1_DEVICE=ON` option diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 4cf9f8b6..fd97124a 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -249,6 +249,7 @@ sudo apt-get install -y \ libavformat-dev \ libavutil-dev \ libswscale-dev \ + libavfilter-dev \ libuv1-dev \ libcurl4-openssl-dev \ libmbedtls-dev \ @@ -420,6 +421,7 @@ sudo apt-get install -y \ libavformat-dev \ libavutil-dev \ libswscale-dev \ + libavfilter-dev \ libuv1-dev \ libcurl4-openssl-dev \ libmbedtls-dev \ diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index d4b5da21..ad260ea5 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -37,6 +37,7 @@ sudo apt-get install -y \ libavformat-dev \ libavutil-dev \ libswscale-dev \ + libavfilter-dev \ libuv1-dev \ libcurl4-openssl-dev \ libssl-dev diff --git a/include/video/unified_detection_thread.h b/include/video/unified_detection_thread.h index cf8f6a8b..5c24062c 100644 --- a/include/video/unified_detection_thread.h +++ b/include/video/unified_detection_thread.h @@ -117,6 +117,7 @@ typedef struct { // Audio recording configuration bool record_audio; // Whether to include audio in recordings + bool audio_voice_enhancement; // Voice-enhancement filter for transcoded PCM audio // External motion trigger: set to 1 by unified_detection_notify_motion() when // an ONVIF-managed master stream propagates its motion event to this UDT-managed diff --git a/src/video/mp4_writer_utils.c b/src/video/mp4_writer_utils.c index 99e68c16..a4aa333f 100644 --- a/src/video/mp4_writer_utils.c +++ b/src/video/mp4_writer_utils.c @@ -30,6 +30,11 @@ #include #include #include +#ifdef LIGHTNVR_HAVE_AVFILTER +#include +#include +#include +#endif #include #include "core/config.h" @@ -59,16 +64,19 @@ typedef struct { bool faac_flushed; int64_t next_faac_pts; - // Voice-enhancement filter graph (discussion #395). filter_graph is NULL - // when the feature is disabled or the graph hasn't been constructed yet. - // The actual filter chain (afftdn / highpass / lowpass, codec-aware - // defaults) is deferred to a follow-up PR — for now this is a stub that - // routes the per-stream opt-in through to the transcoder lifecycle so - // future filter-graph code has a stable insertion point. + // Voice-enhancement filter graph (discussion #395, issue #652). + // filter_graph is NULL when the feature is disabled, the graph hasn't + // been built yet (it is built lazily from the first decoded frame), or + // building it failed. When the graph is active its S16 output feeds the + // FIFO directly and swr_ctx is bypassed. bool voice_enhancement_enabled; - void *filter_graph; // AVFilterGraph*; void* to avoid header churn - void *filter_buffersrc_ctx; // AVFilterContext* (buffer source) - void *filter_buffersink_ctx; // AVFilterContext* (buffer sink) + bool filter_failed; // build/run failed (or unsupported); logged once +#ifdef LIGHTNVR_HAVE_AVFILTER + AVFilterGraph *filter_graph; + AVFilterContext *filter_buffersrc_ctx; + AVFilterContext *filter_buffersink_ctx; + int64_t filter_next_pts; // synthetic input pts, 1/sample_rate units +#endif } audio_transcoder_t; /** @@ -111,6 +119,212 @@ typedef struct { } voice_enhancement_pending_t; static voice_enhancement_pending_t voice_enhancement_pending[MAX_STREAMS] = {0}; +#ifdef LIGHTNVR_HAVE_AVFILTER +#ifndef LIGHTNVR_AUDIO_DENOISE +#define LIGHTNVR_AUDIO_DENOISE 1 +#endif + +// Speech-band defaults for the voice-enhancement chain. The highpass (two +// cascaded 2-pole sections, 24 dB/octave: roughly -25 dB at 50 Hz and -19 dB at +// 60 Hz) removes mains hum, wind rumble and handling noise while leaving voice +// fundamentals mostly intact; the lowpass trims hiss above the +// speech band and is only added when the source actually carries content up +// there (narrowband G.711 at 8 kHz already stops at 4 kHz); afftdn lowers the +// remaining broadband noise floor while tracking it adaptively. afftdn is the +// expensive stage, so embedded builds leave it out (LIGHTNVR_AUDIO_DENOISE=0). +#define VOICE_HIGHPASS_HZ 100 +#define VOICE_LOWPASS_HZ 8000 +#define VOICE_DENOISE_OPTS "nr=12:nf=-40:tn=1" + +static void voice_filter_free(audio_transcoder_t *t) +{ + avfilter_graph_free(&t->filter_graph); + t->filter_buffersrc_ctx = NULL; + t->filter_buffersink_ctx = NULL; +} + +/* abuffer -> highpass x2 [-> lowpass] [-> afftdn] -> aformat(s16, rate) -> abuffersink. + * The output matches what the FIFO holds (interleaved S16 at the decoder's rate + * and channel count), so filtered frames bypass swr_ctx. */ +static int voice_filter_build(audio_transcoder_t *t, const AVFrame *frame, + const char *stream_name) +{ + char layout[64]; + char args[256]; + char chain[256]; + AVFilterInOut *outputs = NULL; + AVFilterInOut *inputs = NULL; + int sr = frame->sample_rate > 0 ? frame->sample_rate : t->decoder_ctx->sample_rate; + const char *fmt_name = av_get_sample_fmt_name(frame->format); + if (sr <= 0 || !fmt_name) return AVERROR(EINVAL); + + int ret = av_channel_layout_describe(&frame->ch_layout, layout, sizeof(layout)); + if (ret < 0) return ret; + + t->filter_graph = avfilter_graph_alloc(); + if (!t->filter_graph) return AVERROR(ENOMEM); + // The transcoder already runs on the recording thread; a worker pool for + // a mono speech chain costs more than it saves. + t->filter_graph->nb_threads = 1; + + snprintf(args, sizeof(args), + "time_base=1/%d:sample_rate=%d:sample_fmt=%s:channel_layout=%s", + sr, sr, fmt_name, layout); + ret = avfilter_graph_create_filter(&t->filter_buffersrc_ctx, + avfilter_get_by_name("abuffer"), "in", + args, NULL, t->filter_graph); + if (ret < 0) goto fail; + ret = avfilter_graph_create_filter(&t->filter_buffersink_ctx, + avfilter_get_by_name("abuffersink"), "out", + NULL, NULL, t->filter_graph); + if (ret < 0) goto fail; + + size_t len = (size_t)snprintf(chain, sizeof(chain), "highpass=f=%d,highpass=f=%d", + VOICE_HIGHPASS_HZ, VOICE_HIGHPASS_HZ); + if (sr / 2 > VOICE_LOWPASS_HZ) { + len += (size_t)snprintf(chain + len, sizeof(chain) - len, ",lowpass=f=%d", + VOICE_LOWPASS_HZ); + } +#if LIGHTNVR_AUDIO_DENOISE + if (avfilter_get_by_name("afftdn")) { + len += (size_t)snprintf(chain + len, sizeof(chain) - len, ",afftdn=%s", + VOICE_DENOISE_OPTS); + } +#endif + snprintf(chain + len, sizeof(chain) - len, ",aformat=sample_fmts=s16:sample_rates=%d", sr); + + outputs = avfilter_inout_alloc(); + inputs = avfilter_inout_alloc(); + if (!outputs || !inputs) { + ret = AVERROR(ENOMEM); + goto fail; + } + outputs->name = av_strdup("in"); + outputs->filter_ctx = t->filter_buffersrc_ctx; + inputs->name = av_strdup("out"); + inputs->filter_ctx = t->filter_buffersink_ctx; + if (!outputs->name || !inputs->name) { + ret = AVERROR(ENOMEM); + goto fail; + } + + ret = avfilter_graph_parse_ptr(t->filter_graph, chain, &inputs, &outputs, NULL); + if (ret < 0) goto fail; + ret = avfilter_graph_config(t->filter_graph, NULL); + if (ret < 0) goto fail; + + avfilter_inout_free(&inputs); + avfilter_inout_free(&outputs); + t->filter_next_pts = 0; + log_info("Voice enhancement active for %s (%d Hz, %s): %s", + stream_name, sr, layout, chain); + return 0; + +fail: + avfilter_inout_free(&inputs); + avfilter_inout_free(&outputs); + voice_filter_free(t); + return ret; +} + +/* Move every frame the graph has ready into the encoder FIFO. */ +static int voice_filter_drain(audio_transcoder_t *t) +{ + AVFrame *out = t->resampled_frame; + for (;;) { + av_frame_unref(out); + int ret = av_buffersink_get_frame(t->filter_buffersink_ctx, out); + if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) return 0; + if (ret < 0) return ret; + int nb_samples = out->nb_samples; + ret = av_audio_fifo_write(t->fifo, (void **)out->data, nb_samples); + av_frame_unref(out); + if (ret < nb_samples) { + recording_io_report_failure(RECORDING_IO_RESOURCE_RECORDING, + RECORDING_IO_OPERATION_ALLOCATE, ENOMEM); + return AVERROR(ENOMEM); + } + } +} + +/* Signal EOF, drain the samples the filters still hold, and free the graph. */ +static int voice_filter_finish(audio_transcoder_t *t) +{ + if (!t->filter_graph) return 0; + int ret = av_buffersrc_add_frame_flags(t->filter_buffersrc_ctx, NULL, 0); + if (ret >= 0) ret = voice_filter_drain(t); + voice_filter_free(t); + return ret; +} + +static void voice_filter_disable(audio_transcoder_t *t, const char *stream_name, + const char *what, int err) +{ + char errbuf[AV_ERROR_MAX_STRING_SIZE]; + av_strerror(err, errbuf, sizeof(errbuf)); + log_warn("Voice enhancement disabled for %s: %s (%s); recording audio unfiltered", + stream_name, what, errbuf); + voice_filter_free(t); + t->filter_failed = true; +} + +/** + * Route the decoded frame in t->frame through the voice-enhancement graph. + * + * @return true when the frame was consumed (its filtered samples are in the + * FIFO), false when the caller should take the plain swr path. Never + * fails the recording: a graph that can't be built or run is logged + * once and the stream continues unfiltered. + */ +static bool voice_filter_process(audio_transcoder_t *t, const char *stream_name) +{ + if (!t->voice_enhancement_enabled) { + // Switched off mid-recording: keep the samples the graph still holds. + if (t->filter_graph) { + int ret = voice_filter_finish(t); + if (ret < 0) voice_filter_disable(t, stream_name, "drain failed", ret); + } + return false; + } + if (t->filter_failed) return false; + + if (!t->filter_graph) { + int ret = voice_filter_build(t, t->frame, stream_name); + if (ret < 0) { + voice_filter_disable(t, stream_name, "filter graph setup failed", ret); + return false; + } + } + + t->frame->pts = t->filter_next_pts; + int ret = av_buffersrc_add_frame_flags(t->filter_buffersrc_ctx, t->frame, + AV_BUFFERSRC_FLAG_KEEP_REF); + if (ret < 0) { + voice_filter_disable(t, stream_name, "frame rejected", ret); + return false; + } + t->filter_next_pts += t->frame->nb_samples; + + ret = voice_filter_drain(t); + if (ret < 0) voice_filter_disable(t, stream_name, "filtering failed", ret); + return true; +} +#else +static void voice_filter_free(audio_transcoder_t *t) { (void)t; } +static int voice_filter_finish(audio_transcoder_t *t) { (void)t; return 0; } + +static bool voice_filter_process(audio_transcoder_t *t, const char *stream_name) +{ + if (t->voice_enhancement_enabled && !t->filter_failed) { + log_warn("Voice enhancement requested for %s but this build has no " + "libavfilter support (ENABLE_AUDIO_FILTERS=OFF); recording audio unfiltered", + stream_name); + t->filter_failed = true; + } + return false; +} +#endif + /** * Initialize audio transcoder for a stream * @@ -300,9 +514,8 @@ static int init_audio_transcoder(const char *stream_name, } // Pick up any voice-enhancement opt-in staged before this slot was - // allocated. The actual filter graph is built lazily in - // transcode_audio_packet() once the decoder frame format is known - // (currently stubbed — discussion #395). + // allocated. The filter graph itself is built lazily in + // transcode_audio_packet() once the decoder frame format is known. audio_transcoders[slot].voice_enhancement_enabled = false; for (int i = 0; i < g_config.max_streams; i++) { if (voice_enhancement_pending[i].stream_name[0] != '\0' && @@ -312,9 +525,13 @@ static int init_audio_transcoder(const char *stream_name, break; } } + audio_transcoders[slot].filter_failed = false; +#ifdef LIGHTNVR_HAVE_AVFILTER audio_transcoders[slot].filter_graph = NULL; audio_transcoders[slot].filter_buffersrc_ctx = NULL; audio_transcoders[slot].filter_buffersink_ctx = NULL; + audio_transcoders[slot].filter_next_pts = 0; +#endif // Mark as initialized audio_transcoders[slot].initialized = 1; @@ -424,10 +641,11 @@ void cleanup_audio_transcoder(const char *stream_name) { audio_transcoders[i].out_pkt = NULL; } + voice_filter_free(&audio_transcoders[i]); + audio_transcoders[i].initialized = 0; audio_transcoders[i].voice_enhancement_enabled = false; - // filter_graph et al. will be freed here once the real filter - // chain lands (discussion #395) — currently always NULL. + audio_transcoders[i].filter_failed = false; audio_transcoder_stream_names[i][0] = '\0'; log_info("Cleaned up audio transcoder for stream %s", stream_name); @@ -456,14 +674,16 @@ void set_audio_voice_enhancement(const char *stream_name, bool enabled) { pthread_mutex_lock(&audio_transcoder_mutex); // If the transcoder slot already exists, flip the flag in place. The - // future filter-graph code reads this on the next packet and rebuilds - // the graph if the state changed. + // transcode path reads it on the next packet: it builds the graph when + // enabled, or drains and frees it when disabled. for (int i = 0; i < g_config.max_streams; i++) { if (audio_transcoders[i].initialized && audio_transcoder_stream_names[i][0] != '\0' && strcmp(audio_transcoder_stream_names[i], stream_name) == 0) { if (audio_transcoders[i].voice_enhancement_enabled != enabled) { audio_transcoders[i].voice_enhancement_enabled = enabled; + // A fresh opt-in gets a fresh attempt at building the graph. + audio_transcoders[i].filter_failed = false; log_info("Audio voice enhancement %s for stream %s (live)", enabled ? "enabled" : "disabled", stream_name); } @@ -745,7 +965,10 @@ static int transcode_audio_packet_locked(const char *stream_name, enum AVSampleFormat enc_sample_fmt = AV_SAMPLE_FMT_S16; - if (audio_transcoders[transcoder_idx].swr_ctx) { + // Voice enhancement, when on, filters the frame straight into the FIFO. + bool filtered = voice_filter_process(&audio_transcoders[transcoder_idx], stream_name); + + if (!filtered && audio_transcoders[transcoder_idx].swr_ctx) { AVFrame *resampled = audio_transcoders[transcoder_idx].resampled_frame; av_frame_unref(resampled); resampled->format = enc_sample_fmt; @@ -830,6 +1053,13 @@ static int transcode_audio_flush_locked(const char *stream_name, AVPacket ***pac } if (!t || !t->faac_enc || t->faac_flushed) return 0; + /* The voice-enhancement filters hold back samples too (afftdn works in + overlapping windows). An error here only costs that tail. */ + int filter_ret = voice_filter_finish(t); + if (filter_ret < 0) { + log_warn("Failed to drain voice-enhancement filter for %s: %d", stream_name, filter_ret); + } + /* swr may retain samples for filter delay even when input and output rates match. Drain those before the FIFO's final partial frame. */ if (t->swr_ctx && t->fifo) { diff --git a/src/video/unified_detection_thread.c b/src/video/unified_detection_thread.c index 0d0f922a..1c78fad1 100644 --- a/src/video/unified_detection_thread.c +++ b/src/video/unified_detection_thread.c @@ -1214,6 +1214,7 @@ int start_unified_detection_thread(const char *stream_name, const char *model_pa ctx->detection_interval = config.detection_interval > 0 ? config.detection_interval : DEFAULT_DETECTION_INTERVAL; load_runtime_engine_config(ctx, model_path, threshold, ctx->detection_interval); ctx->record_audio = config.record_audio && !(global_cfg && global_cfg->audio_disabled); + ctx->audio_voice_enhancement = config.audio_voice_enhancement; ctx->annotation_only = annotation_only; atomic_store(&ctx->external_motion_trigger, 0); // no pending external trigger @@ -2622,6 +2623,9 @@ static int udt_start_recording(unified_detection_ctx_t *ctx) { } else { log_info("[%s] PCM->AAC codec params prepared for MP4 header (eager init)", ctx->stream_name); + // The continuous writer thread stages this every segment; do the + // same here so detection recordings honour the opt-in too. + set_audio_voice_enhancement(ctx->stream_name, ctx->audio_voice_enhancement); } } else if (is_audio_codec_compatible_with_mp4(ain->codecpar->codec_id, &codec_name)) { // Already MP4-compatible (AAC, MP3, AC3, Opus): deep-copy codec params. diff --git a/src/web/api_handlers_streams_modify.c b/src/web/api_handlers_streams_modify.c index 7689cadd..cd410bd7 100644 --- a/src/web/api_handlers_streams_modify.c +++ b/src/web/api_handlers_streams_modify.c @@ -1473,8 +1473,9 @@ void handle_put_stream(const http_request_t *req, http_response_t *res) { } } - // Voice-enhancement is a runtime toggle — the filter graph is rebuilt on the - // next recording session, so flipping it doesn't require a stream restart. + // Voice-enhancement is a runtime toggle — the recording thread picks it up + // at the next segment and the transcoder builds or drains its filter graph + // on the next packet, so flipping it doesn't require a stream restart. // (Discussion #395.) cJSON *audio_voice_enhancement = cJSON_GetObjectItem(stream_json, "audio_voice_enhancement"); if (audio_voice_enhancement && cJSON_IsBool(audio_voice_enhancement)) { diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index 9af0f683..d9a5a577 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -343,6 +343,15 @@ add_layer3_test(test_mp4_faststart) target_compile_definitions(test_mp4_faststart PRIVATE RECORDING_FIXTURE="${CMAKE_SOURCE_DIR}/tests/integration/fixtures/recording.mp4") add_layer3_test(test_audio_voice_enhancement) +if(ENABLE_AUDIO_FILTERS) + # Fault injection for the voice-enhancement graph (issue #652). + add_layer3_test(test_audio_voice_filter_faults) + target_link_options(test_audio_voice_filter_faults PRIVATE + "-Wl,--wrap=avfilter_inout_alloc" "-Wl,--wrap=av_strdup" + "-Wl,--wrap=avfilter_graph_config" "-Wl,--wrap=av_buffersrc_add_frame_flags" + "-Wl,--wrap=av_buffersink_get_frame" "-Wl,--wrap=av_audio_fifo_write" + ) +endif() add_layer3_test(test_timestamp_manager) add_layer3_test(test_api_handlers_system) add_layer3_test(test_api_read_isolation) @@ -372,6 +381,11 @@ target_link_options(test_detection_io_recovery PRIVATE "-Wl,--wrap=av_read_frame" "-Wl,--wrap=av_gettime_relative" ) set_tests_properties(test_detection_io_recovery PROPERTIES TIMEOUT 30) +add_layer3_test(test_udt_recording) +target_compile_definitions(test_udt_recording PRIVATE + TEST_RECORDING_PATH="${CMAKE_SOURCE_DIR}/tests/integration/fixtures/recording.mp4" +) +set_tests_properties(test_udt_recording PROPERTIES TIMEOUT 60) # Health tasks register their tests in independent fragments so parallel work # never needs to modify this shared list. diff --git a/tests/unit/test_audio_transcoder.c b/tests/unit/test_audio_transcoder.c index 09346545..db2ec37a 100644 --- a/tests/unit/test_audio_transcoder.c +++ b/tests/unit/test_audio_transcoder.c @@ -2,6 +2,7 @@ #include #include #include +#include #include #include "unity.h" #include "core/config.h" @@ -19,6 +20,16 @@ static AVFormatContext *readback; static AVCodecContext *decoder; static AVFrame *decoded; +/* Decoded output of a voice-enhancement run (see ve_run below). */ +typedef struct { + float *pcm; + int count; + int capacity; + int64_t coded_duration; +} ve_capture_t; + +static ve_capture_t ve_plain, ve_enhanced; /* freed in tearDown */ + void setUp(void) { g_config.max_streams = MAX_STREAMS; saved_audio_disabled = g_config.audio_disabled; @@ -60,6 +71,10 @@ void tearDown(void) { av_packet_free(&pcm); av_packet_free(&aac); avformat_free_context(input); + free(ve_plain.pcm); + free(ve_enhanced.pcm); + memset(&ve_plain, 0, sizeof(ve_plain)); + memset(&ve_enhanced, 0, sizeof(ve_enhanced)); } static void test_large_pcm_packet_flushes_all_samples(void) { @@ -150,7 +165,7 @@ static void test_unsupported_channel_count_fails_cleanly(void) { TEST_ASSERT_EQUAL_INT(0, aac->size); } -static void test_pcm_formats_drain_without_losing_samples(void) { +static void check_pcm_formats_drain(bool enhance) { const struct { enum AVCodecID codec; int rate, channels, bytes; } cases[] = { { AV_CODEC_ID_PCM_MULAW, 8000, 1, 1 }, { AV_CODEC_ID_PCM_ALAW, 16000, 1, 1 }, @@ -167,6 +182,7 @@ static void test_pcm_formats_drain_without_losing_samples(void) { s->time_base = (AVRational){1, cases[c].rate}; TEST_ASSERT_EQUAL_INT(0, transcode_pcm_to_aac(s->codecpar, &s->time_base, "audio-test", ¶ms)); + set_audio_voice_enhancement("audio-test", enhance); int samples = params->frame_size * 4 + 137; int64_t next_pts = -params->initial_padding; int64_t duration = 0; @@ -206,6 +222,154 @@ static void test_pcm_formats_drain_without_losing_samples(void) { } } +static void test_pcm_formats_drain_without_losing_samples(void) { + check_pcm_formats_drain(false); +} + +/* Every PCM rate/layout builds a working graph (8 kHz skips the lowpass, + * stereo exercises multi-channel) and keeps exact sample accounting. */ +static void test_voice_enhancement_pcm_formats_keep_samples(void) { + check_pcm_formats_drain(true); +} + +/* ---- Voice enhancement (issue #652) --------------------------------------- + * 1 kHz "voice" tone plus an equally loud 50 Hz mains hum, S16LE mono 16 kHz, + * pushed through the real transcoder and decoded back from AAC. */ +#define VE_RATE 16000 +#define VE_SAMPLES (VE_RATE * 3) +#define VE_PACKET 1000 /* divides VE_SAMPLES */ +#define VE_HUM_HZ 50.0 +#define VE_TONE_HZ 1000.0 +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif + + +/* Goertzel power of one frequency bin. */ +static double ve_power(const float *x, int n, double hz) { + double coeff = 2.0 * cos(2.0 * M_PI * hz / VE_RATE); + double s1 = 0.0, s2 = 0.0; + for (int i = 0; i < n; i++) { + double s0 = x[i] + coeff * s1 - s2; + s2 = s1; + s1 = s0; + } + return (s1 * s1 + s2 * s2 - coeff * s1 * s2) / ((double)n * n); +} + +static void ve_decode(AVCodecContext *dec, AVFrame *frame, const AVPacket *pkt, + ve_capture_t *cap) { + cap->coded_duration += pkt->duration; + TEST_ASSERT_EQUAL_INT(0, avcodec_send_packet(dec, pkt)); + while (avcodec_receive_frame(dec, frame) >= 0) { + if (cap->count + frame->nb_samples > cap->capacity) { + cap->capacity = (cap->count + frame->nb_samples) * 2; + cap->pcm = realloc(cap->pcm, (size_t)cap->capacity * sizeof(float)); + TEST_ASSERT_NOT_NULL(cap->pcm); + } + TEST_ASSERT_EQUAL_INT(AV_SAMPLE_FMT_FLTP, frame->format); + memcpy(cap->pcm + cap->count, frame->data[0], + (size_t)frame->nb_samples * sizeof(float)); + cap->count += frame->nb_samples; + av_frame_unref(frame); + } +} + +/* Transcode the test signal with enhancement set to `enhance`; toggle_at >= 0 + * flips it to the opposite state at that sample offset. */ +static void ve_run(bool enhance, int toggle_at, ve_capture_t *cap) { + AVStream *s = input->streams[0]; + s->codecpar->codec_id = AV_CODEC_ID_PCM_S16LE; + s->codecpar->sample_rate = VE_RATE; + s->time_base = (AVRational){1, VE_RATE}; + avcodec_parameters_free(¶ms); + TEST_ASSERT_EQUAL_INT(0, transcode_pcm_to_aac(s->codecpar, &s->time_base, + "audio-test", ¶ms)); + AVCodecContext *dec = avcodec_alloc_context3(avcodec_find_decoder(AV_CODEC_ID_AAC)); + TEST_ASSERT_NOT_NULL(dec); + TEST_ASSERT_EQUAL_INT(0, avcodec_parameters_to_context(dec, params)); + TEST_ASSERT_EQUAL_INT(0, avcodec_open2(dec, dec->codec, NULL)); + AVFrame *frame = av_frame_alloc(); + TEST_ASSERT_NOT_NULL(frame); + + free(cap->pcm); + memset(cap, 0, sizeof(*cap)); + set_audio_voice_enhancement("audio-test", enhance); + for (int pos = 0; pos < VE_SAMPLES; pos += VE_PACKET) { + if (toggle_at >= 0 && pos >= toggle_at) + set_audio_voice_enhancement("audio-test", !enhance); + av_packet_unref(pcm); + TEST_ASSERT_EQUAL_INT(0, av_new_packet(pcm, VE_PACKET * 2)); + int16_t *out = (int16_t *)pcm->data; + for (int i = 0; i < VE_PACKET; i++) { + double t = (double)(pos + i) / VE_RATE; + out[i] = (int16_t)(6000.0 * sin(2.0 * M_PI * VE_TONE_HZ * t) + + 6000.0 * sin(2.0 * M_PI * VE_HUM_HZ * t)); + } + pcm->pts = pcm->dts = pos; + TEST_ASSERT_EQUAL_INT(0, transcode_audio_packet("audio-test", pcm, aac, s)); + while (aac->size > 0) { + ve_decode(dec, frame, aac, cap); + TEST_ASSERT_EQUAL_INT(0, transcode_audio_packet("audio-test", NULL, aac, s)); + } + } + int padding = 0; + TEST_ASSERT_EQUAL_INT(0, transcode_audio_flush("audio-test", &tail, &tail_count, &padding)); + for (int i = 0; i < tail_count; i++) { + ve_decode(dec, frame, tail[i], cap); + av_packet_free(&tail[i]); + } + av_freep(&tail); + tail_count = 0; + cap->coded_duration -= params->initial_padding + padding; + cleanup_audio_transcoder("audio-test"); + av_frame_free(&frame); + avcodec_free_context(&dec); +} + +/* Hum and voice-tone level (dB) over one steady-state second of the capture. */ +static void ve_levels(const ve_capture_t *cap, double *hum_db, double *tone_db) { + const int start = VE_RATE; /* skip filter/encoder warm-up */ + const int n = VE_RATE; /* integer cycles of both tones */ + TEST_ASSERT_GREATER_OR_EQUAL(start + n, cap->count); + *hum_db = 10.0 * log10(ve_power(cap->pcm + start, n, VE_HUM_HZ) + 1e-20); + *tone_db = 10.0 * log10(ve_power(cap->pcm + start, n, VE_TONE_HZ) + 1e-20); +} + +static void test_voice_enhancement_attenuates_hum(void) { + double plain_hum, plain_tone, enh_hum, enh_tone; + char msg[160]; + ve_run(false, -1, &ve_plain); + ve_run(true, -1, &ve_enhanced); + ve_levels(&ve_plain, &plain_hum, &plain_tone); + ve_levels(&ve_enhanced, &enh_hum, &enh_tone); + snprintf(msg, sizeof(msg), "plain hum %.1f dB tone %.1f dB; enhanced hum %.1f dB tone %.1f dB", + plain_hum, plain_tone, enh_hum, enh_tone); + + /* The unfiltered path keeps the hum about as loud as the tone. */ + TEST_ASSERT_TRUE_MESSAGE(fabs(plain_hum - plain_tone) < 3.0, msg); + /* Enhancement must not drop samples. */ + TEST_ASSERT_EQUAL_INT64(VE_SAMPLES, ve_enhanced.coded_duration); +#ifdef LIGHTNVR_HAVE_AVFILTER + /* At least 20 dB less hum... */ + TEST_ASSERT_TRUE_MESSAGE(plain_hum - enh_hum > 20.0, msg); + /* ...without gutting the voice band (no more than 6 dB lost). */ + TEST_ASSERT_TRUE_MESSAGE(plain_tone - enh_tone < 6.0, msg); +#else + /* Without libavfilter the opt-in is a logged no-op; audio still records. */ + TEST_ASSERT_TRUE_MESSAGE(fabs(plain_hum - enh_hum) < 0.5, msg); +#endif +} + +/* Flipping enhancement mid-recording, either way, swaps paths without losing + * samples: turning it off drains what the filter graph still holds. */ +static void test_voice_enhancement_live_toggle_keeps_samples(void) { + ve_run(false, VE_SAMPLES / 2, &ve_plain); + TEST_ASSERT_EQUAL_INT64(VE_SAMPLES, ve_plain.coded_duration); + ve_run(true, VE_SAMPLES / 2, &ve_enhanced); + TEST_ASSERT_EQUAL_INT64(VE_SAMPLES, ve_enhanced.coded_duration); +} + int main(void) { UNITY_BEGIN(); RUN_TEST(test_large_pcm_packet_flushes_all_samples); @@ -213,5 +377,8 @@ int main(void) { RUN_TEST(test_unsupported_channel_count_fails_cleanly); RUN_TEST(test_writer_drains_large_packet_and_preserves_priming); RUN_TEST(test_pcm_formats_drain_without_losing_samples); + RUN_TEST(test_voice_enhancement_pcm_formats_keep_samples); + RUN_TEST(test_voice_enhancement_attenuates_hum); + RUN_TEST(test_voice_enhancement_live_toggle_keeps_samples); return UNITY_END(); } diff --git a/tests/unit/test_audio_voice_enhancement.c b/tests/unit/test_audio_voice_enhancement.c index 61f52d18..40ef010b 100644 --- a/tests/unit/test_audio_voice_enhancement.c +++ b/tests/unit/test_audio_voice_enhancement.c @@ -3,9 +3,10 @@ * @brief Layer 3 Unity tests for the voice-enhancement opt-in staging logic in * src/video/mp4_writer_utils.c (discussion #395). * - * The real filter graph is deferred to a follow-up PR; what's exercised here is - * the lifecycle plumbing that routes a per-stream opt-in to the audio - * transcoder pool: set_audio_voice_enhancement() (live-flip vs. staged), + * The filter graph itself is covered by test_audio_transcoder.c (issue #652); + * what's exercised here is the lifecycle plumbing that routes a per-stream + * opt-in to the audio transcoder pool: set_audio_voice_enhancement() + * (live-flip vs. staged), * get_audio_voice_enhancement() (read-back), and cleanup_audio_transcoder() * dropping any staged entry. No transcoder slot is ever initialized here (that * needs a real PCM frame), so every code path under test takes the "staged" diff --git a/tests/unit/test_audio_voice_filter_faults.c b/tests/unit/test_audio_voice_filter_faults.c new file mode 100644 index 00000000..39662d9d --- /dev/null +++ b/tests/unit/test_audio_voice_filter_faults.c @@ -0,0 +1,270 @@ +/** + * @file test_audio_voice_filter_faults.c + * @brief Fault injection for the voice-enhancement filter graph (issue #652). + * + * Includes the translation unit (as test_detection_current_frame does for the + * UDT) so each test can see the transcoder slot, and wraps the libavfilter / + * FIFO calls it makes so every failure path can be forced. The contract under + * test: a filter that can't be built or run is logged once and the stream + * keeps recording unfiltered, without losing or duplicating samples. + */ + +#include "../../src/video/mp4_writer_utils.c" + +#include + +#include "unity.h" + +#define RATE 16000 +#define PACKET 1000 +#define PACKETS 32 +#define STREAM "fault-test" + +enum fault { + FAULT_NONE, + FAULT_INOUT_ALLOC, /* avfilter_inout_alloc returns NULL */ + FAULT_STRDUP, /* av_strdup returns NULL */ + FAULT_GRAPH_CONFIG, /* avfilter_graph_config fails */ + FAULT_ADD_FRAME, /* buffersrc rejects a frame */ + FAULT_ADD_EOF, /* buffersrc rejects the EOF marker */ + FAULT_SINK, /* buffersink returns a hard error */ + FAULT_FIFO, /* the FIFO write after filtering comes up short */ +}; +static enum fault fault; + +AVFilterInOut *__real_avfilter_inout_alloc(void); +AVFilterInOut *__wrap_avfilter_inout_alloc(void) { + if (fault == FAULT_INOUT_ALLOC) return NULL; + return __real_avfilter_inout_alloc(); +} + +char *__real_av_strdup(const char *s); +char *__wrap_av_strdup(const char *s) { + if (fault == FAULT_STRDUP) return NULL; + return __real_av_strdup(s); +} + +int __real_avfilter_graph_config(AVFilterGraph *graph, void *log_ctx); +int __wrap_avfilter_graph_config(AVFilterGraph *graph, void *log_ctx) { + if (fault == FAULT_GRAPH_CONFIG) return AVERROR(EINVAL); + return __real_avfilter_graph_config(graph, log_ctx); +} + +int __real_av_buffersrc_add_frame_flags(AVFilterContext *ctx, AVFrame *frame, int flags); +int __wrap_av_buffersrc_add_frame_flags(AVFilterContext *ctx, AVFrame *frame, int flags) { + if ((fault == FAULT_ADD_FRAME && frame) || (fault == FAULT_ADD_EOF && !frame)) + return AVERROR(EINVAL); + return __real_av_buffersrc_add_frame_flags(ctx, frame, flags); +} + +int __real_av_buffersink_get_frame(AVFilterContext *ctx, AVFrame *frame); +int __wrap_av_buffersink_get_frame(AVFilterContext *ctx, AVFrame *frame) { + if (fault == FAULT_SINK) return AVERROR(EIO); + return __real_av_buffersink_get_frame(ctx, frame); +} + +int __real_av_audio_fifo_write(AVAudioFifo *af, void *const *data, int nb_samples); +int __wrap_av_audio_fifo_write(AVAudioFifo *af, void *const *data, int nb_samples) { + if (fault == FAULT_FIFO) return 0; + return __real_av_audio_fifo_write(af, data, nb_samples); +} + +static AVFormatContext *input; +static AVCodecParameters *params; +static AVPacket *pcm, *aac; +static int64_t coded; /* AAC duration collected so far */ +static int fed; /* PCM samples pushed */ + +void setUp(void) { + g_config.max_streams = MAX_STREAMS; + fault = FAULT_NONE; + coded = 0; + fed = 0; + input = avformat_alloc_context(); + TEST_ASSERT_NOT_NULL(input); + AVStream *s = avformat_new_stream(input, NULL); + TEST_ASSERT_NOT_NULL(s); + s->time_base = (AVRational){1, RATE}; + s->codecpar->codec_type = AVMEDIA_TYPE_AUDIO; + s->codecpar->codec_id = AV_CODEC_ID_PCM_S16LE; + s->codecpar->sample_rate = RATE; + av_channel_layout_default(&s->codecpar->ch_layout, 1); + params = NULL; + TEST_ASSERT_EQUAL_INT(0, transcode_pcm_to_aac(s->codecpar, &s->time_base, STREAM, ¶ms)); + pcm = av_packet_alloc(); + aac = av_packet_alloc(); + TEST_ASSERT_NOT_NULL(pcm); + TEST_ASSERT_NOT_NULL(aac); + set_audio_voice_enhancement(STREAM, true); +} + +void tearDown(void) { + fault = FAULT_NONE; + cleanup_audio_transcoder(STREAM); + av_packet_free(&pcm); + av_packet_free(&aac); + avcodec_parameters_free(¶ms); + avformat_free_context(input); +} + +static audio_transcoder_t *slot(void) { + for (int i = 0; i < g_config.max_streams; i++) + if (audio_transcoders[i].initialized && + strcmp(audio_transcoder_stream_names[i], STREAM) == 0) + return &audio_transcoders[i]; + return NULL; +} + +/* Push packets of a 300 Hz tone, collecting every AAC packet produced. */ +static void feed(int packets) { + for (int n = 0; n < packets; n++) { + av_packet_unref(pcm); + TEST_ASSERT_EQUAL_INT(0, av_new_packet(pcm, PACKET * 2)); + int16_t *out = (int16_t *)pcm->data; + for (int i = 0; i < PACKET; i++) + out[i] = (int16_t)(4000.0 * sin(2.0 * M_PI * 300.0 * (fed + i) / RATE)); + pcm->pts = pcm->dts = fed; + fed += PACKET; + TEST_ASSERT_EQUAL_INT(0, transcode_audio_packet(STREAM, pcm, aac, input->streams[0])); + while (aac->size > 0) { + coded += aac->duration; + TEST_ASSERT_EQUAL_INT(0, transcode_audio_packet(STREAM, NULL, aac, input->streams[0])); + } + } +} + +/* Flush and return the number of samples the AAC stream represents. */ +static int64_t finish(void) { + AVPacket **tail = NULL; + int count = 0, padding = 0; + TEST_ASSERT_EQUAL_INT(0, transcode_audio_flush(STREAM, &tail, &count, &padding)); + for (int i = 0; i < count; i++) { + coded += tail[i]->duration; + av_packet_free(&tail[i]); + } + av_freep(&tail); + return coded - params->initial_padding - padding; +} + +/* Graph setup failures leave the stream recording unfiltered. */ +static void check_setup_failure(enum fault which) { + fault = which; + feed(1); + audio_transcoder_t *t = slot(); + TEST_ASSERT_NOT_NULL(t); + TEST_ASSERT_TRUE(t->filter_failed); + TEST_ASSERT_NULL(t->filter_graph); + fault = FAULT_NONE; + feed(PACKETS - 1); /* failure is sticky: no rebuild attempts */ + TEST_ASSERT_NULL(t->filter_graph); + TEST_ASSERT_EQUAL_INT64(fed, finish()); +} + +static void test_inout_alloc_failure_records_unfiltered(void) { + check_setup_failure(FAULT_INOUT_ALLOC); +} + +static void test_name_alloc_failure_records_unfiltered(void) { + check_setup_failure(FAULT_STRDUP); +} + +static void test_graph_config_failure_records_unfiltered(void) { + check_setup_failure(FAULT_GRAPH_CONFIG); +} + +/* A fresh opt-in after a failure retries the build. */ +static void test_reenabling_retries_after_failure(void) { + fault = FAULT_GRAPH_CONFIG; + feed(2); + TEST_ASSERT_TRUE(slot()->filter_failed); + fault = FAULT_NONE; + set_audio_voice_enhancement(STREAM, false); + set_audio_voice_enhancement(STREAM, true); + TEST_ASSERT_FALSE(slot()->filter_failed); + feed(PACKETS - 2); + TEST_ASSERT_NOT_NULL(slot()->filter_graph); + TEST_ASSERT_EQUAL_INT64(fed, finish()); +} + +/* A rejected frame falls back to the plain path for that same frame, so no + * samples are lost; the samples already inside the graph are the only cost. */ +static void test_rejected_frame_falls_back_without_loss(void) { + feed(4); + TEST_ASSERT_NOT_NULL(slot()->filter_graph); + fault = FAULT_ADD_FRAME; + feed(1); + fault = FAULT_NONE; + TEST_ASSERT_TRUE(slot()->filter_failed); + TEST_ASSERT_NULL(slot()->filter_graph); + feed(PACKETS - 5); + int64_t held = fed - finish(); + TEST_ASSERT_GREATER_OR_EQUAL_INT64(0, held); + TEST_ASSERT_LESS_THAN_INT64(PACKET * 4, held); +} + +/* Errors while pulling filtered audio disable the filter but keep going. */ +static void check_drain_failure(enum fault which) { + feed(2); + TEST_ASSERT_NOT_NULL(slot()->filter_graph); + fault = which; + feed(1); + fault = FAULT_NONE; + TEST_ASSERT_TRUE(slot()->filter_failed); + TEST_ASSERT_NULL(slot()->filter_graph); + int64_t before = fed; + feed(PACKETS - 3); + int64_t samples = finish(); + /* Everything after the failure is recorded; only that frame can be lost. */ + TEST_ASSERT_GREATER_OR_EQUAL_INT64(before - 3 * PACKET, samples - (fed - before)); +} + +static void test_sink_error_disables_filter(void) { + check_drain_failure(FAULT_SINK); +} + +static void test_fifo_shortfall_disables_filter(void) { + check_drain_failure(FAULT_FIFO); +} + +/* Turning enhancement off drains the graph; if that drain fails the filter + * is torn down anyway and recording continues. */ +static void test_disable_with_failed_drain(void) { + feed(3); + TEST_ASSERT_NOT_NULL(slot()->filter_graph); + set_audio_voice_enhancement(STREAM, false); + fault = FAULT_ADD_EOF; + feed(1); + fault = FAULT_NONE; + TEST_ASSERT_NULL(slot()->filter_graph); + TEST_ASSERT_TRUE(slot()->filter_failed); + feed(PACKETS - 4); + TEST_ASSERT_GREATER_THAN_INT64(fed - 3 * PACKET, finish()); +} + +/* A failed drain at final flush only costs the filter's tail. */ +static void test_flush_with_failed_drain_still_completes(void) { + feed(PACKETS); + TEST_ASSERT_NOT_NULL(slot()->filter_graph); + fault = FAULT_ADD_EOF; + int64_t samples = finish(); + TEST_ASSERT_NULL(slot()->filter_graph); + TEST_ASSERT_TRUE(slot()->faac_flushed); + TEST_ASSERT_GREATER_THAN_INT64(fed - PACKET, samples); + TEST_ASSERT_LESS_OR_EQUAL_INT64(fed, samples); +} + +int main(void) { + init_logger(); + set_log_level(LOG_LEVEL_ERROR); + UNITY_BEGIN(); + RUN_TEST(test_inout_alloc_failure_records_unfiltered); + RUN_TEST(test_name_alloc_failure_records_unfiltered); + RUN_TEST(test_graph_config_failure_records_unfiltered); + RUN_TEST(test_reenabling_retries_after_failure); + RUN_TEST(test_rejected_frame_falls_back_without_loss); + RUN_TEST(test_sink_error_disables_filter); + RUN_TEST(test_fifo_shortfall_disables_filter); + RUN_TEST(test_disable_with_failed_drain); + RUN_TEST(test_flush_with_failed_drain_still_completes); + return UNITY_END(); +} diff --git a/tests/unit/test_udt_recording.c b/tests/unit/test_udt_recording.c new file mode 100644 index 00000000..090689b3 --- /dev/null +++ b/tests/unit/test_udt_recording.c @@ -0,0 +1,758 @@ +/** + * @file test_udt_recording.c + * @brief Layer 3 tests for the unified detection thread's recording path. + * + * Like test_detection_current_frame, this includes the translation unit so the + * private packet/state-machine functions run unmodified. Real packets from the + * recording fixture (plus synthetic G.711 audio) go through process_packet() + * against a temporary catalog with a healthy default storage target, so + * recordings are actually placed, written, and catalogued. + * + * Covered: detection- and trigger-driven BUFFERING → RECORDING → POST_BUFFER → + * BUFFERING transitions and keep-alives, the detection-stream hand-off, the + * pre-buffer flush and start-time correction, per-codec audio setup when a + * recording starts (including the voice-enhancement opt-in, issue #652), + * blocked placement, the stop fallback, and the public status/registry API. + */ + +#define _GNU_SOURCE +#include "../../src/video/unified_detection_thread.c" + +#include + +#include "unity.h" +#include "database/db_core.h" +#include "database/db_storage_targets.h" +#include "database/db_detection_engines.h" +#include "video/stream_manager.h" + +#define STREAM "udt-rec" +#define LIVE_STREAM "udt-live" /* points at the fixture file */ +#define ENGINE_STREAM "udt-engines" /* carries custom detection engines */ +#define AUDIO_RATE 8000 +#define AUDIO_PACKET 800 /* 100 ms of mono G.711 per video packet */ + +static char root[] = "/tmp/lightnvr-udt-rec-XXXXXX"; +static char db_path[PATH_MAX]; +static AVFormatContext *source; /* recording fixture: 600 x 1 s H.264, GOP 200 */ +static AVPacket **video_packets; +static int video_count; +static int64_t source_duration; /* fixture length in the video time base */ + +static unified_detection_ctx_t *ctx; +static AVFormatContext *input; /* what the UDT believes it is reading */ +static int64_t cursor; /* monotonically increasing packet index */ +static int64_t audio_pts; + +/* ---- helpers ------------------------------------------------------------ */ + +static void sql(const char *statement) { + char *error = NULL; + int rc = sqlite3_exec(get_db_handle(), statement, NULL, NULL, &error); + TEST_ASSERT_EQUAL_INT_MESSAGE(SQLITE_OK, rc, error ? error : statement); +} + +static int64_t scalar(const char *query, uint64_t id) { + sqlite3_stmt *stmt = NULL; + TEST_ASSERT_EQUAL_INT(SQLITE_OK, sqlite3_prepare_v2(get_db_handle(), query, -1, &stmt, NULL)); + if (sqlite3_bind_parameter_count(stmt) > 0) sqlite3_bind_int64(stmt, 1, (sqlite3_int64)id); + int64_t value = sqlite3_step(stmt) == SQLITE_ROW ? sqlite3_column_int64(stmt, 0) : -1; + sqlite3_finalize(stmt); + return value; +} + +static void build_input_channels(enum AVCodecID audio_codec, int channels) { + input = avformat_alloc_context(); + TEST_ASSERT_NOT_NULL(input); + AVStream *video = avformat_new_stream(input, NULL); + TEST_ASSERT_NOT_NULL(video); + TEST_ASSERT_EQUAL_INT(0, avcodec_parameters_copy(video->codecpar, source->streams[0]->codecpar)); + video->time_base = source->streams[0]->time_base; + ctx->video_stream_idx = 0; + ctx->audio_stream_idx = -1; + if (audio_codec != AV_CODEC_ID_NONE) { + AVStream *audio = avformat_new_stream(input, NULL); + TEST_ASSERT_NOT_NULL(audio); + audio->codecpar->codec_type = AVMEDIA_TYPE_AUDIO; + audio->codecpar->codec_id = audio_codec; + audio->codecpar->sample_rate = AUDIO_RATE; + av_channel_layout_default(&audio->codecpar->ch_layout, channels); + audio->time_base = (AVRational){1, AUDIO_RATE}; + ctx->audio_stream_idx = audio->index; + } + ctx->input_ctx = input; +} + +static void build_input(enum AVCodecID audio_codec) { + build_input_channels(audio_codec, 1); +} + +/* Feed the next fixture packet (and a G.711 packet when the input has audio). */ +static void feed_one(void) { + const AVPacket *src = video_packets[cursor % video_count]; + AVPacket *pkt = av_packet_clone(src); + TEST_ASSERT_NOT_NULL(pkt); + int64_t offset = (cursor / video_count) * source_duration; + pkt->pts += offset; + pkt->dts += offset; + pkt->stream_index = 0; + TEST_ASSERT_EQUAL_INT(0, process_packet(ctx, pkt)); + av_packet_free(&pkt); + cursor++; + + if (ctx->audio_stream_idx >= 0 && + input->streams[ctx->audio_stream_idx]->codecpar->codec_id == AV_CODEC_ID_PCM_MULAW) { + AVPacket *audio = av_packet_alloc(); + TEST_ASSERT_NOT_NULL(audio); + TEST_ASSERT_EQUAL_INT(0, av_new_packet(audio, AUDIO_PACKET)); + for (int i = 0; i < AUDIO_PACKET; i++) audio->data[i] = (uint8_t)(0x80 + (i & 0x3f)); + audio->pts = audio->dts = audio_pts; + audio->duration = AUDIO_PACKET; + audio->flags = AV_PKT_FLAG_KEY; + audio->stream_index = ctx->audio_stream_idx; + audio_pts += AUDIO_PACKET; + TEST_ASSERT_EQUAL_INT(0, process_packet(ctx, audio)); + av_packet_free(&audio); + } +} + +static void feed(int count) { + for (int i = 0; i < count; i++) feed_one(); +} + +/* Feed up to and including the next video keyframe. */ +static void feed_keyframe(void) { + while (!(video_packets[cursor % video_count]->flags & AV_PKT_FLAG_KEY)) feed_one(); + feed_one(); +} + +static unified_detection_state_t state(void) { + return (unified_detection_state_t)atomic_load(&ctx->state); +} + +static void assert_complete_recording(uint64_t id, int expected_streams) { + TEST_ASSERT_EQUAL_INT64(1, scalar("SELECT is_complete FROM recordings WHERE id=?", id)); + TEST_ASSERT_GREATER_THAN_INT64(0, scalar("SELECT size_bytes FROM recordings WHERE id=?", id)); + TEST_ASSERT_EQUAL_INT64(RETENTION_TIER_IMPORTANT, + scalar("SELECT retention_tier FROM recordings WHERE id=?", id)); + + sqlite3_stmt *stmt = NULL; + TEST_ASSERT_EQUAL_INT(SQLITE_OK, sqlite3_prepare_v2(get_db_handle(), + "SELECT file_path, trigger_type FROM recordings WHERE id=?", -1, &stmt, NULL)); + sqlite3_bind_int64(stmt, 1, (sqlite3_int64)id); + TEST_ASSERT_EQUAL_INT(SQLITE_ROW, sqlite3_step(stmt)); + char path[PATH_MAX]; + safe_strcpy(path, (const char *)sqlite3_column_text(stmt, 0), sizeof(path), 0); + TEST_ASSERT_EQUAL_STRING("detection", (const char *)sqlite3_column_text(stmt, 1)); + sqlite3_finalize(stmt); + + TEST_ASSERT_EQUAL_INT(0, strncmp(path, root, strlen(root))); + AVFormatContext *clip = NULL; + TEST_ASSERT_EQUAL_INT(0, avformat_open_input(&clip, path, NULL, NULL)); + TEST_ASSERT_GREATER_OR_EQUAL_INT(0, avformat_find_stream_info(clip, NULL)); + TEST_ASSERT_EQUAL_UINT(expected_streams, clip->nb_streams); + TEST_ASSERT_EQUAL_INT(AV_CODEC_ID_H264, clip->streams[0]->codecpar->codec_id); + if (expected_streams > 1) + TEST_ASSERT_EQUAL_INT(AV_CODEC_ID_AAC, clip->streams[1]->codecpar->codec_id); + avformat_close_input(&clip); +} + +/* ---- fixtures ----------------------------------------------------------- */ + +void setUp(void) { + ctx = calloc(1, sizeof(*ctx)); + TEST_ASSERT_NOT_NULL(ctx); + safe_strcpy(ctx->stream_name, STREAM, sizeof(ctx->stream_name), 0); + safe_strcpy(ctx->camera_uuid, "00000000-0000-4000-8000-0000000000a1", + sizeof(ctx->camera_uuid), 0); + safe_strcpy(ctx->model_path, "onvif", sizeof(ctx->model_path), 0); + ctx->detection_interval = 1; + ctx->pre_buffer_seconds = 10; + ctx->post_buffer_seconds = 30; + ctx->segment_duration = 900; + /* Generous enough that a wall-clock second ticking over mid-test never + * ends a clip; tests that want post-buffer backdate last_detection_time. */ + ctx->recording_grace_interval = 5; + ctx->packet_buffer = create_packet_buffer(STREAM, ctx->pre_buffer_seconds, BUFFER_MODE_MEMORY); + TEST_ASSERT_NOT_NULL(ctx->packet_buffer); + pthread_mutex_init(&ctx->mutex, NULL); + pthread_mutex_init(&ctx->detection_stream_result_mutex, NULL); + atomic_store(&ctx->state, UDT_STATE_BUFFERING); + atomic_store(&ctx->running, 1); + ctx->stream_is_live = true; + ctx->first_video_pts = AV_NOPTS_VALUE; + ctx->record_audio = true; + g_config.detection_grace_period = 0; + cursor = 0; + audio_pts = 0; + input = NULL; +} + +void tearDown(void) { + if (ctx->mp4_writer) udt_stop_recording(ctx); + for (int i = 0; i < MAX_UNIFIED_DETECTION_THREADS; i++) + if (detection_contexts[i] == ctx) detection_contexts[i] = NULL; + cleanup_audio_transcoder(STREAM); + destroy_packet_buffer(ctx->packet_buffer); + pthread_mutex_destroy(&ctx->mutex); + pthread_mutex_destroy(&ctx->detection_stream_result_mutex); + free(ctx); + avformat_free_context(input); + sql("UPDATE storage_targets SET enabled=1;"); +} + +/* ---- tests -------------------------------------------------------------- */ + +/* A detection on a keyframe starts a clip from the pre-buffer, transcodes the + * G.711 track with the stream's voice-enhancement opt-in applied, and the clip + * is finalised in the catalog once detection stops and the post-buffer ends. */ +static void test_detection_records_pcm_audio_with_voice_enhancement(void) { + build_input(AV_CODEC_ID_PCM_MULAW); + ctx->audio_voice_enhancement = true; + + /* Seed the pre-buffer with the opening keyframe stamped 5 s ago so the + * clip's start time gets pulled back by the pre-roll it contains. */ + TEST_ASSERT_EQUAL_INT(0, packet_buffer_add_packet(ctx->packet_buffer, video_packets[0], + time(NULL) - 5)); + cursor = 1; + feed(150); /* fill the pre-buffer, no motion */ + TEST_ASSERT_EQUAL_INT(UDT_STATE_BUFFERING, state()); + TEST_ASSERT_FALSE(get_audio_voice_enhancement(STREAM)); + + atomic_store(&ctx->onvif_motion_detected, 1); + atomic_store(&ctx->last_detection_check_time, 0); + feed_keyframe(); /* packet 200 */ + TEST_ASSERT_EQUAL_INT(UDT_STATE_RECORDING, state()); + uint64_t id = atomic_load(&ctx->current_recording_id); + TEST_ASSERT_NOT_EQUAL_UINT64(0, id); + TEST_ASSERT_NOT_NULL(ctx->mp4_writer); + TEST_ASSERT_TRUE(ctx->mp4_writer->has_audio); + TEST_ASSERT_TRUE(ctx->mp4_writer->is_initialized); + TEST_ASSERT_TRUE(ctx->mp4_writer->start_time_corrected); + TEST_ASSERT_TRUE(get_audio_voice_enhancement(STREAM)); + TEST_ASSERT_EQUAL_INT(1, (int)ctx->total_detections); + TEST_ASSERT_EQUAL_INT(1, (int)ctx->total_recordings); + + feed(50); + atomic_store(&ctx->onvif_motion_detected, 0); + atomic_store(&ctx->last_detection_check_time, 0); + atomic_store(&ctx->last_detection_time, (long long)time(NULL) - 60); + ctx->post_buffer_seconds = 0; + /* Packet 400 finds no detection and enters a zero-length post-buffer; the + * audio packet that follows it already sees it expired and stops the clip. */ + feed_keyframe(); + TEST_ASSERT_EQUAL_INT(UDT_STATE_BUFFERING, state()); + TEST_ASSERT_EQUAL_UINT64(0, atomic_load(&ctx->current_recording_id)); + TEST_ASSERT_NULL(ctx->mp4_writer); + assert_complete_recording(id, 2); +} + +/* Motion forwarded from another stream drives the same state machine: start, + * keep-alive while recording, end into post-buffer, revive, end, expire. */ +static void test_external_trigger_cycle(void) { + build_input(AV_CODEC_ID_NONE); /* record_audio but no track */ + + feed(10); + atomic_store(&ctx->external_motion_trigger, 2); /* ended while idle: no-op */ + feed_keyframe(); + TEST_ASSERT_EQUAL_INT(UDT_STATE_BUFFERING, state()); + + atomic_store(&ctx->external_motion_trigger, 1); + feed_keyframe(); + TEST_ASSERT_EQUAL_INT(UDT_STATE_RECORDING, state()); + uint64_t id = atomic_load(&ctx->current_recording_id); + TEST_ASSERT_NOT_EQUAL_UINT64(0, id); + TEST_ASSERT_FALSE(ctx->mp4_writer->has_audio); + TEST_ASSERT_EQUAL_INT(0, atomic_load(&ctx->external_motion_trigger)); + + atomic_store(&ctx->external_motion_trigger, 1); /* keep-alive */ + feed_keyframe(); + TEST_ASSERT_EQUAL_INT(UDT_STATE_RECORDING, state()); + + atomic_store(&ctx->external_motion_trigger, 2); + feed_keyframe(); + TEST_ASSERT_EQUAL_INT(UDT_STATE_POST_BUFFER, state()); + + atomic_store(&ctx->external_motion_trigger, 1); /* revive from post-buffer */ + feed_keyframe(); + TEST_ASSERT_EQUAL_INT(UDT_STATE_RECORDING, state()); + TEST_ASSERT_EQUAL_UINT64(id, atomic_load(&ctx->current_recording_id)); + + atomic_store(&ctx->external_motion_trigger, 2); + feed_keyframe(); + TEST_ASSERT_EQUAL_INT(UDT_STATE_POST_BUFFER, state()); + atomic_store(&ctx->post_buffer_end_time, 0); + feed(1); + TEST_ASSERT_EQUAL_INT(UDT_STATE_BUFFERING, state()); + assert_complete_recording(id, 1); +} + +static void hand_off_detection(void) { + pthread_mutex_lock(&ctx->detection_stream_result_mutex); + memset(&ctx->detection_stream_pending, 0, sizeof(ctx->detection_stream_pending)); + ctx->detection_stream_pending.count = 1; + ctx->detection_stream_pending.detections[0].confidence = 0.9f; + safe_strcpy(ctx->detection_stream_pending.detections[0].label, "person", + sizeof(ctx->detection_stream_pending.detections[0].label), 0); + ctx->detection_stream_pending_ts = time(NULL); + pthread_mutex_unlock(&ctx->detection_stream_result_mutex); + atomic_store(&ctx->detection_stream_result, 1); +} + +/* Results from the secondary detection stream are consumed on any packet, + * stored, and start/extend the clip; the ONVIF onset backdates the event. */ +static void test_detection_stream_hand_off(void) { + build_input(AV_CODEC_ID_NONE); + ctx->record_audio = false; + ctx->has_onvif_engine = true; + atomic_store(&ctx->onvif_motion_onset, (long long)time(NULL) - 1); + atomic_store(&ctx->detection_stream_connected, 1); + + feed(20); + TEST_ASSERT_EQUAL_INT(UDT_STATE_BUFFERING, state()); + hand_off_detection(); + feed(1); + TEST_ASSERT_EQUAL_INT(UDT_STATE_RECORDING, state()); + TEST_ASSERT_EQUAL_INT(1, (int)ctx->total_detections); + TEST_ASSERT_EQUAL_INT(0, atomic_load(&ctx->detection_stream_result)); + TEST_ASSERT_EQUAL_INT64(1, scalar("SELECT count(*) FROM detections WHERE stream_name='" STREAM "'", 0)); + + atomic_store(&ctx->last_detection_time, (long long)time(NULL) - 60); + feed(1); /* no result: grace exceeded */ + TEST_ASSERT_EQUAL_INT(UDT_STATE_POST_BUFFER, state()); + hand_off_detection(); + feed(1); /* keep-alive from post-buffer */ + TEST_ASSERT_EQUAL_INT(UDT_STATE_RECORDING, state()); + TEST_ASSERT_EQUAL_INT(2, (int)ctx->total_detections); + feed_keyframe(); /* writer is live past the flush */ + uint64_t id = atomic_load(&ctx->current_recording_id); + TEST_ASSERT_EQUAL_INT(0, udt_stop_recording(ctx)); + assert_complete_recording(id, 1); +} + +/* ONVIF motion first seen between sampled checks starts recording on the very + * next keyframe instead of waiting out detection_interval (#494). */ +static void test_onvif_onset_fast_path(void) { + build_input(AV_CODEC_ID_NONE); + ctx->detection_interval = 3600; + feed(5); + atomic_store(&ctx->last_detection_check_time, (long long)time(NULL)); + atomic_store(&ctx->onvif_motion_detected, 1); + atomic_store(&ctx->onvif_motion_onset, (long long)time(NULL)); + feed_keyframe(); + TEST_ASSERT_EQUAL_INT(UDT_STATE_RECORDING, state()); + TEST_ASSERT_EQUAL_INT64(atomic_load(&ctx->onvif_motion_onset), ctx->onvif_onset_handled); +} + +/* Audio setup when a clip starts: PCM is prepared for AAC transcoding, + * MP4-compatible tracks are copied, and a PCM layout the encoder can't take + * or record_audio=off leaves the clip video-only. */ +static void test_start_recording_audio_variants(void) { + const struct { + enum AVCodecID codec; int channels; bool record; bool has_audio; bool pending; + } cases[] = { + { AV_CODEC_ID_PCM_ALAW, 1, true, true, true }, + { AV_CODEC_ID_AAC, 1, true, true, true }, + { AV_CODEC_ID_PCM_MULAW, 9, true, false, false }, + { AV_CODEC_ID_PCM_ALAW, 1, false, false, false }, + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + build_input_channels(cases[i].codec, cases[i].channels); + ctx->record_audio = cases[i].record; + TEST_ASSERT_EQUAL_INT(0, udt_start_recording(ctx)); + TEST_ASSERT_NOT_NULL(ctx->mp4_writer); + TEST_ASSERT_EQUAL_INT(cases[i].has_audio, ctx->mp4_writer->has_audio != 0); + TEST_ASSERT_EQUAL_INT(cases[i].pending, ctx->mp4_writer->pending_audio_codecpar != NULL); + TEST_ASSERT_EQUAL_INT(1, ctx->mp4_writer->schedule_restricted); + TEST_ASSERT_EQUAL_INT(0, udt_stop_recording(ctx)); + avformat_free_context(input); + input = NULL; + } +} + +/* A paused/unavailable storage target blocks the clip without side effects. */ +static void test_blocked_placement_does_not_start(void) { + build_input(AV_CODEC_ID_NONE); + sql("UPDATE storage_targets SET enabled=0;"); + TEST_ASSERT_EQUAL_INT(-1, udt_start_recording(ctx)); + TEST_ASSERT_NULL(ctx->mp4_writer); + TEST_ASSERT_EQUAL_UINT64(0, atomic_load(&ctx->current_recording_id)); + + feed(5); + atomic_store(&ctx->external_motion_trigger, 1); + feed_keyframe(); + TEST_ASSERT_EQUAL_INT(UDT_STATE_BUFFERING, state()); +} + +/* Stopping without a catalog id still records the clip; stopping with no + * writer is a no-op. */ +static void test_stop_fallback_and_noop(void) { + build_input(AV_CODEC_ID_NONE); + TEST_ASSERT_EQUAL_INT(0, udt_stop_recording(ctx)); + TEST_ASSERT_EQUAL_INT(-1, udt_stop_recording(NULL)); + + TEST_ASSERT_EQUAL_INT(0, udt_start_recording(ctx)); + uint64_t original = atomic_load(&ctx->current_recording_id); + atomic_store(&ctx->current_recording_id, 0); + int64_t before = scalar("SELECT count(*) FROM recordings", 0); + TEST_ASSERT_EQUAL_INT(0, udt_stop_recording(ctx)); + TEST_ASSERT_EQUAL_INT64(before + 1, scalar("SELECT count(*) FROM recordings", 0)); + TEST_ASSERT_EQUAL_INT64(0, scalar("SELECT is_complete FROM recordings WHERE id=?", original)); + TEST_ASSERT_EQUAL_STRING("", ctx->current_recording_path); +} + +/* The public status API reads the registered context. */ +static void test_registry_status_api(void) { + TEST_ASSERT_FALSE(is_unified_detection_running(STREAM)); + TEST_ASSERT_EQUAL_INT(UDT_STATE_STOPPED, get_unified_detection_state(STREAM)); + TEST_ASSERT_EQUAL_INT(STREAM_STATUS_STOPPED, get_unified_detection_effective_status(STREAM)); + TEST_ASSERT_EQUAL_INT(-1, get_unified_detection_stats(STREAM, NULL, NULL, NULL)); + TEST_ASSERT_EQUAL_INT(-1, stop_unified_detection_thread(STREAM)); + TEST_ASSERT_FALSE(is_unified_detection_running(NULL)); + TEST_ASSERT_EQUAL_INT(UDT_STATE_STOPPED, get_unified_detection_state(NULL)); + TEST_ASSERT_EQUAL_INT(STREAM_STATUS_STOPPED, get_unified_detection_effective_status(NULL)); + TEST_ASSERT_EQUAL_INT(0, get_unified_detection_reconnect_attempts(NULL)); + TEST_ASSERT_EQUAL_UINT64(0, get_unified_detection_recording_id(NULL)); + TEST_ASSERT_EQUAL_INT(-1, get_unified_detection_stats(NULL, NULL, NULL, NULL)); + TEST_ASSERT_EQUAL_INT(-1, stop_unified_detection_thread(NULL)); + unified_detection_notify_motion(NULL, true); + unified_detection_notify_motion(STREAM, true); /* unknown: silent no-op */ + + int slot = find_empty_slot(); + TEST_ASSERT_GREATER_OR_EQUAL_INT(0, slot); + detection_contexts[slot] = ctx; + TEST_ASSERT_EQUAL_PTR(ctx, find_context_by_name(STREAM)); + TEST_ASSERT_TRUE(is_unified_detection_running(STREAM)); + ctx->reconnect_attempt = 3; + atomic_store(&ctx->current_recording_id, 77); + ctx->total_packets_processed = 5; + ctx->total_detections = 2; + ctx->total_recordings = 1; + TEST_ASSERT_EQUAL_INT(3, get_unified_detection_reconnect_attempts(STREAM)); + TEST_ASSERT_EQUAL_UINT64(77, get_unified_detection_recording_id(STREAM)); + uint64_t packets = 0, detections = 0, recordings = 0; + TEST_ASSERT_EQUAL_INT(0, get_unified_detection_stats(STREAM, &packets, &detections, &recordings)); + TEST_ASSERT_EQUAL_UINT64(5, packets); + TEST_ASSERT_EQUAL_UINT64(2, detections); + TEST_ASSERT_EQUAL_UINT64(1, recordings); + atomic_store(&ctx->current_recording_id, 0); + + const struct { unified_detection_state_t state; int attempts; stream_status_t status; const char *name; } map[] = { + { UDT_STATE_INITIALIZING, 0, STREAM_STATUS_STARTING, "INITIALIZING" }, + { UDT_STATE_CONNECTING, 0, STREAM_STATUS_STARTING, "CONNECTING" }, + { UDT_STATE_CONNECTING, 2, STREAM_STATUS_RECONNECTING, "CONNECTING" }, + { UDT_STATE_BUFFERING, 0, STREAM_STATUS_RUNNING, "BUFFERING" }, + { UDT_STATE_RECORDING, 0, STREAM_STATUS_RUNNING, "RECORDING" }, + { UDT_STATE_POST_BUFFER, 0, STREAM_STATUS_RUNNING, "POST_BUFFER" }, + { UDT_STATE_RECONNECTING, 0, STREAM_STATUS_RECONNECTING, "RECONNECTING" }, + { UDT_STATE_STOPPING, 0, STREAM_STATUS_STOPPING, "STOPPING" }, + { UDT_STATE_STOPPED, 0, STREAM_STATUS_STOPPED, "STOPPED" }, + }; + for (size_t i = 0; i < sizeof(map) / sizeof(map[0]); i++) { + atomic_store(&ctx->state, map[i].state); + ctx->reconnect_attempt = map[i].attempts; + TEST_ASSERT_EQUAL_INT(map[i].state, get_unified_detection_state(STREAM)); + TEST_ASSERT_EQUAL_INT(map[i].status, get_unified_detection_effective_status(STREAM)); + TEST_ASSERT_EQUAL_STRING(map[i].name, state_to_string(map[i].state)); + } + TEST_ASSERT_EQUAL_STRING("UNKNOWN", state_to_string((unified_detection_state_t)99)); + + unified_detection_notify_motion(STREAM, true); + TEST_ASSERT_EQUAL_INT(1, atomic_load(&ctx->external_motion_trigger)); + unified_detection_notify_motion(STREAM, false); + TEST_ASSERT_EQUAL_INT(2, atomic_load(&ctx->external_motion_trigger)); + + atomic_store(&ctx->state, UDT_STATE_BUFFERING); + TEST_ASSERT_EQUAL_INT(0, stop_unified_detection_thread(STREAM)); + TEST_ASSERT_EQUAL_INT(UDT_STATE_STOPPING, get_unified_detection_state(STREAM)); + TEST_ASSERT_FALSE(is_unified_detection_running(STREAM)); + TEST_ASSERT_EQUAL_INT(0, init_unified_detection_system()); /* already initialised */ +} + +/* Shutdown frees every registered context, closing a writer the thread left + * open; a stopped thread is not waited on. */ +static void test_shutdown_cleans_up_stopped_contexts(void) { + unified_detection_ctx_t *orphan = calloc(1, sizeof(*orphan)); + TEST_ASSERT_NOT_NULL(orphan); + safe_strcpy(orphan->stream_name, "udt-orphan", sizeof(orphan->stream_name), 0); + orphan->packet_buffer = create_packet_buffer("udt-orphan", 5, BUFFER_MODE_MEMORY); + TEST_ASSERT_NOT_NULL(orphan->packet_buffer); + char path[PATH_MAX]; + snprintf(path, sizeof(path), "%s/orphan.mp4", root); + orphan->mp4_writer = mp4_writer_create(path, "udt-orphan"); + TEST_ASSERT_NOT_NULL(orphan->mp4_writer); + pthread_mutex_init(&orphan->mutex, NULL); + pthread_mutex_init(&orphan->detection_stream_result_mutex, NULL); + atomic_store(&orphan->state, UDT_STATE_STOPPED); + detection_contexts[find_empty_slot()] = orphan; + + shutdown_unified_detection_system(); + TEST_ASSERT_FALSE(system_initialized); + for (int i = 0; i < MAX_UNIFIED_DETECTION_THREADS; i++) TEST_ASSERT_NULL(detection_contexts[i]); + shutdown_unified_detection_system(); /* second call is a no-op */ + TEST_ASSERT_EQUAL_INT(0, init_unified_detection_system()); +} + +static void add_engine(const char *key, const char *type, const char *model, + float threshold, int interval, bool enabled) { + stream_detection_engine_t engine = {0}; + safe_strcpy(engine.engine_key, key, sizeof(engine.engine_key), 0); + safe_strcpy(engine.engine_type, type, sizeof(engine.engine_type), 0); + safe_strcpy(engine.model_path, model, sizeof(engine.model_path), 0); + engine.enabled = enabled; + engine.threshold = threshold; + engine.interval_seconds = interval; + TEST_ASSERT_EQUAL_INT(0, db_detection_engine_upsert(ENGINE_STREAM, &engine)); +} + +static const stream_detection_engine_t *engine_named(const char *key) { + for (int i = 0; i < ctx->engine_count; i++) + if (strcmp(ctx->engines[i].engine_key, key) == 0) return &ctx->engines[i]; + return NULL; +} + +/* Stored engines replace the legacy single model: disabled rows are skipped, + * relative object models resolve under models_path, motion/ONVIF get their + * implicit model path, and the shortest/longest intervals drive sampling and + * the recording grace window. Without rows, the legacy model is classified. */ +static void test_runtime_engine_config(void) { + safe_strcpy(ctx->stream_name, ENGINE_STREAM, sizeof(ctx->stream_name), 0); + safe_strcpy(g_config.models_path, "/opt/models", sizeof(g_config.models_path), 0); + add_engine("motion-a", "motion", "", 0.3f, 4, true); + add_engine("people", "object", "people.tflite", 0.6f, 9, true); + add_engine("doorbell", "onvif", "", 0.5f, 2, true); + add_engine("retired", "motion", "", 0.3f, 1, false); + + load_runtime_engine_config(ctx, "/opt/models/legacy.tflite", 0.5f, 30); + TEST_ASSERT_NOT_NULL(engine_named("motion-a")); + TEST_ASSERT_NULL(engine_named("retired")); + TEST_ASSERT_EQUAL_STRING("motion", engine_named("motion-a")->model_path); + TEST_ASSERT_EQUAL_STRING("onvif", engine_named("doorbell")->model_path); + TEST_ASSERT_EQUAL_STRING("/opt/models/people.tflite", engine_named("people")->model_path); + const stream_detection_engine_t *legacy = engine_named("legacy-primary"); + if (legacy) TEST_ASSERT_EQUAL_STRING("/opt/models/legacy.tflite", legacy->model_path); + TEST_ASSERT_EQUAL_INT(2, ctx->detection_interval); + TEST_ASSERT_GREATER_OR_EQUAL_INT(9, ctx->recording_grace_interval); + TEST_ASSERT_TRUE(ctx->has_onvif_engine); + TEST_ASSERT_TRUE(runtime_has_engine_type(ctx, "object")); + TEST_ASSERT_FALSE(runtime_has_engine_type(ctx, "api")); + TEST_ASSERT_FALSE(runtime_has_engine_type(NULL, "motion")); + + /* No stored engines: the legacy model becomes the only engine. */ + const struct { const char *model; const char *type; } legacy_models[] = { + { "motion", "motion" }, { "onvif", "onvif" }, + { "http://127.0.0.1:9001/detect", "api" }, { "/opt/models/x.tflite", "object" }, + }; + safe_strcpy(ctx->stream_name, STREAM, sizeof(ctx->stream_name), 0); + for (size_t i = 0; i < sizeof(legacy_models) / sizeof(legacy_models[0]); i++) { + load_runtime_engine_config(ctx, legacy_models[i].model, 0.4f, 0); + TEST_ASSERT_EQUAL_INT(1, ctx->engine_count); + TEST_ASSERT_EQUAL_STRING(legacy_models[i].type, ctx->engines[0].engine_type); + TEST_ASSERT_EQUAL_STRING(legacy_models[i].model, ctx->model_path); + TEST_ASSERT_EQUAL_INT(DEFAULT_DETECTION_INTERVAL, ctx->detection_interval); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.4f, ctx->detection_threshold); + } + /* External-trigger-only: no engines, grace follows the sampling interval. */ + ctx->detection_interval = 7; + load_runtime_engine_config(ctx, "", 0.4f, 7); + TEST_ASSERT_EQUAL_INT(0, ctx->engine_count); + TEST_ASSERT_EQUAL_INT(7, ctx->recording_grace_interval); +} + +static AVFrame *gray_frame(void) { + AVFrame *frame = av_frame_alloc(); + TEST_ASSERT_NOT_NULL(frame); + frame->format = AV_PIX_FMT_YUV420P; + frame->width = 64; + frame->height = 64; + TEST_ASSERT_EQUAL_INT(0, av_frame_get_buffer(frame, 0)); + memset(frame->data[0], 0x80, (size_t)frame->linesize[0] * 64); + memset(frame->data[1], 0x80, (size_t)frame->linesize[1] * 32); + memset(frame->data[2], 0x80, (size_t)frame->linesize[2] * 32); + return frame; +} + +static void set_engine(int i, const char *key, const char *type, const char *model, int interval) { + stream_detection_engine_t *engine = &ctx->engines[i]; + memset(engine, 0, sizeof(*engine)); + safe_strcpy(engine->engine_key, key, sizeof(engine->engine_key), 0); + safe_strcpy(engine->engine_type, type, sizeof(engine->engine_type), 0); + safe_strcpy(engine->model_path, model, sizeof(engine->model_path), 0); + engine->enabled = true; + engine->threshold = 0.5f; + engine->interval_seconds = interval; +} + +/* Multi-engine fan-out on one decoded frame: ONVIF reports its async flag, + * an object model that can't load is disabled once, API engines are isolated, + * and every engine then waits out its own interval. */ +static void test_runtime_engine_fan_out(void) { + set_engine(0, "motion-a", "motion", "motion", 60); + set_engine(1, "people", "object", "/nonexistent/people.tflite", 60); + set_engine(2, "doorbell", "onvif", "onvif", 60); + set_engine(3, "cloud", "api", "http://127.0.0.1:9/detect", 60); + ctx->engine_count = 4; + AVFrame *frame = gray_frame(); + detection_result_t result; + time_t now = time(NULL); + + atomic_store(&ctx->onvif_motion_detected, 1); + TEST_ASSERT_TRUE(detect_on_decoded_frame(ctx, frame, now, &result)); + TEST_ASSERT_EQUAL_INT(0, result.count); + TEST_ASSERT_TRUE(ctx->engine_load_failed[1]); + TEST_ASSERT_TRUE(ctx->engine_load_failed[3]); + for (int i = 0; i < 4; i++) TEST_ASSERT_EQUAL_INT64(now, ctx->engine_last_run[i]); + + /* Within every interval: nothing is due, so not even ONVIF is re-read. */ + TEST_ASSERT_FALSE(detect_on_decoded_frame(ctx, frame, now + 1, &result)); + + /* append_engine_result keeps only detections above the engine threshold. */ + detection_result_t combined = {0}, candidate = {0}; + candidate.count = 2; + candidate.detections[0].confidence = 0.9f; + safe_strcpy(candidate.detections[0].label, "person", sizeof(candidate.detections[0].label), 0); + candidate.detections[1].confidence = 0.1f; + safe_strcpy(candidate.detections[1].label, "cat", sizeof(candidate.detections[1].label), 0); + append_engine_result(ctx, &ctx->engines[1], &combined, &candidate); + TEST_ASSERT_EQUAL_INT(1, combined.count); + TEST_ASSERT_EQUAL_STRING("person", combined.detections[0].label); + + /* Single-engine dispatch: motion runs, a missing embedded model fails once. */ + ctx->engine_count = 1; + safe_strcpy(ctx->model_path, "motion", sizeof(ctx->model_path), 0); + TEST_ASSERT_FALSE(detect_on_decoded_frame(ctx, frame, now, &result)); + safe_strcpy(ctx->model_path, "/nonexistent/legacy.tflite", sizeof(ctx->model_path), 0); + TEST_ASSERT_FALSE(detect_on_decoded_frame(ctx, frame, now, &result)); + TEST_ASSERT_TRUE(ctx->model_load_failed); + TEST_ASSERT_FALSE(detect_on_decoded_frame(ctx, frame, now, &result)); + ctx->model_path[0] = '\0'; + ctx->model_load_failed = false; + TEST_ASSERT_FALSE(detect_on_decoded_frame(ctx, frame, now, &result)); + TEST_ASSERT_FALSE(detect_on_decoded_frame(NULL, frame, now, &result)); + + unload_runtime_models(ctx); + av_frame_free(&frame); +} + +static bool live_context_exists(void) { + pthread_mutex_lock(&contexts_mutex); + bool exists = find_context_by_name(LIVE_STREAM) != NULL; + pthread_mutex_unlock(&contexts_mutex); + return exists; +} + +/* Start a real UDT against the fixture file: it connects, reads and processes + * packets, and on stop disconnects and frees its own context. The detection + * URL is refused (file access is not whitelisted), so that thread backs off + * until it is stopped, and the ONVIF engine has no derivable endpoint. */ +static void test_thread_lifecycle_reads_local_source(void) { + TEST_ASSERT_EQUAL_INT(-1, start_unified_detection_thread(NULL, "onvif", 0.5f, 5, 5, false)); + TEST_ASSERT_EQUAL_INT(-1, start_unified_detection_thread(LIVE_STREAM, NULL, 0.5f, 5, 5, false)); + TEST_ASSERT_EQUAL_INT(-1, start_unified_detection_thread("udt-missing", "onvif", 0.5f, 5, 5, false)); + + TEST_ASSERT_EQUAL_INT(0, start_unified_detection_thread(LIVE_STREAM, "onvif", 0.5f, 0, 0, true)); + TEST_ASSERT_EQUAL_INT(0, start_unified_detection_thread(LIVE_STREAM, "onvif", 0.5f, 0, 0, true)); + TEST_ASSERT_TRUE(is_unified_detection_running(LIVE_STREAM)); + + uint64_t packets = 0; + for (int i = 0; i < 200 && packets == 0; i++) { + usleep(50000); + TEST_ASSERT_EQUAL_INT(0, get_unified_detection_stats(LIVE_STREAM, &packets, NULL, NULL)); + } + TEST_ASSERT_GREATER_THAN_UINT64(0, packets); + TEST_ASSERT_EQUAL_INT(STREAM_STATUS_RUNNING, get_unified_detection_effective_status(LIVE_STREAM)); + + TEST_ASSERT_EQUAL_INT(0, stop_unified_detection_thread(LIVE_STREAM)); + for (int i = 0; i < 300 && live_context_exists(); i++) usleep(50000); + TEST_ASSERT_FALSE(live_context_exists()); + TEST_ASSERT_EQUAL_INT(UDT_STATE_STOPPED, get_unified_detection_state(LIVE_STREAM)); +} + +/* ---- main --------------------------------------------------------------- */ + +static int load_fixture(void) { + if (avformat_open_input(&source, TEST_RECORDING_PATH, NULL, NULL) != 0) return -1; + if (avformat_find_stream_info(source, NULL) < 0) return -1; + AVPacket *pkt = av_packet_alloc(); + if (!pkt) return -1; + int64_t end = 0; + while (av_read_frame(source, pkt) >= 0) { + if (pkt->stream_index == 0) { + AVPacket **grown = realloc(video_packets, sizeof(*grown) * (size_t)(video_count + 1)); + if (!grown) return -1; + video_packets = grown; + video_packets[video_count++] = av_packet_clone(pkt); + if (pkt->pts + pkt->duration > end) end = pkt->pts + pkt->duration; + } + av_packet_unref(pkt); + } + av_packet_free(&pkt); + source_duration = end; + return video_count > 0 ? 0 : -1; +} + +int main(void) { + init_logger(); + set_log_level(LOG_LEVEL_ERROR); + load_default_config(&g_config); + if (!mkdtemp(root)) return 2; + snprintf(db_path, sizeof(db_path), "%s/catalog.sqlite", root); + safe_strcpy(g_config.storage_path, root, sizeof(g_config.storage_path), 0); + if (init_database_ex(db_path, DB_INIT_NO_BACKUP | DB_INIT_NO_CHECK) != 0) return 2; + + stream_config_t camera = {0}; + safe_strcpy(camera.name, STREAM, sizeof(camera.name), 0); + safe_strcpy(camera.url, "rtsp://fixture/live", sizeof(camera.url), 0); + camera.enabled = true; + camera.detection_record_on_schedule = true; + camera.audio_voice_enhancement = true; + if (!add_stream_config(&camera)) return 2; + stream_config_t live = {0}; + safe_strcpy(live.name, LIVE_STREAM, sizeof(live.name), 0); + safe_strcpy(live.url, TEST_RECORDING_PATH, sizeof(live.url), 0); + safe_strcpy(live.detection_url, TEST_RECORDING_PATH, sizeof(live.detection_url), 0); + live.enabled = true; + live.record_audio = true; + live.detection_interval = 1; + if (!add_stream_config(&live)) return 2; + stream_config_t engines = {0}; + safe_strcpy(engines.name, ENGINE_STREAM, sizeof(engines.name), 0); + safe_strcpy(engines.url, "rtsp://fixture/engines", sizeof(engines.url), 0); + engines.enabled = true; + if (!add_stream_config(&engines)) return 2; + if (init_stream_manager(MAX_STREAMS) != 0) return 2; + char target_uuid[LIGHTNVR_UUID_STRING_SIZE]; + if (db_storage_target_bootstrap_default(root, target_uuid) != 0) return 2; + char *error = NULL; + if (sqlite3_exec(get_db_handle(), + "UPDATE storage_targets SET health_status='healthy', enabled=1;", + NULL, NULL, &error) != SQLITE_OK) return 2; + if (load_fixture() != 0) return 2; + if (init_unified_detection_system() != 0) return 2; + + UNITY_BEGIN(); + RUN_TEST(test_detection_records_pcm_audio_with_voice_enhancement); + RUN_TEST(test_external_trigger_cycle); + RUN_TEST(test_detection_stream_hand_off); + RUN_TEST(test_onvif_onset_fast_path); + RUN_TEST(test_start_recording_audio_variants); + RUN_TEST(test_blocked_placement_does_not_start); + RUN_TEST(test_stop_fallback_and_noop); + RUN_TEST(test_runtime_engine_config); + RUN_TEST(test_runtime_engine_fan_out); + RUN_TEST(test_registry_status_api); + RUN_TEST(test_thread_lifecycle_reads_local_source); + RUN_TEST(test_shutdown_cleans_up_stopped_contexts); + int failures = UNITY_END(); + + shutdown_unified_detection_system(); + shutdown_stream_manager(); + for (int i = 0; i < video_count; i++) av_packet_free(&video_packets[i]); + free(video_packets); + avformat_close_input(&source); + shutdown_database(); + char command[PATH_MAX + 16]; + snprintf(command, sizeof(command), "rm -rf '%s'", root); + if (system(command) != 0) return failures ? failures : 3; + return failures; +} diff --git a/web/public/locales/en.json b/web/public/locales/en.json index a291609c..118396ea 100644 --- a/web/public/locales/en.json +++ b/web/public/locales/en.json @@ -1765,7 +1765,7 @@ "streamsConfig.recordAudio": "Record Audio", "streamsConfig.twoWayAudio": "Two-Way Audio", "streamsConfig.audioVoiceEnhancement": "Voice Enhancement (recordings)", - "streamsConfig.audioSettingsHelp": "Audio recording applies to both continuous and detection-based recordings (requires audio track in stream). Voice Enhancement is a preview opt-in that has no effect yet — the noise-reduction filter chain for the G.711 → AAC transcode lands in a future update; recordings only, live audio is unchanged.", + "streamsConfig.audioSettingsHelp": "Audio recording applies to both continuous and detection-based recordings (requires audio track in stream). Voice Enhancement reduces hum and background noise in recordings when the camera sends G.711/PCM audio, which is transcoded to AAC; AAC and other pass-through audio is recorded unchanged. Live audio is not affected.", "streamsConfig.aiDetectionSettings": "AI Detection Settings", "streamsConfig.detectionUrl": "Detection Stream URL (optional)", "streamsConfig.detectionUrlPlaceholder": "http://192.168.1.100/mjpeg or rtsp://…/substream", diff --git a/web/public/locales/fil.json b/web/public/locales/fil.json index 3238d789..da8a1b52 100644 --- a/web/public/locales/fil.json +++ b/web/public/locales/fil.json @@ -1023,7 +1023,7 @@ "streamsConfig.audioSettings": "Mga Setting ng Audio", "streamsConfig.recordAudio": "I-record ang Audio", "streamsConfig.twoWayAudio": "Two-Way Audio", - "streamsConfig.audioSettingsHelp": "Nalalapat ang audio recording sa tuloy-tuloy at detection-based na recording (kailangan ng audio track sa stream). Opsyonal na preview ang Voice Enhancement at wala pang epekto — darating sa susunod na update ang noise-reduction filter para sa G.711 → AAC transcode; para lamang sa recording, hindi magbabago ang live na audio.", + "streamsConfig.audioSettingsHelp": "Nalalapat ang audio recording sa tuloy-tuloy at detection-based na recording (kailangan ng audio track sa stream). Binabawasan ng Voice Enhancement ang ugong at ingay sa background ng mga recording kapag G.711/PCM audio ang ipinapadala ng camera, na tina-transcode sa AAC; hindi binabago ang AAC at iba pang pass-through na audio. Hindi apektado ang live na audio.", "streamsConfig.aiDetectionSettings": "Mga Setting ng AI Detection", "streamsConfig.detectionUrl": "URL ng Detection Stream (opsyonal)", "streamsConfig.detectionUrlPlaceholder": "http://192.168.1.100/mjpeg o rtsp://…/substream", diff --git a/web/public/locales/ko.json b/web/public/locales/ko.json index 1bfceeb8..304a178e 100644 --- a/web/public/locales/ko.json +++ b/web/public/locales/ko.json @@ -808,7 +808,7 @@ "streamsConfig.recordAudio": "오디오 녹음", "streamsConfig.twoWayAudio": "양방향 오디오", "streamsConfig.audioVoiceEnhancement": "음성 향상 (녹화)", - "streamsConfig.audioSettingsHelp": "오디오 녹음은 연속 및 감지 기반 녹화 모두에 적용됩니다 (스트림에 오디오 트랙 필요). 음성 향상은 미리보기 옵션으로 아직 효과가 없습니다. G.711 → AAC 트랜스코드용 노이즈 감소 필터 체인은 향후 업데이트에 포함될 예정입니다. 라이브 오디오에는 영향이 없습니다.", + "streamsConfig.audioSettingsHelp": "오디오 녹음은 연속 및 감지 기반 녹화 모두에 적용됩니다 (스트림에 오디오 트랙 필요). 음성 향상은 카메라가 G.711/PCM 오디오를 보낼 때(AAC로 트랜스코딩됨) 녹화의 험 노이즈와 배경 소음을 줄입니다. AAC 등 그대로 전달되는 오디오는 변경 없이 녹음됩니다. 라이브 오디오에는 영향이 없습니다.", "streamsConfig.aiDetectionSettings": "AI 감지 설정", "streamsConfig.detectionUrl": "감지 스트림 URL (선택 사항)", "streamsConfig.detectionUrlPlaceholder": "http://192.168.1.100/mjpeg 또는 rtsp://…/substream",