Files
zoneminder/tests/zm_audio_detector.cpp
T
Isaac ConnorandClaude Opus 5 428c048bac feat: measure the audio level on demand, with a meter in the editor
Reverts the previous commit's always-on measurement. Decoding audio for every
monitor that has it spends CPU on a number almost nothing reads, which is the
wrong trade even though it did solve the chicken and egg of picking a
threshold without ever seeing a level.

Measure when something is actually going to use the reading instead:

 - AudioDetection is on, as before, so nothing changes for a monitor that
   scores on audio; or
 - somebody asked. SharedData gains audio_level_until, a wall clock second
   the capture thread keeps measuring up to. The monitor editor's new level
   meter pushes it forward while it is on screen and the measurement lapses a
   few seconds after the page is left, so nothing has to send a stop and a
   crashed browser cannot leave a monitor decoding forever.

When the reading stops being wanted the decoder is released and the published
level and peak are cleared, so a stale number is not left looking current and
an old peak does not land on the next frame row written.

audio_level_until is carved out of analysis_pad rather than appended, so
SharedData stays 888 bytes and no existing offset moves; the static_asserts,
Memory.pm and Monitor.php are updated together and all three now agree the
field is at +880.

The meter itself is on the audio settings, shown whether or not
AudioDetection is checked, because the level is what you need in order to
choose a threshold. It draws the threshold currently in the input as a mark on
the bar so a reading can be judged against it before saving, and a monitor
whose zmc is not running reads "no reading" rather than a confident 0, which
would be indistinguishable from silence.

Frames.AudioLevel is therefore 0 again on monitors that do not score on audio.
That is what the graph already treats as "no audio data", so it draws no line
rather than a flat one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME
2026-09-20 18:17:44 -05:00

311 lines
11 KiB
C++

