voice_audio_multi/tests/unittests/test_streaming_audio_input.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

304 lines
12 KiB
C++

#include "../../app/streaming/pcm_source.h"
#include "../../app/streaming/streaming.h"
#include "test_assert.h"
#include <cstdint>
#include <iostream>
#include <sstream>
#include <string>
#include <vector>
namespace {
using engine::test::require;
using engine::test::require_eq;
// Records what the streaming driver pushes, so a live source and a materialized buffer can be
// compared chunk for chunk.
class RecordingSession : public engine::runtime::IStreamingVoiceTaskSession {
public:
struct Chunk {
int sample_rate = 0;
int channels = 0;
int64_t start_sample = 0;
std::vector<float> samples;
};
explicit RecordingSession(int64_t chunk_samples) : chunk_samples_(chunk_samples) {}
std::string family() const override { return "recording"; }
engine::runtime::VoiceTaskKind task_kind() const override {
return engine::runtime::VoiceTaskKind::Asr;
}
engine::runtime::RunMode run_mode() const override {
return engine::runtime::RunMode::Streaming;
}
void prepare(const engine::runtime::SessionPreparationRequest &) override { prepared_ = true; }
engine::runtime::StreamingPolicy streaming_policy() const override {
engine::runtime::StreamingPolicy policy;
policy.input = engine::runtime::StreamingInputKind::AudioChunks;
policy.output = engine::runtime::StreamingOutputKind::FinalResult;
policy.preferred_audio_chunk_samples = chunk_samples_;
return policy;
}
void start_stream(const engine::runtime::TaskRequest &) override {
started_ = true;
chunks.clear();
}
void reset() override { chunks.clear(); }
engine::runtime::StreamEvent process_audio_chunk(const engine::runtime::AudioChunk & chunk) override {
chunks.push_back(Chunk{chunk.sample_rate, chunk.channels, chunk.start_sample, chunk.samples});
return {};
}
engine::runtime::TaskResult finalize() override {
finalized_ = true;
engine::runtime::TaskResult result;
result.text_output = engine::runtime::Transcript{"done", ""};
return result;
}
bool prepared() const { return prepared_; }
bool started() const { return started_; }
bool finalized() const { return finalized_; }
std::vector<Chunk> chunks;
private:
int64_t chunk_samples_ = 0;
bool prepared_ = false;
bool started_ = false;
bool finalized_ = false;
};
std::string s16le_bytes(const std::vector<int16_t> & values) {
std::string bytes;
bytes.reserve(values.size() * 2);
for (const int16_t value : values) {
const auto raw = static_cast<uint16_t>(value);
bytes.push_back(static_cast<char>(raw & 0xFF));
bytes.push_back(static_cast<char>((raw >> 8) & 0xFF));
}
return bytes;
}
engine::runtime::TaskRequest audio_request(engine::runtime::AudioBuffer audio) {
engine::runtime::TaskRequest request;
request.audio_input = std::move(audio);
return request;
}
// Every case here runs at 16 kHz; only the channel count and sample encoding vary.
engine::runtime::AudioBuffer audio_format(int channels = 1) {
engine::runtime::AudioBuffer buffer;
buffer.sample_rate = 16000;
buffer.channels = channels;
return buffer;
}
minitts::app::AudioChunkStream pcm_stream(
std::istream & input,
int channels = 1,
minitts::app::PcmSampleFormat format = minitts::app::PcmSampleFormat::S16LE) {
return minitts::app::make_pcm_chunk_stream(
input, minitts::app::AudioStreamFormat{16000, channels}, format);
}
std::vector<float> flatten(const std::vector<RecordingSession::Chunk> & chunks) {
std::vector<float> all;
for (const auto & chunk : chunks) {
all.insert(all.end(), chunk.samples.begin(), chunk.samples.end());
}
return all;
}
// The whole point of the source abstraction: a live stdin-style source and a fully materialized
// buffer must drive the session identically, so transcripts cannot diverge between the two.
void test_stdin_source_matches_buffer_source() {
std::vector<int16_t> pcm(2500);
for (size_t i = 0; i < pcm.size(); ++i) {
pcm[i] = static_cast<int16_t>((i * 37) % 30000 - 15000);
}
auto buffer = audio_format();
buffer.samples.reserve(pcm.size());
for (const int16_t value : pcm) {
buffer.samples.push_back(static_cast<float>(value) / 32768.0F);
}
RecordingSession buffered(512);
minitts::app::run_streaming_task(buffered, audio_request(buffer), nullptr);
RecordingSession streamed(512);
std::istringstream input(s16le_bytes(pcm));
const auto stream = pcm_stream(input);
minitts::app::run_streaming_task(streamed, audio_request(buffer), nullptr, stream);
require_eq(streamed.chunks.size(), buffered.chunks.size(), "chunk count");
for (size_t i = 0; i < buffered.chunks.size(); ++i) {
const auto & expected = buffered.chunks[i];
const auto & actual = streamed.chunks[i];
const std::string label = "chunk " + std::to_string(i);
require_eq(actual.sample_rate, expected.sample_rate, label + " sample rate");
require_eq(actual.channels, expected.channels, label + " channels");
require_eq(actual.start_sample, expected.start_sample, label + " start sample");
require_eq(actual.samples.size(), expected.samples.size(), label + " size");
for (size_t j = 0; j < expected.samples.size(); ++j) {
require(
actual.samples[j] == expected.samples[j],
label + " sample " + std::to_string(j) + " differs");
}
}
require(streamed.started(), "stream was not started");
require(streamed.finalized(), "stream was not finalized");
}
// The driver must honour the session's preferred chunk size and drain the source to EOF,
// including a final short chunk.
void test_chunk_sizing_and_drain() {
const int64_t chunk_samples = 400;
std::vector<int16_t> pcm(1000); // 2 full chunks + a 200-sample tail
for (size_t i = 0; i < pcm.size(); ++i) {
pcm[i] = static_cast<int16_t>(i);
}
RecordingSession session(chunk_samples);
std::istringstream input(s16le_bytes(pcm));
const auto stream = pcm_stream(input);
minitts::app::run_streaming_task(session, audio_request(audio_format()), nullptr, stream);
require_eq(session.chunks.size(), size_t{3}, "chunk count");
require_eq(session.chunks[0].samples.size(), size_t{400}, "first chunk size");
require_eq(session.chunks[1].samples.size(), size_t{400}, "second chunk size");
require_eq(session.chunks[2].samples.size(), size_t{200}, "tail chunk size");
require_eq(session.chunks[0].start_sample, int64_t{0}, "first start sample");
require_eq(session.chunks[1].start_sample, int64_t{400}, "second start sample");
require_eq(session.chunks[2].start_sample, int64_t{800}, "tail start sample");
require_eq(flatten(session.chunks).size(), pcm.size(), "total samples delivered");
}
// Decoding is explicitly little-endian so a stream captured on one machine reads back the same
// on any host, and matches the scaling the WAV reader uses.
void test_sample_format_decoding() {
const std::string s16 = s16le_bytes({0, 32767, -32768, 1234});
std::istringstream s16_input(s16);
std::vector<float> decoded;
auto s16_stream = pcm_stream(s16_input);
s16_stream.read(4, decoded);
require_eq(decoded.size(), size_t{4}, "s16le sample count");
require(decoded[0] == 0.0F, "s16le zero");
require(decoded[1] == 32767.0F / 32768.0F, "s16le positive full scale");
require(decoded[2] == -1.0F, "s16le negative full scale");
require(decoded[3] == 1234.0F / 32768.0F, "s16le midrange");
// 1.0f and -2.0f little-endian.
const std::string f32("\x00\x00\x80\x3F\x00\x00\x00\xC0", 8);
std::istringstream f32_input(f32);
auto f32_stream = pcm_stream(f32_input, 1, minitts::app::PcmSampleFormat::F32LE);
f32_stream.read(2, decoded);
require_eq(decoded.size(), size_t{2}, "f32le sample count");
require(decoded[0] == 1.0F, "f32le 1.0");
require(decoded[1] == -2.0F, "f32le -2.0");
}
// A trailing partial frame cannot be interpreted, so chunks must always hold whole frames -
// sessions reject a chunk whose sample count is not divisible by the channel count.
void test_partial_trailing_frame_is_dropped() {
const std::string bytes = s16le_bytes({1, 2, 3}); // 1.5 stereo frames
std::istringstream input(bytes);
auto stream = pcm_stream(input, 2);
std::vector<float> samples;
const bool more = stream.read(8, samples);
require(!more, "short read should report end of stream");
require_eq(samples.size(), size_t{2}, "partial trailing frame must be dropped");
}
void test_empty_stream_produces_no_chunks() {
RecordingSession session(256);
std::istringstream input("");
const auto stream = pcm_stream(input);
minitts::app::run_streaming_task(session, audio_request(audio_format()), nullptr, stream);
require(session.chunks.empty(), "empty stream must not produce chunks");
require(session.finalized(), "empty stream must still finalize");
}
// A live source declares its format in audio_input and carries no samples there. The buffer
// overload must not mistake that for zero-length audio and feed the session nothing.
void test_live_format_contract_is_not_mistaken_for_empty_audio() {
RecordingSession session(512);
bool rejected = false;
try {
minitts::app::run_streaming_task(session, audio_request(audio_format()), nullptr);
} catch (const std::exception &) {
rejected = true;
}
require(rejected, "a format-only audio_input must be rejected, not streamed as empty audio");
require(session.chunks.empty(), "no chunks should have been delivered");
}
// The buffer length is known up front, so a malformed buffer must be rejected before the session
// sees any of it rather than partway through on the final short chunk.
void test_malformed_buffer_is_rejected_before_any_chunk() {
RecordingSession session(400);
auto audio = audio_format(2);
audio.samples.assign(1001, 0.5F); // not a whole number of stereo frames
bool rejected = false;
try {
minitts::app::run_streaming_task(session, audio_request(audio), nullptr);
} catch (const std::exception &) {
rejected = true;
}
require(rejected, "a buffer that is not divisible by channels must be rejected");
require(session.chunks.empty(), "no chunks should have been delivered before the rejection");
}
void test_format_parsing_and_validation() {
require(
minitts::app::parse_pcm_sample_format("s16le") == minitts::app::PcmSampleFormat::S16LE,
"s16le parse");
require(
minitts::app::parse_pcm_sample_format("f32le") == minitts::app::PcmSampleFormat::F32LE,
"f32le parse");
bool rejected = false;
try {
(void)minitts::app::parse_pcm_sample_format("s24le");
} catch (const std::exception &) {
rejected = true;
}
require(rejected, "unsupported sample format must be rejected");
std::istringstream input("");
bool bad_format_rejected = false;
try {
(void)minitts::app::make_pcm_chunk_stream(
input,
minitts::app::AudioStreamFormat{0, 1},
minitts::app::PcmSampleFormat::S16LE);
} catch (const std::exception &) {
bad_format_rejected = true;
}
require(bad_format_rejected, "non-positive sample rate must be rejected");
}
} // namespace
int main() {
try {
test_stdin_source_matches_buffer_source();
test_chunk_sizing_and_drain();
test_sample_format_decoding();
test_partial_trailing_frame_is_dropped();
test_empty_stream_produces_no_chunks();
test_live_format_contract_is_not_mistaken_for_empty_audio();
test_malformed_buffer_is_rejected_before_any_chunk();
test_format_parsing_and_validation();
} catch (const std::exception & error) {
std::cerr << "streaming_audio_input_test failed: " << error.what() << '\n';
return 1;
}
std::cout << "streaming_audio_input_test passed\n";
return 0;
}