voice_audio_multi/tests/granite5asr/test_ctc_timestamps.cpp
Fabio c36510c21e
Some checks failed
Linux Build / Linux cpu (push) Has been cancelled
Linux Build / Linux cpu-clang (push) Has been cancelled
Linux Build / Linux vulkan (push) Has been cancelled
mac-build / macOS CPU (push) Has been cancelled
Nix Build / Nix (cpu) (push) Has been cancelled
Nix Build / Nix (vulkan) (push) Has been cancelled
windows-build / Windows CPU (push) Has been cancelled
Initial import: audio.cpp with Orange Pi documentation
2026-11-04 19:05:22 +01:00

295 lines
16 KiB
C++

#include "engine/community_models/granite5asr/ctc_timestamps.h"
#include "test_assert.h"
#include <cmath>
#include <iostream>
#include <string>
#include <vector>
namespace {
using engine::community_models::granite5asr::CtcArgmaxFrame;
using engine::community_models::granite5asr::CtcPiece;
using engine::community_models::granite5asr::DecodedCtcPiece;
using engine::community_models::granite5asr::Granite5ASRConfig;
using engine::community_models::granite5asr::ctc_greedy_pieces;
using engine::community_models::granite5asr::language_code_from_token;
using engine::community_models::granite5asr::samples_per_ctc_frame;
using engine::community_models::granite5asr::scale_sample_to_rate;
using engine::community_models::granite5asr::words_from_decoded_ctc_pieces;
constexpr int32_t kBlank = 0;
constexpr int64_t kRate = 16000;
CtcArgmaxFrame frame(int32_t id, float probability) {
return CtcArgmaxFrame{id, probability};
}
DecodedCtcPiece decoded_from(const CtcPiece & piece, std::string text) {
return DecodedCtcPiece{
std::move(text),
piece.start_frame,
piece.end_frame,
piece.log_prob_sum,
piece.frames,
};
}
void test_blank_gap_between_labels() {
const std::vector<CtcArgmaxFrame> frames = {
frame(1, 0.9f), frame(1, 0.9f), frame(1, 0.9f),
frame(kBlank, 0.9f), frame(kBlank, 0.9f), frame(kBlank, 0.9f),
frame(2, 0.8f),
};
const auto pieces = ctc_greedy_pieces(frames, kBlank);
engine::test::require_eq(pieces.size(), size_t(2), "A and B are separate pieces");
engine::test::require_eq(pieces[0].id, int32_t(1), "first piece is A");
engine::test::require_eq(pieces[0].start_frame, int64_t(0), "A starts at frame 0");
engine::test::require_eq(pieces[0].end_frame, int64_t(3), "A ends at the first blank");
engine::test::require_eq(pieces[1].id, int32_t(2), "second piece is B");
engine::test::require_eq(pieces[1].start_frame, int64_t(6), "B starts after the blanks");
engine::test::require_eq(pieces[1].frames, int64_t(1), "trailing blanks are outside B");
}
void test_repeated_label_is_one_piece() {
std::vector<CtcArgmaxFrame> frames(50, frame(4, 0.5f));
const auto pieces = ctc_greedy_pieces(frames, kBlank);
engine::test::require_eq(pieces.size(), size_t(1), "a repeated label is one piece");
engine::test::require_eq(pieces[0].end_frame - pieces[0].start_frame, int64_t(50), "piece covers the run");
engine::test::require_eq(pieces[0].frames, int64_t(50), "every frame of the run is scored");
}
void test_subword_gap_stays_inside_one_word() {
std::vector<CtcArgmaxFrame> frames;
frames.insert(frames.end(), 2, frame(7, 0.5f));
frames.insert(frames.end(), 39, frame(kBlank, 0.99f));
frames.insert(frames.end(), 2, frame(8, 0.5f));
const auto pieces = ctc_greedy_pieces(frames, kBlank);
engine::test::require_eq(pieces.size(), size_t(2), "hel and lo collapse separately");
engine::test::require_eq(pieces[0].start_frame, int64_t(0), "hel starts at 0");
engine::test::require_eq(pieces[0].end_frame, int64_t(2), "hel ends at the blank");
engine::test::require_eq(pieces[1].start_frame, int64_t(41), "lo starts at frame 41");
engine::test::require_eq(pieces[1].end_frame, int64_t(43), "lo ends at frame 43");
engine::test::require_eq(pieces[0].frames + pieces[1].frames, int64_t(4), "blank frames are not scored");
const auto words = words_from_decoded_ctc_pieces(
{decoded_from(pieces[0], "hel"), decoded_from(pieces[1], "lo")},
1,
0,
1000,
kRate,
kRate);
engine::test::require_eq(words.size(), size_t(1), "no leading space keeps one word");
engine::test::require(words[0].word == "hello", "subwords concatenate");
engine::test::require_eq(words[0].span.start_sample, int64_t(0), "word starts at the first piece");
engine::test::require_eq(words[0].span.end_sample, int64_t(43), "word ends at the last piece");
engine::test::require_close(words[0].confidence, 0.5f, 1.0e-5f, "confidence ignores blank frames");
}
void test_punctuation_does_not_extend_the_word() {
const DecodedCtcPiece effort{"effort", 0, 2, 2.0 * std::log(0.9), 2};
const DecodedCtcPiece period{".", 2, 80, std::log(0.5), 78};
const DecodedCtcPiece toward{" toward", 80, 82, std::log(0.8), 2};
const auto words = words_from_decoded_ctc_pieces(
{effort, period, toward}, 1, 0, 1000, kRate, kRate);
engine::test::require_eq(words.size(), size_t(2), "punctuation stays on the previous word");
engine::test::require(words[0].word == "effort.", "the period is kept on the word");
engine::test::require_eq(words[0].span.start_sample, int64_t(0), "word still starts at the letters");
engine::test::require_eq(words[0].span.end_sample, int64_t(2), "the period's pause is not part of the word");
engine::test::require_close(words[0].confidence, 0.9f, 1.0e-5f, "punctuation frames are not scored");
engine::test::require(words[1].word == "toward", "the next word is unchanged");
engine::test::require_eq(words[1].span.start_sample, int64_t(80), "the next word keeps its own start");
const DecodedCtcPiece spaced_period{" .", 2, 80, 0.0, 1};
const auto spaced = words_from_decoded_ctc_pieces(
{effort, spaced_period}, 1, 0, 1000, kRate, kRate);
engine::test::require_eq(spaced.size(), size_t(1), "a spaced period is not its own word");
engine::test::require(spaced[0].word == "effort.", "a spaced period is written onto the word");
engine::test::require_eq(spaced[0].span.end_sample, int64_t(2), "a spaced period does not move the end");
const DecodedCtcPiece mark{"。", 2, 40, 0.0, 1};
const auto cjk = words_from_decoded_ctc_pieces({effort, mark}, 1, 0, 1000, kRate, kRate);
engine::test::require(cjk[0].word == "effort。", "ideographic punctuation is kept");
engine::test::require_eq(cjk[0].span.end_sample, int64_t(2), "ideographic punctuation does not move the end");
}
void test_leading_symbols_stay_on_the_following_word() {
const DecodedCtcPiece euro{"€", 0, 1, std::log(0.9), 1};
const DecodedCtcPiece fifty{"50", 1, 3, 2.0 * std::log(0.8), 2};
const auto amount = words_from_decoded_ctc_pieces(
{euro, fifty}, 1, 0, 1000, kRate, kRate);
engine::test::require_eq(amount.size(), size_t(1), "a leading euro stays with the number");
engine::test::require(amount[0].word == "€50", "€50 keeps the currency symbol");
engine::test::require_eq(amount[0].span.start_sample, int64_t(0), "the symbol starts the word");
engine::test::require_eq(amount[0].span.end_sample, int64_t(3), "the number ends the word");
const DecodedCtcPiece quote{"\"", 0, 1, std::log(0.9), 1};
const DecodedCtcPiece hello{"Hello", 1, 4, std::log(0.8), 3};
const auto quoted = words_from_decoded_ctc_pieces(
{quote, hello}, 1, 0, 1000, kRate, kRate);
engine::test::require_eq(quoted.size(), size_t(1), "a leading quote stays with the word");
engine::test::require(quoted[0].word == "\"Hello", "the opening quote is kept");
const DecodedCtcPiece said{"said", 0, 2, std::log(0.7), 2};
const DecodedCtcPiece spaced_euro{" €", 2, 3, std::log(0.9), 1};
const DecodedCtcPiece digits{"50", 3, 5, std::log(0.8), 2};
const auto after = words_from_decoded_ctc_pieces(
{said, spaced_euro, digits}, 1, 0, 1000, kRate, kRate);
engine::test::require_eq(after.size(), size_t(2), "a spaced currency symbol starts the next word");
engine::test::require(after[0].word == "said", "the previous word does not take the symbol");
engine::test::require(after[1].word == "€50", "the symbol prefixes the following word");
engine::test::require_eq(after[0].span.end_sample, int64_t(2), "the previous word keeps its own end");
}
void test_leading_space_leaves_blanks_between_words() {
std::vector<CtcArgmaxFrame> frames;
frames.insert(frames.end(), 2, frame(7, 0.8f));
frames.insert(frames.end(), 39, frame(kBlank, 0.8f));
frames.insert(frames.end(), 2, frame(8, 0.8f));
const auto pieces = ctc_greedy_pieces(frames, kBlank);
const auto words = words_from_decoded_ctc_pieces(
{decoded_from(pieces[0], "hel"), decoded_from(pieces[1], " lo")},
1,
0,
1000,
kRate,
kRate);
engine::test::require_eq(words.size(), size_t(2), "leading space flushes the previous word");
engine::test::require(words[0].word == "hel", "first word text");
engine::test::require(words[1].word == "lo", "leading spaces are stripped");
engine::test::require_eq(words[0].span.end_sample, int64_t(2), "first word ends at its piece");
engine::test::require_eq(words[1].span.start_sample, int64_t(41), "second word starts at its piece");
engine::test::require(words[1].span.start_sample > words[0].span.end_sample, "blanks sit in neither word");
}
void test_non_positive_probability_zeros_confidence() {
const auto zero = ctc_greedy_pieces({frame(9, 0.f)}, kBlank);
const auto negative = ctc_greedy_pieces({frame(9, -0.2f)}, kBlank);
engine::test::require_eq(zero[0].frames, int64_t(0), "non-positive probability contributes no frames");
engine::test::require(!std::isfinite(zero[0].log_prob_sum), "non-positive probability yields -inf");
engine::test::require_eq(negative[0].frames, int64_t(0), "negative probability contributes no frames");
const auto words = words_from_decoded_ctc_pieces(
{decoded_from(zero[0], "hel"), decoded_from(negative[0], "lo")},
1280,
0,
100000,
kRate,
kRate);
engine::test::require_eq(words.size(), size_t(1), "zeroed pieces still form the word");
engine::test::require_close(words[0].confidence, 0.f, 0.f, "no contributing frames means confidence 0");
const auto good = ctc_greedy_pieces({frame(9, 0.9f)}, kBlank);
const auto mixed = words_from_decoded_ctc_pieces(
{decoded_from(good[0], "hel"), decoded_from(zero[0], "lo")},
1280,
0,
100000,
kRate,
kRate);
engine::test::require_close(mixed[0].confidence, 0.f, 0.f, "a zeroed piece makes the word sum non-finite");
}
void test_probability_above_one_clamps() {
const auto pieces = ctc_greedy_pieces({frame(3, 1.5f), frame(3, 2.f)}, kBlank);
engine::test::require_eq(pieces[0].frames, int64_t(2), "clamped frames still count");
engine::test::require_close(static_cast<float>(pieces[0].log_prob_sum), 0.f, 0.f, "probability above 1 scores as 1");
}
void test_end_sample_clamps_to_chunk_end() {
const DecodedCtcPiece tail{"tail", 12, 13, 0.0, 1};
const auto words = words_from_decoded_ctc_pieces({tail}, 1280, 16000, 32000, kRate, kRate);
engine::test::require_eq(words[0].span.start_sample, int64_t(16000 + 12 * 1280), "offset start");
engine::test::require_eq(words[0].span.end_sample, int64_t(32000), "end clamps to the chunk end");
}
void test_sample_stride_and_offset() {
Granite5ASRConfig config;
config.frontend.hop_length = 160;
config.frontend.stack_factor = 2;
config.encoder.subsample_layers = {0, 1};
const int64_t stride = samples_per_ctc_frame(config);
engine::test::require_eq(stride, int64_t(1280), "16 kHz frame is 160 * 2 * 4 samples");
const DecodedCtcPiece piece{"hi", 0, 1, 0.0, 1};
const auto origin = words_from_decoded_ctc_pieces({piece}, stride, 0, 1000000, kRate, kRate);
engine::test::require_eq(origin[0].span.start_sample, int64_t(0), "frame 0 is sample 0");
engine::test::require_eq(origin[0].span.end_sample, int64_t(1280), "frame 1 is sample 1280");
const auto shifted = words_from_decoded_ctc_pieces({piece}, stride, 16000, 1000000, kRate, kRate);
engine::test::require_eq(shifted[0].span.start_sample, int64_t(16000), "offset is added to the start");
engine::test::require_eq(shifted[0].span.end_sample, int64_t(17280), "offset is added to the end");
}
void test_frames_convert_to_input_rate_samples() {
const DecodedCtcPiece piece{"hi", 1, 2, 0.0, 1};
const auto high = words_from_decoded_ctc_pieces({piece}, 1280, 48000, 1000000, kRate, 48000);
engine::test::require_eq(high[0].span.start_sample, int64_t(48000 + 3840), "48 kHz frame 1 is sample 3840");
engine::test::require_eq(high[0].span.end_sample, int64_t(48000 + 7680), "48 kHz frame 2 is sample 7680");
const auto low = words_from_decoded_ctc_pieces({piece}, 1280, 8000, 1000000, kRate, 8000);
engine::test::require_eq(low[0].span.start_sample, int64_t(8000 + 640), "8 kHz frame 1 is sample 640");
engine::test::require_eq(low[0].span.end_sample, int64_t(8000 + 1280), "8 kHz frame 2 is sample 1280");
const DecodedCtcPiece tail{"tents", 43, 44, 0.0, 1};
const auto clamped = words_from_decoded_ctc_pieces({tail}, 1280, 0, 28040, kRate, 8000);
engine::test::require_eq(clamped[0].span.start_sample, int64_t(43 * 640), "8 kHz start stays on the input clock");
engine::test::require_eq(clamped[0].span.end_sample, int64_t(28040), "8 kHz end clamps to the input buffer");
}
void test_streaming_samples_use_the_input_rate() {
engine::test::require_eq(scale_sample_to_rate(16000, 16000, 16000), int64_t(16000), "16 kHz stays on its own clock");
engine::test::require_eq(scale_sample_to_rate(16000, 16000, 48000), int64_t(48000), "one 16 kHz second is 48000 input samples");
engine::test::require_eq(scale_sample_to_rate(16000, 16000, 8000), int64_t(8000), "one 16 kHz second is 8000 input samples");
engine::test::require_eq(scale_sample_to_rate(0, 16000, 48000), int64_t(0), "the origin stays at 0");
}
void test_language_tokens_are_recognized() {
engine::test::require(language_code_from_token("<|en|>") == std::string("en"), "en tag");
engine::test::require(language_code_from_token("<|fr|>") == std::string("fr"), "fr tag");
engine::test::require(language_code_from_token("<|ja|>") == std::string("ja"), "ja tag");
engine::test::require(language_code_from_token("<|zh|>").empty(), "unsupported tags are not a language");
engine::test::require(language_code_from_token("hello").empty(), "ordinary text is not a language");
engine::test::require(language_code_from_token("<|timestamp|>").empty(), "the timestamp control is not a language");
}
void test_end_sample_clamps_and_keeps_one_sample() {
const DecodedCtcPiece inside{"hi", 0, 10, 0.0, 1};
const auto clamped = words_from_decoded_ctc_pieces({inside}, 1280, 0, 1000, kRate, kRate);
engine::test::require_eq(clamped[0].span.start_sample, int64_t(0), "start stays inside the buffer");
engine::test::require_eq(clamped[0].span.end_sample, int64_t(1000), "end clamps to the buffer length");
const DecodedCtcPiece at_end{"hi", 1000, 1008, 0.0, 1};
const auto bumped = words_from_decoded_ctc_pieces({at_end}, 1, 0, 1000, kRate, kRate);
engine::test::require_eq(bumped[0].span.start_sample, int64_t(1000), "start is the piece start");
engine::test::require_eq(
bumped[0].span.end_sample,
bumped[0].span.start_sample + 1,
"an empty clamped span is one sample");
}
} // namespace
int main() {
try {
test_blank_gap_between_labels();
test_repeated_label_is_one_piece();
test_subword_gap_stays_inside_one_word();
test_punctuation_does_not_extend_the_word();
test_leading_symbols_stay_on_the_following_word();
test_leading_space_leaves_blanks_between_words();
test_non_positive_probability_zeros_confidence();
test_sample_stride_and_offset();
test_frames_convert_to_input_rate_samples();
test_end_sample_clamps_and_keeps_one_sample();
test_probability_above_one_clamps();
test_end_sample_clamps_to_chunk_end();
test_streaming_samples_use_the_input_rate();
test_language_tokens_are_recognized();
std::cout << "PASS: granite5asr CTC word timestamps\n";
return 0;
} catch (const std::exception & error) {
std::cerr << error.what() << '\n';
return 1;
}
}