/*
* This file is part of the ZoneMinder Project. See AUTHORS file for Copyright information
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include "zm_catch2.h"
#include "zm_audio_detector.h"
#include <cmath>
#include <vector>
namespace {
// A full-scale square wave: every sample at the same magnitude, so its RMS is
// exactly that magnitude and the expected level can be reasoned about rather
// than measured.
std::vector<int16_t> SquareS16(int16_t amplitude, size_t count) {
std::vector<int16_t> samples(count);
for (size_t i = 0; i < count; i++)
samples[i] = (i % 2) ? amplitude : static_cast<int16_t>(-amplitude);
return samples;
}
} // namespace
TEST_CASE("Audio RMS of signed 16-bit samples") {
SECTION("silence is zero") {
const std::vector<int16_t> silence(128, 0);
REQUIRE(AudioDetector::RmsS16(silence.data(), silence.size()) == 0.0);
}
SECTION("a full scale square wave is full scale") {
const auto samples = SquareS16(32767, 64);
REQUIRE(AudioDetector::RmsS16(samples.data(), samples.size()) == Catch::Approx(1.0).margin(0.001));
}
SECTION("the most negative sample does not exceed full scale") {
// -32768 over 32767 would give a ratio above 1.0 and, once squared and
// fed to log10, a positive dB reading for a legal sample.
const std::vector<int16_t> samples(16, -32768);
REQUIRE(AudioDetector::RmsS16(samples.data(), samples.size()) <= 1.0);
}
SECTION("halving the amplitude halves the rms") {
const auto loud = SquareS16(16384, 64);
const auto quiet = SquareS16(8192, 64);
REQUIRE(AudioDetector::RmsS16(loud.data(), loud.size()) ==
Catch::Approx(2.0 * AudioDetector::RmsS16(quiet.data(), quiet.size())));
}
SECTION("no samples is zero, not a division by zero") {
REQUIRE(AudioDetector::RmsS16(nullptr, 0) == 0.0);
const std::vector<int16_t> samples(4, 1000);
REQUIRE(AudioDetector::RmsS16(samples.data(), 0) == 0.0);
}
}
TEST_CASE("Audio RMS of float samples") {
SECTION("silence is zero") {
const std::vector<float> silence(64, 0.0f);
REQUIRE(AudioDetector::RmsFloat(silence.data(), silence.size()) == 0.0);
}
SECTION("full scale is one") {
const std::vector<float> samples(64, 1.0f);
REQUIRE(AudioDetector::RmsFloat(samples.data(), samples.size()) == Catch::Approx(1.0));
}
SECTION("decoder overshoot is clamped rather than scoring above full scale") {
// Float decoders are allowed to emit values outside -1..1; without the
// clamp a hot AAC stream would report a level above 100.
const std::vector<float> samples(64, 4.0f);
REQUIRE(AudioDetector::RmsFloat(samples.data(), samples.size()) == Catch::Approx(1.0));
}
}
TEST_CASE("Audio level scale") {
SECTION("silence and sub-floor signals are zero") {
REQUIRE(AudioDetector::LevelFromRms(0.0) == 0);
// -80 dBFS, well under the -60 floor.
REQUIRE(AudioDetector::LevelFromRms(0.0001) == 0);
}
SECTION("full scale is 100") {
REQUIRE(AudioDetector::LevelFromRms(1.0) == 100);
}
SECTION("the floor itself is the bottom of the scale") {
const double floor_rms = std::pow(10.0, AudioDetector::AUDIO_FLOOR_DB / 20.0);
REQUIRE(AudioDetector::LevelFromRms(floor_rms) == 0);
}
SECTION("half the floor in dB is half the scale") {
// -30 dBFS is the midpoint of a -60..0 range, so it must land on 50.
REQUIRE(AudioDetector::LevelFromRms(std::pow(10.0, -30.0 / 20.0)) == 50);
}
SECTION("the scale is monotonic and never leaves 0..100") {
int previous = -1;
for (int db = -70; db <= 0; db++) {
const int level = AudioDetector::LevelFromRms(std::pow(10.0, db / 20.0));
REQUIRE(level >= 0);
REQUIRE(level <= 100);
REQUIRE(level >= previous);
previous = level;
}
}
SECTION("ordinary speech lands somewhere usable, not pinned at the bottom") {
// Roughly 2% of full scale. On a linear scale this would be level 2 and
// indistinguishable from noise; the whole point of the dB mapping is that
// it is not.
const int level = AudioDetector::LevelFromRms(0.02);
REQUIRE(level > 30);
REQUIRE(level < 50);
}
SECTION("a value above full scale still cannot exceed 100") {
REQUIRE(AudioDetector::LevelFromRms(2.0) == 100);
}
}
TEST_CASE("Audio alarm threshold") {
SECTION("a level at or above the threshold alarms") {
REQUIRE(AudioDetector::IsAlarm(40, 40));
REQUIRE(AudioDetector::IsAlarm(41, 40));
}
SECTION("a quieter level does not") {
REQUIRE_FALSE(AudioDetector::IsAlarm(39, 40));
REQUIRE_FALSE(AudioDetector::IsAlarm(0, 40));
}
SECTION("a threshold of zero means off, not alarm on silence") {
// The naive level >= threshold test makes an unconfigured monitor alarm on
// every single audio packet, which is the worst possible default.
REQUIRE_FALSE(AudioDetector::IsAlarm(0, 0));
REQUIRE_FALSE(AudioDetector::IsAlarm(100, 0));
REQUIRE_FALSE(AudioDetector::IsAlarm(50, -1));
}
}
TEST_CASE("Audio detector with no decoder open") {
AudioDetector detector;
SECTION("reports no level and refuses to open a null stream") {
REQUIRE_FALSE(detector.IsOpen());
REQUIRE_FALSE(detector.Open(nullptr));
REQUIRE(detector.Level() == 0);
}
SECTION("processing without a decoder is a no-op rather than a crash") {
REQUIRE(detector.Process(nullptr) == 0);
REQUIRE(detector.Level() == 0);
}
}
TEST_CASE("Audio detector does not retry a codec it cannot decode") {
// Monitor::Capture calls Open on every audio packet until it succeeds, and
// it now does so for every monitor with audio rather than only those with
// AudioDetection on. A stream ZoneMinder has no decoder for must therefore
// fail quietly after the first attempt, or it warns at the audio packet
// rate for as long as the monitor runs.
AudioDetector detector;
AVCodecParameters codecpar = {};
codecpar.codec_type = AVMEDIA_TYPE_AUDIO;
// Deliberately not a real audio codec, so no decoder can be found for it
// whatever ffmpeg build this runs against.
codecpar.codec_id = AV_CODEC_ID_FIRST_UNKNOWN;
SECTION("the first attempt fails and later ones stay failed") {
REQUIRE_FALSE(detector.Open(&codecpar));
REQUIRE_FALSE(detector.Open(&codecpar));
REQUIRE_FALSE(detector.IsOpen());
}
SECTION("a different codec is still tried") {
REQUIRE_FALSE(detector.Open(&codecpar));
// PCM is built into every ffmpeg, so this one really should open. What is
// being pinned is that the refusal is specific to the failed codec and
// does not disable the detector for the life of the monitor.
AVCodecParameters pcm = {};
pcm.codec_type = AVMEDIA_TYPE_AUDIO;
pcm.codec_id = AV_CODEC_ID_PCM_S16LE;
pcm.sample_rate = 8000;
#if LIBAVUTIL_VERSION_CHECK(57, 28, 100, 28, 0)
av_channel_layout_default(&pcm.ch_layout, 1);
#else
pcm.channels = 1;
pcm.channel_layout = AV_CH_LAYOUT_MONO;
#endif
REQUIRE(detector.Open(&pcm));
REQUIRE(detector.IsOpen());
}
SECTION("a successful open clears the refusal") {
AVCodecParameters pcm = {};
pcm.codec_type = AVMEDIA_TYPE_AUDIO;
pcm.codec_id = AV_CODEC_ID_PCM_S16LE;
pcm.sample_rate = 8000;
#if LIBAVUTIL_VERSION_CHECK(57, 28, 100, 28, 0)
av_channel_layout_default(&pcm.ch_layout, 1);
#else
pcm.channels = 1;
pcm.channel_layout = AV_CH_LAYOUT_MONO;
#endif
REQUIRE(detector.Open(&pcm));
REQUIRE(detector.Open(&pcm));
REQUIRE(detector.IsOpen());
}
}
TEST_CASE("Audio level is only measured when something wants it") {
// Decoding audio costs CPU on every packet, so a monitor nobody is asking
// about must not do it. Two things ask: scoring on audio, and the editor's
// level meter, which writes a deadline into shared memory and pushes it
// forward while it is on screen.
const uint32_t now = 1000;
SECTION("a monitor that scores on audio always wants it") {
REQUIRE(AudioDetector::LevelWanted(true, 0, now));
// Even with a long expired request, because the setting alone is enough.
REQUIRE(AudioDetector::LevelWanted(true, 1, now));
}
SECTION("with detection off and nobody asking, it is not measured") {
REQUIRE_FALSE(AudioDetector::LevelWanted(false, 0, now));
}
SECTION("an outstanding request turns it on") {
REQUIRE(AudioDetector::LevelWanted(false, now + 10, now));
}
SECTION("the deadline second itself still counts") {
REQUIRE(AudioDetector::LevelWanted(false, now, now));
}
SECTION("an expired request turns it off again") {
// This is what stops the decoding when the editor is closed: nothing
// sends a "stop", the request simply runs out.
REQUIRE_FALSE(AudioDetector::LevelWanted(false, now - 1, now));
}
}
TEST_CASE("Audio peak tracking") {
// The peak is what gets persisted in Frames.AudioLevel. Frames rows are
// written well below the capture rate -- only alarm, bulk and
// score-increasing frames get one -- so sampling Level() when the row is
// written would drop a bang that happened between two rows and had already
// decayed by the time the row was built.
AudioDetector detector;
SECTION("starts at zero") {
REQUIRE(detector.TakePeak() == 0);
}
SECTION("keeps the loudest level seen, not the most recent") {
detector.RaisePeak(12);
detector.RaisePeak(73);
detector.RaisePeak(4);
REQUIRE(detector.TakePeak() == 73);
}
SECTION("taking it clears it, so each row covers its own interval") {
detector.RaisePeak(73);
REQUIRE(detector.TakePeak() == 73);
REQUIRE(detector.TakePeak() == 0);
detector.RaisePeak(5);
REQUIRE(detector.TakePeak() == 5);
}
SECTION("a quieter interval after a loud one is not held up by it") {
detector.RaisePeak(90);
detector.TakePeak();
detector.RaisePeak(3);
REQUIRE(detector.TakePeak() == 3);
}
SECTION("the peak is independent of the current level") {
// Level() is what the alarm threshold compares against and must keep
// tracking the present; TakePeak() must not disturb it.
detector.RaisePeak(64);
REQUIRE(detector.Level() == 0);
REQUIRE(detector.TakePeak() == 64);
REQUIRE(detector.Level() == 0);
}
SECTION("silence is recorded as silence") {
detector.RaisePeak(0);
REQUIRE(detector.TakePeak() == 0);
}
}