voice_audio_multi/tests/whistle_asr/test_assets.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

370 lines
17 KiB
C++

#include "engine/community_models/whistle_asr/assets.h"
#include "engine/community_models/whistle_asr/encoder.h"
#include "engine/community_models/whistle_asr/frontend.h"
#include "engine/community_models/whistle_asr/runtime.h"
#include "engine/framework/audio/wav_reader.h"
#include "engine/framework/core/execution_context.h"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <limits>
#include <sstream>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
namespace {
namespace whistle = engine::community_models::whistle_asr;
struct Options {
std::string gguf;
std::string wav;
std::string expected;
bool full = false;
bool use_neon = false;
std::string reference_dir;
std::string dump_dir;
// Allowed max |difference| as a fraction of the reference tensor's largest magnitude.
double tolerance = 2.0e-3;
engine::core::BackendConfig backend{engine::core::BackendType::Cpu, 0, 4};
};
Options parse(int argc, char ** argv) {
Options options;
std::vector<std::string> positional;
for (int index = 1; index < argc; ++index) {
const std::string argument = argv[index];
const auto value = [&]() -> std::string {
if (index + 1 >= argc) {
throw std::invalid_argument(argument + " needs a value");
}
return argv[++index];
};
if (argument == "--full") {
options.full = true;
} else if (argument == "--reference") {
options.reference_dir = value();
} else if (argument == "--dump") {
options.dump_dir = value();
} else if (argument == "--backend") {
const std::string backend = value();
if (backend == "cpu") {
options.backend.type = engine::core::BackendType::Cpu;
} else if (backend == "metal") {
options.backend.type = engine::core::BackendType::Metal;
} else {
throw std::invalid_argument("Unsupported test backend: " + backend);
}
} else if (argument == "--threads") {
options.backend.threads = std::stoi(value());
} else if (argument == "--decoder-impl") {
const auto impl = value();
if (impl != "native" && impl != "neon") throw std::invalid_argument("Unknown decoder implementation: " + impl);
options.use_neon = impl == "neon";
} else if (argument == "--tolerance") {
options.tolerance = std::stod(value());
} else {
positional.push_back(argument);
}
}
if (positional.empty() || positional.size() > 3) {
throw std::invalid_argument(
"Pass <GGUF> [16k-mono.wav] [expected transcript] [--full] "
"[--reference <dump-dir>] [--tolerance <fraction>] [--dump <dir>] (encoder tensors and decoder logits/tokens/transcript) [--backend cpu|metal] [--threads n] [--decoder-impl native|neon]");
}
options.gguf = positional[0];
if (positional.size() > 1) options.wav = positional[1];
if (positional.size() > 2) options.expected = positional[2];
return options;
}
engine::runtime::AudioBuffer read_audio(const std::string & path, bool full) {
auto wave = engine::audio::read_wav_f32(std::filesystem::path(path));
if (wave.sample_rate != 16000 || wave.channels != 1) {
throw std::invalid_argument("Whistle test requires 16 kHz mono WAV");
}
if (!full) {
wave.samples.resize(std::min<size_t>(wave.samples.size(), 3 * 16000));
}
engine::runtime::AudioBuffer audio;
audio.sample_rate = wave.sample_rate;
audio.channels = wave.channels;
audio.samples = std::move(wave.samples);
return audio;
}
std::vector<float> read_f32(const std::string & path) {
std::ifstream stream(path, std::ios::binary | std::ios::ate);
const auto bytes = static_cast<std::streamoff>(stream.tellg());
if (!stream || bytes < 0 || bytes % sizeof(float) != 0) {
throw std::runtime_error("Invalid float reference: " + path);
}
std::vector<float> values(static_cast<size_t>(bytes) / sizeof(float));
stream.seekg(0);
if (!stream.read(reinterpret_cast<char *>(values.data()),
static_cast<std::streamsize>(values.size() * sizeof(float)))) {
throw std::runtime_error("Cannot read float reference: " + path);
}
return values;
}
// Reports the largest absolute difference against a reference dump, scaled by the
// reference's largest magnitude, and fails when that fraction exceeds the tolerance.
void compare(const std::string & name, const std::vector<float> & actual, const std::string & path, double tolerance) {
const auto expected = read_f32(path);
if (expected.size() != actual.size()) {
throw std::runtime_error(name + " reference has " + std::to_string(expected.size()) +
" values, runtime produced " + std::to_string(actual.size()));
}
double max_abs = 0.0;
double scale = 0.0;
for (size_t index = 0; index < actual.size(); ++index) {
if (!std::isfinite(actual[index]) || !std::isfinite(expected[index])) {
throw std::runtime_error(name + " has a non-finite value at index " + std::to_string(index));
}
max_abs = std::max(max_abs, std::abs(static_cast<double>(actual[index]) - expected[index]));
scale = std::max(scale, std::abs(static_cast<double>(expected[index])));
}
const double fraction = scale > 0.0 ? max_abs / scale : max_abs;
std::cout << name << ": max_abs=" << max_abs << " reference_max=" << scale
<< " fraction=" << fraction << "\n";
if (!(fraction <= tolerance)) {
throw std::runtime_error(name + " differs from the reference beyond the tolerance");
}
}
void write_f32(const std::string & path, const std::vector<float> & values) {
std::ofstream stream(path, std::ios::binary);
if (!stream.write(reinterpret_cast<const char *>(values.data()),
static_cast<std::streamsize>(values.size() * sizeof(float)))) {
throw std::runtime_error("Cannot write float dump: " + path);
}
}
// Frontend and encoder tensors of the full recording, either written as raw
// little-endian f32 dumps (--dump) or compared against such dumps (--reference).
void check_encoder_tensors(const Options & options, const std::shared_ptr<const whistle::WhistleAssets> & assets,
engine::core::ExecutionContext & execution_context) {
const auto audio = read_audio(options.wav, true);
const whistle::WhistleFrontend frontend(assets->mel_filterbank);
const auto mel = frontend.extract(audio.samples);
whistle::WhistleEncoderRuntime encoder(assets, execution_context);
auto start = std::chrono::steady_clock::now();
const auto output = encoder.encode(mel);
const double first_ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - start).count();
start = std::chrono::steady_clock::now();
(void)encoder.encode(mel);
const double warm_ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - start).count();
std::cout << "encoder: " << output.frames << " frames, first " << first_ms << " ms, warm " << warm_ms << " ms\n";
const std::vector<std::pair<std::string, const std::vector<float> *>> tensors = {
{"mel", &mel.values},
{"memory", &output.memory},
{"cross_k_0", &output.cross_k[0]},
{"cross_v_0", &output.cross_v[0]},
{"cross_k_7", &output.cross_k[7]},
{"cross_v_7", &output.cross_v[7]},
};
if (!options.dump_dir.empty()) {
for (const auto & [name, values] : tensors) {
write_f32(options.dump_dir + "/" + name + ".f32", *values);
}
std::cout << "Wrote " << tensors.size() << " reference dumps to " << options.dump_dir << "\n";
}
if (!options.reference_dir.empty()) {
for (const auto & [name, values] : tensors) {
compare(name, *values, options.reference_dir + "/" + name + ".f32", options.tolerance);
}
}
}
std::vector<int32_t> read_i32(const std::string & path) {
std::ifstream stream(path, std::ios::binary | std::ios::ate);
const auto bytes = static_cast<std::streamoff>(stream.tellg());
if (!stream || bytes < 0 || bytes % sizeof(int32_t) != 0) {
throw std::runtime_error("Invalid token reference: " + path);
}
std::vector<int32_t> values(static_cast<size_t>(bytes) / sizeof(int32_t));
stream.seekg(0);
if (!stream.read(reinterpret_cast<char *>(values.data()),
static_cast<std::streamsize>(values.size() * sizeof(int32_t)))) {
throw std::runtime_error("Cannot read token reference: " + path);
}
return values;
}
std::string read_text(const std::string & path) {
std::ifstream stream(path, std::ios::binary);
if (!stream) {
throw std::runtime_error("Cannot read transcript reference: " + path);
}
std::ostringstream contents;
contents << stream.rdbuf();
return contents.str();
}
// Per-step decoder logits (decoder_logits_<position>.f32), the input token sequence
// (decoder_tokens.i32, BOS and language token first) and the transcript of the full
// recording, written by --dump or required to match by --reference.
void check_decoder(const Options & options, const std::shared_ptr<const whistle::WhistleAssets> & assets,
engine::core::ExecutionContext & execution_context) {
const auto audio = read_audio(options.wav, true);
whistle::WhistleRuntime runtime(assets, execution_context, options.use_neon);
std::vector<whistle::WhistleDecodeStep> steps;
const auto transcript = runtime.transcribe(audio, "", [&](const whistle::WhistleDecodeStep & step) {
steps.push_back(step);
});
std::vector<int32_t> tokens;
for (const auto & step : steps) {
tokens.push_back(step.input_token);
}
const auto logits_path = [](const std::string & dir, size_t position) {
return dir + "/decoder_logits_" + std::to_string(position) + ".f32";
};
if (!options.dump_dir.empty()) {
for (const auto & step : steps) {
write_f32(logits_path(options.dump_dir, step.position), step.logits);
}
std::ofstream token_stream(options.dump_dir + "/decoder_tokens.i32", std::ios::binary);
if (!token_stream.write(reinterpret_cast<const char *>(tokens.data()),
static_cast<std::streamsize>(tokens.size() * sizeof(int32_t)))) {
throw std::runtime_error("Cannot write decoder tokens");
}
std::ofstream text_stream(options.dump_dir + "/decoder_transcript.txt", std::ios::binary);
if (!text_stream.write(transcript.text.data(), static_cast<std::streamsize>(transcript.text.size()))) {
throw std::runtime_error("Cannot write decoder transcript");
}
}
if (!options.reference_dir.empty()) {
if (read_i32(options.reference_dir + "/decoder_tokens.i32") != tokens) {
throw std::runtime_error("Decoder token sequence differs from the reference");
}
if (read_text(options.reference_dir + "/decoder_transcript.txt") != transcript.text) {
throw std::runtime_error("Decoder transcript differs from the reference");
}
for (const auto & step : steps) {
compare("decoder_logits_" + std::to_string(step.position), step.logits,
logits_path(options.reference_dir, step.position), options.tolerance);
}
}
std::cout << "decoder: " << steps.size() << " steps, transcript: " << transcript.text << "\n";
}
void check_transcription(const Options & options, const std::shared_ptr<const whistle::WhistleAssets> & assets,
engine::core::ExecutionContext & execution_context) {
const auto audio = read_audio(options.wav, options.full);
const auto start = std::chrono::steady_clock::now();
whistle::WhistleRuntime runtime(assets, execution_context, options.use_neon);
const auto transcript = runtime.transcribe(audio);
std::cout << "Whistle: " << transcript.text << " [" << transcript.language << "] in "
<< std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start).count() << " ms\n";
if (!options.expected.empty() && transcript.text != options.expected) {
throw std::runtime_error("Whistle transcript differs from the expected speech");
}
const auto repeated = runtime.transcribe(audio);
if (repeated.text != transcript.text || repeated.language != transcript.language) {
throw std::runtime_error("Whistle retained decoder state across recordings");
}
auto quiet = audio;
for (float & sample : quiet.samples) {
sample *= 0.0005f;
}
if (runtime.transcribe(quiet).text.rfind("Some call me", 0) != 0) {
throw std::runtime_error("Whistle dropped quiet speech despite front-end AGC");
}
auto constant_rms = audio;
constant_rms.samples.resize(std::min<size_t>(constant_rms.samples.size(), 3 * 16000));
constexpr size_t kRmsBlockSamples = 320;
constexpr float kTargetRms = 1000.0f / 32768.0f;
for (size_t offset = 0; offset < constant_rms.samples.size(); offset += kRmsBlockSamples) {
const size_t count = std::min(kRmsBlockSamples, constant_rms.samples.size() - offset);
double sum_squares = 0.0;
for (size_t index = 0; index < count; ++index) {
const double sample = constant_rms.samples[offset + index];
sum_squares += sample * sample;
}
const float rms = static_cast<float>(std::sqrt(sum_squares / count));
if (rms > 0.0f) {
const float scale = kTargetRms / rms;
for (size_t index = 0; index < count; ++index) {
constant_rms.samples[offset + index] *= scale;
}
}
}
if (runtime.transcribe(constant_rms).text != "Some call me nature.") {
throw std::runtime_error("Whistle dropped speech with constant block RMS");
}
const auto silence = runtime.transcribe(
engine::runtime::AudioBuffer{16000, 1, std::vector<float>(16000, 0.0f)});
if (!silence.text.empty() || !silence.language.empty()) {
throw std::runtime_error("Whistle invented words for silence");
}
bool rejected = false;
try {
(void)runtime.transcribe(engine::runtime::AudioBuffer{
16000, 1, std::vector<float>(30 * 16000 + 1, 0.0f)});
} catch (const std::invalid_argument &) {
rejected = true;
}
if (!rejected) {
throw std::runtime_error("Whistle accepted audio longer than 30 seconds");
}
rejected = false;
try {
(void)runtime.transcribe(engine::runtime::AudioBuffer{
16000, 1, std::vector<float>(1, std::numeric_limits<float>::quiet_NaN())});
} catch (const std::invalid_argument &) {
rejected = true;
}
if (!rejected) {
throw std::runtime_error("Whistle accepted a non-finite short recording");
}
}
} // namespace
int main(int argc, char ** argv) {
try {
const Options options = parse(argc, argv);
const auto assets = whistle::load_whistle_assets(options.gguf);
if (assets->mel_filterbank.size() != 257 * 80 ||
assets->hadamard_permutations[0][0] != 396 ||
assets->hadamard_permutations[1][0] != 458 ||
whistle::decode_whistle_tokens(assets->tokenizer_pieces, {3633, 1313, 409, 8116}) != " Some call me.") {
throw std::runtime_error("Whistle GGUF assets do not match the reference model");
}
std::cout << "Loaded " << assets->weights->tensors().size()
<< " GGUF tensors and " << assets->tokenizer_pieces.size()
<< " tokenizer pieces\n";
if (options.wav.empty()) {
if (!options.reference_dir.empty() || !options.dump_dir.empty()) {
throw std::invalid_argument("--dump and --reference need a WAV file");
}
return 0;
}
engine::core::ExecutionContext execution_context(options.backend);
if (!options.reference_dir.empty() || !options.dump_dir.empty()) {
if (!options.reference_dir.empty() && !options.dump_dir.empty() &&
std::filesystem::weakly_canonical(options.reference_dir) ==
std::filesystem::weakly_canonical(options.dump_dir)) {
throw std::invalid_argument("--dump would overwrite the --reference directory");
}
check_encoder_tensors(options, assets, execution_context);
check_decoder(options, assets, execution_context);
return 0;
}
check_transcription(options, assets, execution_context);
return 0;
} catch (const std::exception & error) {
std::cerr << error.what() << '\n';
return 1;
}
}