Files
LocalAI/backend/cpp/audio-cpp/audio_io_ctest.cpp
Ettore Di Giacinto 6ee95ab01b backend(audio-cpp): serve the AudioTranscription RPC
Adds result_map, the engine-to-proto boundary, and wires the offline
transcription RPC.

The handler branches on the ROUTED task: for Asr the request's prompt is
whisper-style decoding context and becomes a request option, for Alignment
the same field IS the transcript to align and becomes the text input.
Routing has already decided which.

The result text is TaskResult.text_output verbatim and is never derived
from the segments. audio.cpp carries transcript text in text_output and
nowhere else, so deriving it returns an empty transcript for every
producer that reports segments without word timing. transcript_assembly
already enforces that; this commit's job is not to undo it at the proto
boundary, and result_map_ctest pins it there.

read_audio_file now takes the sample rate the caller needs. Both file-fed
speech handlers ask for 16 kHz mono, for two reasons: silero_vad and
sortformer_diar refuse anything else outright, which turned an ordinary
44.1 kHz upload into INTERNAL, and nemotron_asr emits word timestamps in
its own 16 kHz feature domain whatever the input was, so only a 16 kHz
buffer makes the emitted nanoseconds right. Zero keeps the file's native
rate and channels, which is what source separation will need.

LoadedModel::check_can_serve answers a capability refusal before the lane
is taken and before the input file is read. Routing is a pure read of the
immutable capabilities, so a model that cannot serve an RPC no longer
waits out somebody else's run to say so. VAD and Diarize use it too.

Assisted-by: Claude:claude-opus-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-26 16:11:45 +00:00

169 lines
6.3 KiB
C++

