/*
* 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 .
*/
#include "zm_catch2.h"
#include "zm_audio_detector.h"
#include
#include
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 SquareS16(int16_t amplitude, size_t count) {
std::vector samples(count);
for (size_t i = 0; i < count; i++)
samples[i] = (i % 2) ? amplitude : static_cast(-amplitude);
return samples;
}
} // namespace
TEST_CASE("Audio RMS of signed 16-bit samples") {
SECTION("silence is zero") {
const std::vector 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 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 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 silence(64, 0.0f);
REQUIRE(AudioDetector::RmsFloat(silence.data(), silence.size()) == 0.0);
}
SECTION("full scale is one") {
const std::vector 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 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);
}
}