voice_audio_multi/tests/lfm2_audio/test_lfm2_audio_s2s.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

933 lines
46 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// LFM2.5-Audio speech-to-speech (s2s) on Liquid's published GGUFs against
// liquid-audio 1.3.0 (LFM2.5-Audio-1.5B revision
// c362a0625dfe45aa588dce5f0ada28a7e5707628, fp32), for assets/resources/c.wav
// ("Oh hi, I'm Linashi Mumei from Whole Life English Council. ...") under the
// chat system prompt:
// - the prompt ChatState builds around the audio, and the interleave blocks;
// - generate_interleaved's first text block and first audio block, greedy:
// the text logits, the backbone output after the text and the first
// frame's depthformer logits;
// - replies through the registry, offline and streamed, whose audio says what
// their text says, checked by transcribing it back with the ASR task;
// - a reply cut off at max_tokens, offline and streamed;
// - the first request again after the others, which must give the same reply;
// - text sampling, which follows the greedy-text reply of the same seed until
// a text token differs;
// - a conversation: the first reply returned as an lfm2_audio.reply artifact,
// which holds its text and speech, and a second turn (assets/resources/a.wav)
// with the first turn as history, which changes its reply, repeats to the
// byte and returns an artifact a third turn replays.
//
// --model is the directory of LiquidAI/LFM2.5-Audio-1.5B-GGUF (default
// models/LFM2.5-Audio-1.5B-GGUF, where the lfm2_audio_1_5b_* packages install);
// --model-gguf picks the backbone, and the mmproj-, vocoder- and tokenizer-
// files of the same name come with it. Skips with 125 when the files are not
// there.
#include "engine/community_models/lfm2_audio/asr_inputs.h"
#include "engine/community_models/lfm2_audio/assets.h"
#include "engine/community_models/lfm2_audio/audio_encoder.h"
#include "engine/community_models/lfm2_audio/backbone.h"
#include "engine/community_models/lfm2_audio/chat.h"
#include "engine/community_models/lfm2_audio/depthformer.h"
#include "engine/community_models/lfm2_audio/interleaved.h"
#include "engine/community_models/lfm2_audio/session.h"
#include "engine/community_models/lfm2_audio/tokenizer.h"
#include "engine/framework/audio/wav_reader.h"
#include "engine/framework/core/execution_context.h"
#include "engine/framework/io/filesystem.h"
#include "engine/framework/runtime/model.h"
#include "engine/framework/runtime/registry.h"
#include "engine/framework/runtime/session.h"
#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstddef>
#include <cstdio>
#include <cstdlib>
#include <exception>
#include <filesystem>
#include <fstream>
#include <functional>
#include <iostream>
#include <iterator>
#include <limits>
#include <sstream>
#include <string>
#include <vector>
#ifndef ENGINE_REPO_ROOT
#define ENGINE_REPO_ROOT "."
#endif
namespace {
namespace lfm2 = engine::community_models::lfm2_audio;
constexpr int kExitPass = 0;
constexpr int kExitFail = 1;
constexpr int kExitSkip = 125;
constexpr const char * kAudio = "assets/resources/c.wav";
// The second turn of a conversation.
constexpr const char * kFollowUp = "assets/resources/a.wav";
// ChatState's text around the audio, and where the audio goes.
const std::vector<int32_t> kPromptIds = {1, 6, 24131, 708, 3104, 4168, 916, 1251, 799, 17927, 3304, 810,
14052, 523, 7, 708, 6, 6423, 708, 7, 708, 6, 64015, 708};
constexpr int64_t kAudioTokens = 95;
constexpr int32_t kFirstAudioPosition = 19;
struct Top {
int32_t id;
float logit;
float second;
};
// The first text block: each greedy token, its logit and the runner-up's.
const Top kTextBlock[] = {
{550, 16.97331f, 16.84728f}, {10109, 15.12635f, 14.32001f}, {26508, 9.75035f, 9.49224f},
{1010, 14.96412f, 13.30494f}, {4343, 14.45033f, 14.36696f}, {9368, 14.02111f, 12.57555f},
};
constexpr const char * kTextBlockText = "I’m glad you’re interested";
struct Point {
int64_t index;
float value;
};
// Backbone output after the text block, the first frame's depthformer input.
const Point kHiddenPoints[] = {{0, 0.064191f}, {17, -0.032250f}, {511, 0.283188f}, {1024, 0.048182f}, {1500, -0.114832f}, {2047, -0.027834f}};
const Top kFirstFrame[] = {
{1049, 22.11196f, 9.33686f}, {811, 17.91690f, 15.03631f}, {1626, 19.42575f, 12.59472f}, {290, 20.41790f, 14.60610f},
{1335, 17.07018f, 15.24617f}, {1350, 19.51537f, 9.71587f}, {666, 16.72897f, 9.72017f}, {1630, 17.04280f, 13.54772f},
};
// The first audio block, greedy. Frame 10 holds a near-tie in codebook 2
// (0.015).
const std::vector<std::vector<int32_t>> kAudioBlock = {
{1049, 811, 1626, 290, 1335, 1350, 666, 1630}, {127, 1470, 457, 1422, 481, 1509, 1978, 1533},
{1880, 91, 1029, 1229, 457, 1030, 43, 1177}, {972, 1050, 1697, 104, 340, 1030, 825, 1744},
{972, 1050, 1559, 104, 457, 11, 819, 1533}, {972, 1050, 457, 290, 457, 1572, 825, 2008},
{972, 1050, 1559, 546, 457, 1572, 43, 2008}, {972, 1050, 457, 290, 340, 1030, 43, 945},
{395, 1050, 1559, 546, 1736, 1720, 666, 1648}, {395, 1050, 1697, 104, 1736, 1030, 1978, 2008},
{1140, 875, 325, 1478, 143, 1556, 1603, 1691}, {1268, 257, 1365, 1414, 35, 565, 1700, 158},
};
// For each codebook of kAudioBlock, how far the reference's best logit is
// above its runner-up.
const float kAudioBlockGaps[][8] = {
{12.7751f, 2.8806f, 6.8310f, 5.8118f, 1.8240f, 9.7995f, 7.0088f, 3.4951f},
{13.0188f, 10.2181f, 11.6306f, 10.7541f, 5.4512f, 10.3253f, 6.8596f, 11.1509f},
{12.1370f, 2.4994f, 5.3629f, 3.7745f, 10.0284f, 10.6857f, 9.5876f, 0.5420f},
{11.2426f, 2.2192f, 1.4951f, 9.6916f, 0.6387f, 3.2996f, 1.0865f, 1.1269f},
{11.1424f, 5.8873f, 5.0426f, 4.4161f, 8.9407f, 2.3557f, 5.2584f, 7.5267f},
{9.5922f, 9.1646f, 6.4173f, 2.1134f, 8.7944f, 7.4509f, 4.9751f, 7.0356f},
{10.6470f, 8.5040f, 4.0465f, 9.5790f, 9.1019f, 0.6501f, 8.9544f, 5.5013f},
{9.1969f, 10.5134f, 2.2397f, 8.8024f, 8.3988f, 2.3821f, 7.6328f, 6.7684f},
{10.4493f, 10.6883f, 8.4089f, 0.2643f, 5.0878f, 9.7272f, 3.2462f, 4.4449f},
{9.4810f, 1.2032f, 1.9796f, 7.9592f, 6.9397f, 5.1225f, 0.2985f, 3.9237f},
{0.8270f, 3.9798f, 0.0147f, 4.0144f, 3.0430f, 3.5802f, 1.4994f, 0.4992f},
{4.3692f, 3.2428f, 3.4653f, 1.9455f, 3.0203f, 0.0587f, 2.3085f, 1.4134f},
};
// A greedy pick may differ from the reference's only where the two picks'
// logits are closer than this and so are the reference's top two; past such a
// near-tie the weights' rounding decides, so the steps after it are not
// compared.
constexpr float kNearTie = 0.05f;
// A spoken reply transcribed back matches its text but for the names the
// model makes up ("Al-Ula" comes back as "Aula"): 4-7% at the median of 30 to
// 60 seeds per backend. Now and then the ASR answers the reply, paraphrases it
// or runs on to max_tokens instead of transcribing it (liquid-audio fp32 went
// over 0.2 on 5 of 60 seeds), and a reply can fail to end its audio within
// max_tokens. So the check takes the median of three replies, and a reply or
// transcript that fails or is cut off at max_tokens counts as a miss.
constexpr double kMaxReplyWordErrors = 0.2;
const std::vector<std::string> kReplySeeds = {"1234", "1235", "1236"};
std::filesystem::path repo_path(const std::string & relative) {
return std::filesystem::path(ENGINE_REPO_ROOT) / relative;
}
std::string arg_value(int argc, char ** argv, const std::string & name, const std::string & fallback) {
for (int i = 1; i + 1 < argc; ++i) {
if (argv[i] == name) {
return argv[i + 1];
}
}
return fallback;
}
std::string join(const std::vector<int32_t> & ids) {
std::ostringstream out;
for (size_t i = 0; i < ids.size(); ++i) {
out << (i == 0 ? "" : ",") << ids[i];
}
return out.str();
}
class Checks {
public:
void expect(bool ok, const std::string & label, const std::string & detail = "") {
std::cout << (ok ? "PASS " : "FAIL ") << label << (ok || detail.empty() ? "" : ": " + detail) << "\n";
failures_ += ok ? 0 : 1;
}
void expect_close(double actual, double expected, double tolerance, const std::string & label) {
std::ostringstream detail;
detail << label << ": got " << actual << ", expected " << expected << " +- " << tolerance;
expect(std::fabs(actual - expected) <= tolerance, detail.str());
}
[[nodiscard]] int failures() const { return failures_; }
private:
int failures_ = 0;
};
int32_t argmax(const std::vector<float> & values) {
return static_cast<int32_t>(std::max_element(values.begin(), values.end()) - values.begin());
}
// Lower-case words; ’ is an apostrophe and anything else neither a letter nor
// a digit separates words.
std::vector<std::string> words(const std::string & text) {
std::vector<std::string> out;
std::string word;
for (size_t i = 0; i < text.size(); ++i) {
const auto byte = static_cast<unsigned char>(text[i]);
if (text.compare(i, 3, "\xE2\x80\x99") == 0) {
word += '\'';
i += 2;
} else if (std::isalnum(byte) != 0 || byte == '\'') {
word += static_cast<char>(std::tolower(byte));
} else if (!word.empty()) {
out.push_back(word);
word.clear();
}
}
if (!word.empty()) {
out.push_back(word);
}
return out;
}
// Whether `text` is well-formed UTF-8: decoding it as the reply's bytes keeps
// it as it is.
bool well_formed(const std::string & text) {
auto bytes = text;
return lfm2::lfm2_take_text(bytes) == text;
}
double word_error_rate(const std::vector<std::string> & reference, const std::vector<std::string> & hypothesis) {
std::vector<size_t> row(hypothesis.size() + 1);
for (size_t j = 0; j < row.size(); ++j) {
row[j] = j;
}
for (size_t i = 1; i <= reference.size(); ++i) {
size_t diagonal = row[0];
row[0] = i;
for (size_t j = 1; j <= hypothesis.size(); ++j) {
const size_t above = row[j];
row[j] = std::min({row[j] + 1, row[j - 1] + 1, diagonal + (reference[i - 1] == hypothesis[j - 1] ? 0 : 1)});
diagonal = above;
}
}
return reference.empty() ? 0.0 : static_cast<double>(row.back()) / static_cast<double>(reference.size());
}
void check_prompt(const lfm2::Lfm2AudioComponents & components, const lfm2::Lfm2AudioOutputComponents & output, Checks & checks) {
const lfm2::Lfm2TextTokenizer tokenizer(components.vocabulary);
const auto prompt = lfm2::make_lfm2_spoken_prompt(tokenizer, lfm2::kLfm2ChatSystemPrompt).with_audio(kAudioTokens);
std::vector<int32_t> text_ids;
for (size_t i = 0; i < prompt.input_ids.size(); ++i) {
if (std::find(prompt.audio_positions.begin(), prompt.audio_positions.end(), static_cast<int32_t>(i)) == prompt.audio_positions.end()) {
text_ids.push_back(prompt.input_ids[i]);
}
}
checks.expect(text_ids == kPromptIds, "chat prompt around the audio", "got " + join(text_ids));
checks.expect(!prompt.audio_positions.empty() && prompt.audio_positions.front() == kFirstAudioPosition, "audio after the user turn opens");
// The English vocoder does not record its blocks; liquid-audio's
// config.json has 6 and 12.
checks.expect(output.interleave.text_steps == 6 && output.interleave.audio_steps == 12, "interleaved blocks of 6 text tokens and 12 frames",
std::to_string(output.interleave.text_steps) + "/" + std::to_string(output.interleave.audio_steps));
}
// F16 weights keep the text logits within 0.15 and the backbone output
// within 0.01 of fp32 on every backend measured.
void check_stage_numbers(
const lfm2::Lfm2AudioComponents & components,
const lfm2::Lfm2AudioOutputComponents & output,
const engine::core::BackendConfig & backend,
Checks & checks) {
engine::core::ExecutionContext execution(backend);
const lfm2::Lfm2TextTokenizer tokenizer(components.vocabulary);
lfm2::Lfm2FastConformerEncoderRuntime encoder(components.mmproj, components.encoder, execution);
lfm2::Lfm2BackboneRuntime backbone(
components.model, components.backbone, execution, components.mmproj, output.depthformer.codebooks, output.depthformer.audio_vocab_size);
lfm2::Lfm2DepthformerRuntime depthformer(output.vocoder, output.depthformer, execution);
const auto wav = engine::audio::read_wav_f32(repo_path(kAudio));
const auto samples = lfm2::lfm2_audio_mono_16k({wav.sample_rate, wav.channels, wav.samples});
const auto audio = encoder.encode(lfm2::Lfm2AudioFeatureExtractor(components.encoder.n_mels, backend.threads).extract(samples));
checks.expect(audio.tokens == kAudioTokens, "audio tokens", std::to_string(audio.tokens));
const auto prompt = lfm2::make_lfm2_spoken_prompt(tokenizer, lfm2::kLfm2ChatSystemPrompt).with_audio(audio.tokens);
// The text block, greedy: text logits in, the next token back.
auto out = backbone.start(prompt, audio, 64, lfm2::Lfm2DecodeCache::Speech);
std::vector<int32_t> block;
for (size_t i = 0; i < std::size(kTextBlock); ++i) {
const auto & top = kTextBlock[i];
const int32_t token = argmax(out);
checks.expect_close(out[static_cast<size_t>(top.id)], top.logit, 0.15, "text step " + std::to_string(i) + " top logit");
if (token != top.id) {
const float gap = out[static_cast<size_t>(token)] - out[static_cast<size_t>(top.id)];
const float reference_gap = top.logit - top.second;
checks.expect(gap < kNearTie && reference_gap < kNearTie, "text step " + std::to_string(i) + " differs only at a near-tie",
std::to_string(token) + " for " + std::to_string(top.id) + " at a gap of " + std::to_string(gap) + ", the reference's " +
std::to_string(reference_gap));
return;
}
block.push_back(token);
const bool last = i + 1 == std::size(kTextBlock);
out = backbone.step_text(token, last ? lfm2::Lfm2StepOutput::Hidden : lfm2::Lfm2StepOutput::Logits);
}
checks.expect(tokenizer.decode(block) == kTextBlockText, "text block", tokenizer.decode(block));
for (const auto & point : kHiddenPoints) {
checks.expect_close(out[static_cast<size_t>(point.index)], point.value, 0.01, "backbone output [" + std::to_string(point.index) + "]");
}
// The audio block, greedy.
std::vector<std::vector<float>> frame_logits;
const auto greedy = [&](int64_t, std::vector<float> & values) {
frame_logits.push_back(values);
return argmax(values);
};
// Whether a frame that differs from the reference's differs only at
// near-ties. Each codebook's step reads the code picked before it, so
// after one codebook differs the later ones see other input. The frame
// is run again with the reference's codes fed in instead: a codebook
// differs when its best code is then not the reference's, and each one
// that does must have the reference's code within kNearTie of the best,
// with the reference's own top two logits as close.
const auto differs_only_at_near_ties = [&](size_t frame, const std::vector<float> & frame_hidden, std::string & detail) {
const auto & expected = kAudioBlock[frame];
std::vector<std::vector<float>> forced;
(void)depthformer.frame(frame_hidden, [&](int64_t codebook, std::vector<float> & values) {
forced.push_back(values);
return expected[static_cast<size_t>(codebook)];
});
size_t differing = 0;
bool near_ties = true;
for (size_t codebook = 0; codebook < forced.size(); ++codebook) {
const auto & values = forced[codebook];
const auto best = static_cast<size_t>(argmax(values));
const auto reference = static_cast<size_t>(expected[codebook]);
if (best == reference) {
continue;
}
const float gap = values[best] - values[reference];
const float reference_gap = kAudioBlockGaps[frame][codebook];
++differing;
near_ties = near_ties && gap < kNearTie && reference_gap < kNearTie;
detail += (differing > 1 ? ", codebook " : "codebook ") + std::to_string(codebook) + " has " + std::to_string(best) +
" for " + std::to_string(reference) + " at a gap of " + std::to_string(gap) + ", the reference's " +
std::to_string(reference_gap);
}
if (differing == 0) {
detail = "no codebook differs with the reference's codes fed in";
}
return differing > 0 && near_ties;
};
size_t compared = 0;
for (size_t frame = 0; frame < kAudioBlock.size(); ++frame) {
if (frame > 0) {
out = backbone.step_audio(kAudioBlock[frame - 1], lfm2::Lfm2StepOutput::Hidden);
}
frame_logits.clear();
const auto codes = depthformer.frame(out, greedy);
if (frame == 0) {
for (size_t codebook = 0; codebook < std::size(kFirstFrame); ++codebook) {
const auto & top = kFirstFrame[codebook];
checks.expect_close(frame_logits[codebook][static_cast<size_t>(top.id)], top.logit, 0.15,
"first frame, codebook " + std::to_string(codebook) + " top logit");
}
}
if (codes != kAudioBlock[frame]) {
std::string detail;
const bool near_ties = differs_only_at_near_ties(frame, out, detail);
checks.expect(near_ties, "greedy frame " + std::to_string(frame) + " differs only at near-ties (" + detail + ")", join(codes));
break;
}
++compared;
}
std::cout << "greedy frames identical: " << compared << " of " << kAudioBlock.size() << "\n";
}
// Sampled text draws from a stream of its own, so a reply with
// text_temperature is step for step the greedy-text reply of the same seed,
// audio codes included, until a text token differs.
void check_text_sampling(
const lfm2::Lfm2AudioComponents & components,
const lfm2::Lfm2AudioOutputComponents & output,
const engine::core::BackendConfig & backend,
Checks & checks) {
engine::core::ExecutionContext execution(backend);
const lfm2::Lfm2TextTokenizer tokenizer(components.vocabulary);
lfm2::Lfm2FastConformerEncoderRuntime encoder(components.mmproj, components.encoder, execution);
lfm2::Lfm2BackboneRuntime backbone(
components.model, components.backbone, execution, components.mmproj, output.depthformer.codebooks, output.depthformer.audio_vocab_size);
lfm2::Lfm2DepthformerRuntime depthformer(output.vocoder, output.depthformer, execution);
const auto wav = engine::audio::read_wav_f32(repo_path(kAudio));
const auto samples = lfm2::lfm2_audio_mono_16k({wav.sample_rate, wav.channels, wav.samples});
const auto audio = encoder.encode(lfm2::Lfm2AudioFeatureExtractor(components.encoder.n_mels, backend.threads).extract(samples));
const auto prompt = lfm2::make_lfm2_spoken_prompt(tokenizer, lfm2::kLfm2ChatSystemPrompt).with_audio(audio.tokens);
// 120 steps hold the first seven text blocks.
const auto reply = [&](uint64_t seed, const lfm2::Lfm2TextSampling & text_sampling) {
lfm2::Lfm2InterleavedOptions options;
options.text_steps = output.interleave.text_steps;
options.audio_steps = output.interleave.audio_steps;
options.max_steps = 120;
options.sampling.seed = seed;
options.text_sampling = text_sampling;
lfm2::Lfm2InterleavedGenerator generator(backbone, depthformer, tokenizer, prompt, audio, output.depthformer.end_of_audio(), options);
std::vector<lfm2::Lfm2ReplyStep> steps;
while (auto step = generator.next()) {
steps.push_back(std::move(*step));
}
return steps;
};
size_t differing = 0;
for (const auto & seed : kReplySeeds) {
const auto greedy = reply(std::stoull(seed), {});
const auto sampled = reply(std::stoull(seed), {1.0f, 0});
size_t first = 0;
while (first < greedy.size() && first < sampled.size() && greedy[first].token == sampled[first].token &&
greedy[first].codes == sampled[first].codes) {
++first;
}
if (first == greedy.size() && first == sampled.size()) {
continue;
}
// A reply that drew <|im_end|> ends at that text step.
const auto text_at_first = [&](const std::vector<lfm2::Lfm2ReplyStep> & steps) {
return first >= steps.size() || steps[first].codes.empty();
};
++differing;
checks.expect(text_at_first(greedy) && text_at_first(sampled), "seed " + seed + ": sampled text first differs at a text token",
"step " + std::to_string(first));
}
checks.expect(differing > 0, "text_temperature 1.0 changes a reply's steps");
}
std::unique_ptr<engine::runtime::IVoiceTaskSession> open_session(
engine::runtime::ILoadedVoiceModel & model,
engine::runtime::VoiceTaskKind task,
const std::string & model_gguf,
const engine::core::BackendConfig & backend,
engine::runtime::RunMode mode = engine::runtime::RunMode::Offline) {
engine::runtime::SessionOptions options;
options.backend = backend;
options.options["lfm2_audio.model_gguf"] = model_gguf;
auto session = model.create_task_session({task, mode}, options);
if (auto * offline = dynamic_cast<engine::runtime::IOfflineVoiceTaskSession *>(session.get())) {
offline->prepare({});
}
return session;
}
engine::runtime::TaskResult run(engine::runtime::IVoiceTaskSession & session, const engine::runtime::TaskRequest & request) {
auto * offline = dynamic_cast<engine::runtime::IOfflineVoiceTaskSession *>(&session);
if (offline == nullptr) {
throw std::runtime_error("the session is not an IOfflineVoiceTaskSession");
}
return offline->run(request);
}
// What `call` writes to std::cerr, where the family's warnings go.
std::string stderr_of(const std::function<void()> & call) {
std::ostringstream captured;
auto * const previous = std::cerr.rdbuf(captured.rdbuf());
try {
call();
} catch (...) {
std::cerr.rdbuf(previous);
throw;
}
std::cerr.rdbuf(previous);
return captured.str();
}
// A file's bytes, such as a question's WAV for an lfm2_audio.question
// artifact.
std::vector<std::byte> file_bytes(const std::filesystem::path & path) {
std::ifstream input(path, std::ios::binary);
const std::vector<char> bytes((std::istreambuf_iterator<char>(input)), std::istreambuf_iterator<char>());
std::vector<std::byte> out(bytes.size());
std::transform(bytes.begin(), bytes.end(), out.begin(), [](char value) { return static_cast<std::byte>(value); });
return out;
}
bool same_reply(const engine::runtime::TaskResult & a, const engine::runtime::TaskResult & b) {
const auto artifact_bytes = [](const engine::runtime::TaskResult & result) {
return result.output_artifacts.empty() ? std::vector<std::byte>() : result.output_artifacts.front().payload;
};
return a.text_output.has_value() && b.text_output.has_value() && a.text_output->text == b.text_output->text && a.audio_output.has_value() &&
b.audio_output.has_value() && a.audio_output->samples == b.audio_output->samples && artifact_bytes(a) == artifact_bytes(b);
}
// Replies through the registry, and their audio back through ASR.
void check_replies(
const std::filesystem::path & model_dir,
const std::filesystem::path & spec_override,
const std::string & model_gguf,
const engine::core::BackendConfig & backend,
bool full_precision,
Checks & checks) {
auto registry = engine::runtime::make_default_registry();
engine::runtime::ModelLoadRequest load_request;
load_request.model_path = model_dir;
load_request.model_spec_override = spec_override;
load_request.family_hint = "lfm2_audio";
auto model = registry.load(load_request);
auto s2s = open_session(*model, engine::runtime::VoiceTaskKind::SpeechToSpeech, model_gguf, backend);
auto asr = open_session(*model, engine::runtime::VoiceTaskKind::Asr, model_gguf, backend);
const auto & chat = dynamic_cast<const lfm2::Lfm2AudioChatSession &>(*s2s);
const auto & transcriber = dynamic_cast<const lfm2::Lfm2AudioSession &>(*asr);
const auto wav = engine::audio::read_wav_f32(repo_path(kAudio));
// The README's sampling, temperature 1.0 and top-k 4, by default.
const auto question = [&](const std::string & seed = kReplySeeds[0]) {
engine::runtime::TaskRequest request;
request.audio_input = engine::runtime::AudioBuffer{wav.sample_rate, wav.channels, wav.samples};
request.options["seed"] = seed;
return request;
};
const auto reply = run(*s2s, question());
const bool reply_cut = chat.reached_max_tokens();
const bool has_audio = reply.audio_output.has_value() && reply.audio_output->sample_rate == 24000 && !reply.audio_output->samples.empty();
const std::string text = reply.text_output.has_value() ? reply.text_output->text : "";
checks.expect(has_audio, "the reply is 24 kHz audio");
checks.expect(!words(text).empty() && text.find("<|") == std::string::npos, "the reply has text without markup", text);
// Text is greedy, and the first block comes before any audio is sampled.
// Quantized weights can pick other words for it: Q4_0 does, and Q8_0 is
// within 0.1 of a tie on some of its tokens.
if (full_precision) {
checks.expect(text.rfind(kTextBlockText, 0) == 0, "the reply starts with the text block");
}
std::cout << "reply: " << text << "\n";
if (has_audio) {
const double seconds = static_cast<double>(reply.audio_output->samples.size()) / 24000.0;
std::cout << "reply speech: " << seconds << " s\n";
checks.expect(seconds > 2.0 && seconds < 80.0, "reply length", std::to_string(seconds));
std::vector<double> errors;
std::string heard_texts;
const auto round_trip = [&](const std::string & seed, const engine::runtime::TaskResult * given) {
std::string spoken_text;
std::string heard_text;
double error = std::numeric_limits<double>::infinity();
try {
const auto spoken = given != nullptr ? *given : run(*s2s, question(seed));
spoken_text = spoken.text_output.has_value() ? spoken.text_output->text : "";
if (given != nullptr ? reply_cut : chat.reached_max_tokens()) {
throw std::runtime_error("the reply reached max_tokens");
}
engine::runtime::TaskRequest transcribe;
transcribe.audio_input = spoken.audio_output;
const auto heard = run(*asr, transcribe);
heard_text = heard.text_output.has_value() ? heard.text_output->text : "";
if (transcriber.reached_max_tokens()) {
throw std::runtime_error("the transcript reached max_tokens: " + heard_text);
}
if (!words(spoken_text).empty()) {
error = word_error_rate(words(spoken_text), words(heard_text));
}
} catch (const std::exception & failure) {
heard_text = std::string("(failed: ") + failure.what() + ")";
}
errors.push_back(error);
std::cout << "seed " << seed << " word error rate " << error << "\n wrote: " << spoken_text << "\n heard: " << heard_text << "\n";
heard_texts += (heard_texts.empty() ? "" : " | ") + heard_text;
};
round_trip(kReplySeeds[0], &reply);
for (size_t i = 1; i < kReplySeeds.size(); ++i) {
round_trip(kReplySeeds[i], nullptr);
}
std::sort(errors.begin(), errors.end());
const double median = errors[errors.size() / 2];
checks.expect(median <= kMaxReplyWordErrors, "the replies say what they write, median word error rate " + std::to_string(median), heard_texts);
}
const auto rejects = [&](const std::function<void(engine::runtime::TaskRequest &)> & edit, const std::string & needle, const std::string & label) {
auto request = question();
edit(request);
std::string message = "<no error>";
try {
(void)run(*s2s, request);
} catch (const std::exception & error) {
message = error.what();
}
checks.expect(message.find(needle) != std::string::npos, "rejects " + label, message);
};
rejects([](auto & r) { r.audio_input.reset(); }, "requires audio_input", "a request without audio");
rejects([](auto & r) { r.voice = engine::runtime::VoiceCondition{engine::runtime::VoiceReference{std::nullopt, std::string("uk_male")}, std::nullopt}; },
"takes no voice", "a voice");
rejects([](auto & r) { r.options["text_chunk_size"] = "64"; }, "does not take request option text_chunk_size", "a TTS option");
rejects([](auto & r) { r.options["language"] = "ja"; }, "speaks en", "another language");
// The first 6 steps are the first text block.
rejects([](auto & r) { r.options["max_tokens"] = "6"; }, "before the reply spoke", "a max_tokens that ends the reply before it speaks");
rejects([](auto & r) { r.options["text_temperature"] = "-1"; }, "must not be negative", "a negative text_temperature");
rejects([](auto & r) { r.options["text_top_k"] = "-1"; }, "must not be negative", "a negative text_top_k");
// A reply that reaches max_tokens keeps its text and speech, as
// liquid-audio keeps what it generated at max_new_tokens, with a warning.
// 40 steps are blocks of 6 text tokens and 12 frames: 16 text tokens and
// 24 frames of 1920 samples, fewer if a frame picks end-of-audio for a
// codebook and so has no sound.
auto cut_question = question();
cut_question.options["max_tokens"] = "40";
engine::runtime::TaskResult cut;
const auto cut_warnings = stderr_of([&] { cut = run(*s2s, cut_question); });
const std::string cut_text = cut.text_output.has_value() ? cut.text_output->text : "";
const std::vector<float> cut_speech = cut.audio_output.has_value() ? cut.audio_output->samples : std::vector<float>();
checks.expect(chat.reached_max_tokens(), "the session reports a reply cut off at max_tokens");
checks.expect(!words(cut_text).empty() && well_formed(cut_text), "the cut reply keeps its text", cut_text);
checks.expect(!cut_speech.empty() && cut_speech.size() <= 24 * 1920, "the cut reply keeps up to 24 frames of speech",
std::to_string(cut_speech.size()) + " samples");
checks.expect(cut_warnings.find("[warning][lfm2_audio] the reply reached max_tokens=40") != std::string::npos &&
cut_warnings.find("raise max_tokens") != std::string::npos,
"a warning says what to raise", cut_warnings);
// The first request again, after replies with other seeds and budgets and
// transcripts in between, is the reply the fresh session gave: S2S sizes
// its decode cache from the request alone, as TTS does.
const auto again = run(*s2s, question());
checks.expect(again.text_output.has_value() && again.text_output->text == text, "the first request again has the same text",
again.text_output.has_value() ? again.text_output->text : "");
checks.expect(has_audio && again.audio_output.has_value() && again.audio_output->samples == reply.audio_output->samples,
"the first request again has the same audio");
// Top-k 1 keeps the text greedy at any temperature, as in liquid-audio.
auto top_1 = question();
top_1.options["text_temperature"] = "0.7";
top_1.options["text_top_k"] = "1";
const auto top_1_reply = run(*s2s, top_1);
checks.expect(top_1_reply.text_output.has_value() && top_1_reply.text_output->text == text, "text_top_k=1 gives the greedy text");
checks.expect(has_audio && top_1_reply.audio_output.has_value() && top_1_reply.audio_output->samples == reply.audio_output->samples,
"text_top_k=1 gives the greedy reply's audio");
// A conversation. With return_codes the first reply also comes back as an
// lfm2_audio.reply artifact: every step it generated, its text tokens and
// its frames.
const auto assets = lfm2::load_lfm2_audio_assets(model_dir);
const auto components = lfm2::load_lfm2_audio_components(*assets, model_gguf, "");
const auto output = lfm2::load_lfm2_audio_output_components(*assets, *components, "", "");
const lfm2::Lfm2TextTokenizer tokenizer(components->vocabulary);
const lfm2::Lfm2ReplyCheckpoint checkpoint{
"en", output->depthformer.codebooks, output->depthformer.audio_vocab_size, components->backbone.vocab_size};
auto opening = question();
opening.options["return_codes"] = "true";
const auto turn_1 = run(*s2s, opening);
checks.expect(has_audio && turn_1.text_output.has_value() && turn_1.text_output->text == text && turn_1.audio_output.has_value() &&
turn_1.audio_output->samples == reply.audio_output->samples,
"return_codes leaves the reply as it is");
std::vector<lfm2::Lfm2ReplyStep> record;
try {
record = lfm2::read_lfm2_reply_artifact(turn_1.output_artifacts.at(0), checkpoint);
} catch (const std::exception & error) {
checks.expect(false, "the first turn returns its reply artifact", error.what());
}
std::vector<int32_t> record_tokens;
size_t speaking = 0;
for (const auto & step : record) {
if (step.codes.empty()) {
record_tokens.push_back(step.token);
} else if (lfm2::lfm2_speaks(step.codes, output->depthformer.end_of_audio())) {
++speaking;
}
}
auto record_bytes = tokenizer.decode(record_tokens);
checks.expect(!record.empty() && lfm2::lfm2_take_text(record_bytes) == text, "the artifact's tokens are the reply's text");
checks.expect(has_audio && speaking * 1920 == reply.audio_output->samples.size(), "the artifact's frames are the reply's speech",
std::to_string(speaking) + " frames");
checks.expect(!turn_1.output_artifacts.empty() && turn_1.output_artifacts[0].meta.at("ended") == (reply_cut ? "false" : "true"),
"the artifact says whether the reply ended");
// The second turn sends the first question's WAV and that artifact back.
// It sees the first turn: its reply differs from the one the same request
// gets without it.
const auto follow_up = engine::audio::read_wav_f32(repo_path(kFollowUp));
std::vector<engine::runtime::VoiceArtifact> history = {
engine::runtime::make_voice_artifact(engine::runtime::ArtifactKind::Custom, lfm2::kLfm2QuestionArtifactId, file_bytes(repo_path(kAudio)))};
if (!turn_1.output_artifacts.empty()) {
history.push_back(turn_1.output_artifacts[0]);
}
const auto second_turn = [&](std::vector<engine::runtime::VoiceArtifact> earlier) {
engine::runtime::TaskRequest request;
request.audio_input = engine::runtime::AudioBuffer{follow_up.sample_rate, follow_up.channels, follow_up.samples};
request.options["seed"] = kReplySeeds[0];
request.options["return_codes"] = "true";
request.input_artifacts = std::move(earlier);
return request;
};
const auto turn_2 = run(*s2s, second_turn(history));
const auto alone = run(*s2s, second_turn({}));
const std::string turn_2_text = turn_2.text_output.has_value() ? turn_2.text_output->text : "";
std::cout << "second turn: " << turn_2_text << "\n";
checks.expect(!words(turn_2_text).empty() && turn_2.audio_output.has_value() && !turn_2.audio_output->samples.empty() &&
turn_2.output_artifacts.size() == 1,
"the second turn replies with text, speech and its artifact");
checks.expect(!same_reply(turn_2, alone), "the first turn changes the second turn's reply");
checks.expect(same_reply(run(*s2s, second_turn(history)), turn_2), "the second turn again gives the same bytes");
// The second reply's artifact replays in turn: a third turn, the first
// question again, after both.
if (!turn_2.output_artifacts.empty()) {
history.push_back(engine::runtime::make_voice_artifact(
engine::runtime::ArtifactKind::Custom, lfm2::kLfm2QuestionArtifactId, file_bytes(repo_path(kFollowUp))));
history.push_back(turn_2.output_artifacts[0]);
}
auto turn_3_request = question();
turn_3_request.input_artifacts = history;
const auto turn_3 = run(*s2s, turn_3_request);
checks.expect(history.size() == 4 && turn_3.text_output.has_value() && !words(turn_3.text_output->text).empty() &&
turn_3.audio_output.has_value() && !turn_3.audio_output->samples.empty(),
"a third turn replays both earlier turns");
std::cout << "third turn: " << (turn_3.text_output.has_value() ? turn_3.text_output->text : "") << "\n";
// Streamed with the same seed: the same reply, its audio decoded a frame
// at a time. The other sessions go first, so one set of weights is loaded.
s2s.reset();
asr.reset();
auto streaming_session = open_session(*model, engine::runtime::VoiceTaskKind::SpeechToSpeech, model_gguf, backend, engine::runtime::RunMode::Streaming);
auto * streaming = dynamic_cast<engine::runtime::IStreamingVoiceTaskSession *>(streaming_session.get());
checks.expect(streaming != nullptr, "the s2s session streams");
if (streaming == nullptr || !has_audio) {
return;
}
checks.expect(streaming->streaming_policy().input == engine::runtime::StreamingInputKind::AudioChunks, "the stream takes audio chunks");
auto request = question();
const auto ask = [&] {
streaming->start_stream(request);
const auto & input = *request.audio_input;
for (size_t start = 0; start < input.samples.size(); start += 4800) {
engine::runtime::AudioChunk chunk;
chunk.sample_rate = input.sample_rate;
chunk.channels = input.channels;
chunk.samples.assign(input.samples.begin() + static_cast<std::ptrdiff_t>(start),
input.samples.begin() + static_cast<std::ptrdiff_t>(std::min(input.samples.size(), start + 4800)));
(void)streaming->process_audio_chunk(chunk);
}
};
ask();
std::vector<float> streamed;
std::string streamed_text;
size_t events = 0;
bool deltas_well_formed = true;
while (auto event = streaming->next_stream_event()) {
++events;
if (event->audio_output.has_value()) {
streamed.insert(streamed.end(), event->audio_output->samples.begin(), event->audio_output->samples.end());
}
if (event->partial_text.has_value()) {
streamed_text += event->partial_text->text;
deltas_well_formed = deltas_well_formed && well_formed(event->partial_text->text);
}
}
// This English reply leaves no character open, so the text checks here and
// below guard how the stream is put together; lfm2_audio_tokenizer_test
// covers open characters.
const auto finished = streaming->finish_stream();
checks.expect(finished.audio_output.has_value() && finished.audio_output->samples == streamed, "the stream's audio is its events");
checks.expect(finished.text_output.has_value() && finished.text_output->text == streamed_text, "the stream's text is its events");
checks.expect(deltas_well_formed && well_formed(streamed_text) && well_formed(text), "the texts are well-formed UTF-8");
checks.expect(streamed_text == text, "the streamed reply has the offline text", streamed_text);
const auto & offline = reply.audio_output->samples;
checks.expect(events == offline.size() / 1920 + 1, "one event per frame and one for the tail", std::to_string(events) + " events");
checks.expect(streamed.size() == offline.size(), "the streamed reply has the offline length", std::to_string(streamed.size()));
if (streamed.size() == offline.size()) {
double difference = 0.0;
double energy = 0.0;
for (size_t i = 0; i < streamed.size(); ++i) {
difference += (static_cast<double>(streamed[i]) - offline[i]) * (static_cast<double>(streamed[i]) - offline[i]);
energy += static_cast<double>(offline[i]) * offline[i];
}
// The detokenizer runs in graphs of other sizes, which backends compute
// with other kernels. On this reply with F16: about 1e-3 on the CPUs
// (9e-4 on x86, 1.7e-3 to 1.8e-3 on the M3 Ultra), 4.3e-2 on CUDA, whose
// cuBLAS accumulates the offline products in half precision. Quantized
// packages round activations to 8 bits on the CPU and CUDA, and graphs
// of other sizes round them differently: 1e-3 to 5e-2 on the CPUs,
// depending on the reply and the thread count; on CUDA, over this and
// four more seeds, 2.2e-2 to 4.5e-2 with Q8_0 and 0.06 to 0.15 with
// Q4_0 (0.15 here). The stream's six-row Q4_0 matmuls run ggml-cuda's
// MMVQ kernel, whose dot product makes the rounding error two to three
// times larger than in the kernel offline decoding uses. A stream that
// lost its context measures 1.3 to 1.4.
checks.expect_close(std::sqrt(difference / energy), 0.0, full_precision ? 0.1 : 0.3, "streamed against offline reply, relative RMS difference");
}
// A client can finish the stream before the reply is over. Its text is
// then what the events carried, which starts the reply's text.
ask();
std::string early_text;
for (int i = 0; i < 20; ++i) {
const auto event = streaming->next_stream_event();
if (event.has_value() && event->partial_text.has_value()) {
early_text += event->partial_text->text;
}
}
const auto early = streaming->finish_stream();
const std::string early_final = early.text_output.has_value() ? early.text_output->text : "";
checks.expect(early_final == early_text && well_formed(early_final), "a stream finished early has the text of its events", early_final);
checks.expect(!early_text.empty() && streamed_text.rfind(early_text, 0) == 0, "the early text starts the reply's text", early_text);
// Streamed, the reply cut off at max_tokens is the offline one: the same
// text, which leaves out a character left open at the cut in both, and
// the same audio up to the detokenizer's arithmetic.
request = cut_question;
std::string cut_streamed_text;
std::vector<float> cut_streamed;
const auto stream_warnings = stderr_of([&] {
ask();
while (auto event = streaming->next_stream_event()) {
if (event->audio_output.has_value()) {
cut_streamed.insert(cut_streamed.end(), event->audio_output->samples.begin(), event->audio_output->samples.end());
}
if (event->partial_text.has_value()) {
cut_streamed_text += event->partial_text->text;
}
}
});
const auto & streaming_chat = dynamic_cast<const lfm2::Lfm2AudioChatSession &>(*streaming_session);
checks.expect(streaming_chat.reached_max_tokens() && stream_warnings.find("reached max_tokens=40") != std::string::npos,
"the cut stream warns and reports it", stream_warnings);
const auto cut_finished = streaming->finish_stream();
checks.expect(cut_finished.text_output.has_value() && cut_finished.text_output->text == cut_streamed_text, "the cut stream's text is its events");
checks.expect(cut_finished.audio_output.has_value() && cut_finished.audio_output->samples == cut_streamed, "the cut stream's audio is its events");
checks.expect(cut_streamed_text == cut_text, "the cut stream has the offline cut text", cut_streamed_text);
checks.expect(cut_streamed.size() == cut_speech.size(), "the cut stream has the offline cut length", std::to_string(cut_streamed.size()));
if (!cut_speech.empty() && cut_streamed.size() == cut_speech.size()) {
double difference = 0.0;
double energy = 0.0;
for (size_t i = 0; i < cut_streamed.size(); ++i) {
difference += (static_cast<double>(cut_streamed[i]) - cut_speech[i]) * (static_cast<double>(cut_streamed[i]) - cut_speech[i]);
energy += static_cast<double>(cut_speech[i]) * cut_speech[i];
}
checks.expect_close(std::sqrt(difference / energy), 0.0, full_precision ? 0.1 : 0.3, "streamed against offline cut reply, relative RMS difference");
}
}
} // namespace
int main(int argc, char ** argv) {
const std::filesystem::path model_dir = arg_value(argc, argv, "--model", repo_path("models/LFM2.5-Audio-1.5B-GGUF").string());
const std::string model_gguf = arg_value(argc, argv, "--model-gguf", "LFM2.5-Audio-1.5B-F16.gguf");
const std::filesystem::path spec_override =
arg_value(argc, argv, "--model-spec-override", repo_path("model_specs").string());
const std::string backend_name = arg_value(argc, argv, "--backend", "cpu");
const int threads = std::atoi(arg_value(argc, argv, "--threads", "4").c_str());
if (backend_name != "cpu" && backend_name != "best") {
std::cerr << "FAIL: --backend must be cpu or best\n";
return kExitFail;
}
for (const auto & prefix : {"", "mmproj-", "vocoder-", "tokenizer-"}) {
if (!engine::io::is_existing_file(model_dir / (std::string(prefix) + model_gguf))) {
std::fprintf(
stderr,
"SKIP: test_lfm2_audio_s2s needs %s and its mmproj-, vocoder- and tokenizer- files in '%s'.\n"
" python3 tools/model_manager_v2.py install lfm2_audio_1_5b_f16 --models-root models\n",
model_gguf.c_str(),
model_dir.string().c_str());
return kExitSkip;
}
}
engine::core::BackendConfig backend;
backend.type = backend_name == "cpu" ? engine::core::BackendType::Cpu : engine::core::BackendType::BestAvailable;
backend.threads = threads > 0 ? threads : 1;
// Quantized weights move the stage numbers and the greedy text by design;
// their replies are still checked.
const bool full_precision = model_gguf.find("-F16.") != std::string::npos || model_gguf.find("-F32.") != std::string::npos;
Checks checks;
try {
{
const auto assets = lfm2::load_lfm2_audio_assets(model_dir);
const auto components = lfm2::load_lfm2_audio_components(*assets, model_gguf, "");
const auto output = lfm2::load_lfm2_audio_output_components(*assets, *components, "", "");
check_prompt(*components, *output, checks);
if (full_precision) {
check_stage_numbers(*components, *output, backend, checks);
} else {
std::cout << "SKIP stage numbers: " << model_gguf << " is quantized\n";
}
check_text_sampling(*components, *output, backend, checks);
}
check_replies(model_dir, spec_override, model_gguf, backend, full_precision, checks);
} catch (const std::exception & error) {
std::cerr << "FAIL: " << error.what() << "\n";
return kExitFail;
}
std::cout << (checks.failures() == 0 ? "PASS" : "FAIL") << ": " << checks.failures() << " failed checks\n";
return checks.failures() == 0 ? kExitPass : kExitFail;
}