// Tests for audio_io's reading contract, and in particular for the resampling
// that keeps a 44.1 or 48 kHz upload from reaching a family that only accepts
// 16 kHz.
//
// NAMED _ctest AND NOT _test ON PURPOSE: see the note at the top of
// result_map_ctest.cpp. This file links the audio.cpp engine, so it is built
// and run by ctest, not by backend/cpp/run-unit-tests.sh.
//
// make -C backend/cpp/audio-cpp test-engine
#include "audio_io.h"
#include "loaded_model.h"
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <filesystem>
#include <string>
#include <vector>
static int failures = 0;
static void check(bool ok, const std::string &name) {
if (!ok) {
failures++;
fprintf(stderr, "FAIL: %s\n", name.c_str());
} else {
fprintf(stderr, "ok: %s\n", name.c_str());
}
}
using namespace audiocpp_backend;
// A one-second tone, interleaved across `channels`. Real audio rather than
// silence so a resample that dropped its input would be visible as a flat
// buffer, not just as a different length.
static engine::runtime::AudioBuffer tone(int sample_rate, int channels,
float seconds) {
engine::runtime::AudioBuffer buffer;
buffer.sample_rate = sample_rate;
buffer.channels = channels;
const auto frames =
static_cast<size_t>(static_cast<double>(sample_rate) * seconds);
buffer.samples.reserve(frames * static_cast<size_t>(channels));
for (size_t frame = 0; frame < frames; ++frame) {
const float value = 0.5f * std::sin(2.0f * 3.14159265f * 220.0f *
static_cast<float>(frame) /
static_cast<float>(sample_rate));
for (int channel = 0; channel < channels; ++channel) {
buffer.samples.push_back(value);
}
}
return buffer;
}
static float peak(const std::vector<float> &samples) {
float highest = 0.0f;
for (const float sample : samples) {
highest = std::max(highest, std::abs(sample));
}
return highest;
}
static std::filesystem::path scratch_dir() {
const auto dir = std::filesystem::temp_directory_path() / "audiocpp-io-ctest";
std::filesystem::create_directories(dir);
return dir;
}
// The I2 fixture. Before the resample this returned a 44.1 kHz buffer, which
// silero_vad and sortformer_diar both reject with a plain runtime_error, which
// the server maps to INTERNAL. A 44.1 kHz WAV is an ordinary upload.
static void test_441k_stereo_is_read_as_16k_mono() {
const auto path = scratch_dir() / "input-44100-stereo.wav";
write_audio_file(path.string(), tone(44100, 2, 1.0f));
const auto audio = read_audio_file(path.string(), 16000);
check(audio.sample_rate == 16000, "44.1 kHz input is resampled to 16 kHz");
check(audio.channels == 1, "stereo input is downmixed to mono");
// Linear resampling lands within a sample or two of the exact ratio.
const auto frames = static_cast<long long>(audio.samples.size());
check(frames > 15990 && frames < 16010,
"one second in stays one second out");
check(peak(audio.samples) > 0.2f,
"the resampled buffer still carries the signal");
}
static void test_48k_is_read_as_16k() {
const auto path = scratch_dir() / "input-48000-mono.wav";
write_audio_file(path.string(), tone(48000, 1, 0.5f));
const auto audio = read_audio_file(path.string(), 16000);
check(audio.sample_rate == 16000, "48 kHz input is resampled to 16 kHz");
const auto frames = static_cast<long long>(audio.samples.size());
check(frames > 7990 && frames < 8010, "half a second in, half a second out");
}
// The common case: the upload is already 16 kHz mono, and nothing is resampled.
static void test_16k_mono_passes_through_unchanged() {
const auto path = scratch_dir() / "input-16000-mono.wav";
const auto source = tone(16000, 1, 1.0f);
write_audio_file(path.string(), source);
const auto audio = read_audio_file(path.string(), 16000);
check(audio.sample_rate == 16000, "16 kHz stays 16 kHz");
check(audio.channels == 1, "mono stays mono");
check(audio.samples.size() == source.samples.size(),
"a matching rate resamples nothing");
}
// Rate 0 means "give me the file as it is", which is what a source separation
// route needs: demucs and roformer refuse anything but their own 44.1 kHz and
// work on stereo, so the reader must not force them to 16 kHz mono.
static void test_zero_target_keeps_the_native_format() {
const auto path = scratch_dir() / "input-native.wav";
write_audio_file(path.string(), tone(44100, 2, 0.25f));
const auto audio = read_audio_file(path.string(), 0);
check(audio.sample_rate == 44100, "a zero target keeps the file's rate");
check(audio.channels == 2, "a zero target keeps the file's channels");
}
static void test_missing_file_is_a_config_error() {
bool threw_config_error = false;
try {
read_audio_file((scratch_dir() / "does-not-exist.wav").string(), 16000);
} catch (const ConfigError &) {
threw_config_error = true;
} catch (const std::exception &) {
// Any other type maps to INTERNAL, which is what this asserts against.
}
check(threw_config_error, "a missing input file is INVALID_ARGUMENT, not INTERNAL");
}
static void test_unreadable_file_is_a_config_error() {
const auto path = scratch_dir() / "not-a-wav.wav";
{
FILE *file = fopen(path.string().c_str(), "wb");
if (file != nullptr) {
fputs("this is not a RIFF header", file);
fclose(file);
}
}
bool threw_config_error = false;
try {
read_audio_file(path.string(), 16000);
} catch (const ConfigError &) {
threw_config_error = true;
} catch (const std::exception &) {
}
check(threw_config_error, "a non-WAV input is INVALID_ARGUMENT, not INTERNAL");
}
int main() {
test_441k_stereo_is_read_as_16k_mono();
test_48k_is_read_as_16k();
test_16k_mono_passes_through_unchanged();
test_zero_target_keeps_the_native_format();
test_missing_file_is_a_config_error();
test_unreadable_file_is_a_config_error();
if (failures) {
fprintf(stderr, "%d check(s) failed\n", failures);
return 1;
}
fprintf(stderr, "all audio_io checks passed\n");
return 0;
}