codec: add pre-encoded voice reference (--ref-spk / --ref-rvq)

qwen-codec --talker extracts the speaker embedding (.spk, raw f32)
and the ICL codes (.rvq) in one pass, encode truncated to the hop
boundary conforming to the --ref-wav path. qwen-tts loads them via
--ref-spk / --ref-rvq and skips the speaker encoder and codec encode
on every synthesis: TTFA 205 ms -> 89 ms. Extends qt_tts_params with
ABI v2 latent fields, adds qt_num_codebooks(), ships freeman.spk +
freeman.rvq and switches clone scripts to the latent path. Output is
bit-identical to the raw path at fixed seed.
This commit is contained in:
Pascal
2026-06-11 21:45:13 +02:00
parent e8e33629c1
commit 0bf4a18b22
11 changed files with 399 additions and 144 deletions
+61 -19
View File
@@ -376,14 +376,50 @@ qt_status pipeline_tts_synthesize(PipelineTTS * pt,
const std::string speaker = params->speaker ? params->speaker : "";
const std::string ref_text = params->ref_text ? params->ref_text : "";
// Voice clone mode A: if ref_audio_24k is given, run the speaker
// encoder on the pre-decoded mono buffer and feed the resulting
// embedding straight into the prompt builder. Mutually exclusive
// with --speaker.
const bool has_ref_audio = (params->ref_audio_24k != NULL) && (params->ref_n_samples > 0);
// ABI v2 latent reference fields. Callers compiled against ABI 1
// never set them; the abi_version gate keeps their uninitialised
// tail bytes out of the read path.
const float * lat_spk_emb = (params->abi_version >= 2) ? params->ref_spk_emb : NULL;
const int lat_spk_dim = (params->abi_version >= 2) ? params->ref_spk_dim : 0;
const int32_t * lat_codes = (params->abi_version >= 2) ? params->ref_codes : NULL;
const int lat_T = (params->abi_version >= 2) ? params->ref_T : 0;
const bool has_ref_audio = (params->ref_audio_24k != NULL) && (params->ref_n_samples > 0);
const bool has_lat_spk = (lat_spk_emb != NULL) && (lat_spk_dim > 0);
const bool has_lat_codes = (lat_codes != NULL) && (lat_T > 0);
// Raw waveform and pre-encoded latents are mutually exclusive: the
// caller is told immediately rather than picking a winner silently.
if (has_ref_audio && (has_lat_spk || has_lat_codes)) {
qt_set_error("pipeline_tts_synthesize: ref_audio_24k and ref_spk_emb / ref_codes are mutually exclusive");
qt_log(QT_LOG_ERROR, "[Pipeline] ref_audio_24k and ref_spk_emb / ref_codes are mutually exclusive");
return QT_STATUS_INVALID_PARAMS;
}
// Latent ICL codes ride on top of the speaker embedding and need the
// transcript, mirroring the raw path where mode B implies mode A.
if (has_lat_codes && (!has_lat_spk || ref_text.empty())) {
qt_set_error("pipeline_tts_synthesize: ref_codes requires ref_spk_emb and ref_text");
qt_log(QT_LOG_ERROR, "[Pipeline] ref_codes requires ref_spk_emb and ref_text");
return QT_STATUS_INVALID_PARAMS;
}
// Voice clone mode A: a pre-extracted latent embedding feeds the
// prompt builder directly; otherwise, if ref_audio_24k is given, run
// the speaker encoder on the pre-decoded mono buffer. Mutually
// exclusive with --speaker.
std::vector<float> ref_spk_emb;
const float * ref_spk_emb_ptr = NULL;
if (has_ref_audio) {
if (has_lat_spk) {
if (lat_spk_dim != pt->talker.hidden_size) {
qt_set_error("pipeline_tts_synthesize: ref_spk_dim %d mismatches talker hidden %d", lat_spk_dim,
pt->talker.hidden_size);
qt_log(QT_LOG_ERROR, "[Pipeline] ref_spk_dim %d mismatches talker hidden %d", lat_spk_dim,
pt->talker.hidden_size);
return QT_STATUS_INVALID_PARAMS;
}
ref_spk_emb_ptr = lat_spk_emb;
qt_log(QT_LOG_INFO, "[Pipeline] Latent speaker embedding: %d values", lat_spk_dim);
} else if (has_ref_audio) {
if (!pt->has_speaker_encoder) {
qt_set_error(
"pipeline_tts_synthesize: --ref-wav requires a model with a loaded speaker encoder (Base only)");
@@ -404,17 +440,22 @@ qt_status pipeline_tts_synthesize(PipelineTTS * pt,
ref_spk_emb_ptr = ref_spk_emb.data();
}
// Voice clone mode B: if ref_text is also given, encode the
// reference audio into 16 codebook indices via the codec encoder.
// Layout returned by pipeline_codec_encode is [num_codebooks, T_codec]
// row major, matching what the prompt builder expects for the ICL
// sum loop.
// Voice clone mode B: pre-encoded latent codes feed the ICL prompt
// directly; otherwise, if ref_text is given, encode the reference
// audio into 16 codebook indices via the codec encoder. Layout is
// [num_codebooks, T_codec] row major in both cases, matching what
// the prompt builder expects for the ICL sum loop.
std::vector<int32_t> ref_codes;
int ref_codes_T = 0;
if (!ref_text.empty()) {
const int32_t * ref_codes_ptr = NULL;
int ref_codes_T = 0;
if (has_lat_codes) {
ref_codes_ptr = lat_codes;
ref_codes_T = lat_T;
qt_log(QT_LOG_INFO, "[Pipeline] Latent ICL ref_codes: %d frames at 12.5 Hz", ref_codes_T);
} else if (!ref_text.empty()) {
if (!has_ref_audio) {
qt_set_error("pipeline_tts_synthesize: --ref-text requires --ref-wav");
qt_log(QT_LOG_ERROR, "[Pipeline] --ref-text requires --ref-wav");
qt_set_error("pipeline_tts_synthesize: ref_text requires ref_audio_24k or latent ref_codes");
qt_log(QT_LOG_ERROR, "[Pipeline] ref_text requires ref_audio_24k or latent ref_codes");
return QT_STATUS_INVALID_PARAMS;
}
// The codec hop is 1920 samples at 24 kHz so n_samples must be
@@ -431,7 +472,8 @@ qt_status pipeline_tts_synthesize(PipelineTTS * pt,
qt_log(QT_LOG_ERROR, "[Pipeline] pipeline_codec_encode returned empty codes");
return QT_STATUS_GENERATE_FAILED;
}
ref_codes_T = (int) ref_codes.size() / pt->num_code_groups;
ref_codes_ptr = ref_codes.data();
ref_codes_T = (int) ref_codes.size() / pt->num_code_groups;
qt_log(QT_LOG_INFO, "[Pipeline] ICL ref_codes: %d frames at 12.5 Hz (%d audio samples)", ref_codes_T,
aligned_T);
}
@@ -444,8 +486,8 @@ qt_status pipeline_tts_synthesize(PipelineTTS * pt,
const char * lang = params->lang ? params->lang : "auto";
Timer t_build;
if (!prompt_builder_build(pt, tok, params->text, lang, instruct, speaker, ref_spk_emb_ptr, ref_text,
ref_codes_T > 0 ? ref_codes.data() : NULL, ref_codes_T, &prompt)) {
if (!prompt_builder_build(pt, tok, params->text, lang, instruct, speaker, ref_spk_emb_ptr, ref_text, ref_codes_ptr,
ref_codes_T, &prompt)) {
return QT_STATUS_GENERATE_FAILED;
}
perf.build_ms = t_build.ms();
@@ -469,7 +511,7 @@ qt_status pipeline_tts_synthesize(PipelineTTS * pt,
}
if (ref_codes_T > 0) {
const int shape[2] = { pt->num_code_groups, ref_codes_T };
debug_dump_i32_as_f32(&d, "ref-codes", ref_codes.data(), shape, 2);
debug_dump_i32_as_f32(&d, "ref-codes", ref_codes_ptr, shape, 2);
}
}
+22 -6
View File
@@ -218,6 +218,18 @@ void qt_tts_default_params(struct qt_tts_params * p) {
p->on_chunk_user_data = nullptr;
p->codec_chunk_sec = 24.0f;
p->codec_left_context_sec = 2.0f;
p->ref_spk_emb = nullptr;
p->ref_spk_dim = 0;
p->ref_codes = nullptr;
p->ref_T = 0;
}
int qt_num_codebooks(const struct qt_context * q) {
if (!q) {
qt_set_error("qt_num_codebooks: q is NULL");
return 0;
}
return q->pt.num_code_groups;
}
struct qt_context * qt_init(const struct qt_init_params * params) {
@@ -356,22 +368,26 @@ enum qt_status qt_synthesize(struct qt_context * q, const struct qt_tts_params *
}
return QT_STATUS_MODE_INVALID;
}
if (params->ref_audio_24k && mt != "base") {
qt_set_error("--ref-wav is only valid for base models (loaded: %s)", mt.c_str());
// ABI v2 latent reference fields, same gate as the pipeline.
const bool has_lat_spk = params->abi_version >= 2 && params->ref_spk_emb && params->ref_spk_dim > 0;
const bool has_lat_codes = params->abi_version >= 2 && params->ref_codes && params->ref_T > 0;
if ((params->ref_audio_24k || has_lat_spk) && mt != "base") {
qt_set_error("--ref-wav / --ref-spk is only valid for base models (loaded: %s)", mt.c_str());
if (out) {
qt_audio_free(out);
}
return QT_STATUS_MODE_INVALID;
}
if (params->speaker && params->ref_audio_24k) {
qt_set_error("--speaker and --ref-wav are mutually exclusive");
if (params->speaker && (params->ref_audio_24k || has_lat_spk)) {
qt_set_error("--speaker and --ref-wav / --ref-spk are mutually exclusive");
if (out) {
qt_audio_free(out);
}
return QT_STATUS_INVALID_PARAMS;
}
if (params->ref_text && !params->ref_audio_24k) {
qt_set_error("--ref-text requires --ref-wav");
if (params->ref_text && !params->ref_audio_24k && !has_lat_codes) {
qt_set_error("--ref-text requires --ref-wav or --ref-rvq");
if (out) {
qt_audio_free(out);
}
+20 -1
View File
@@ -57,7 +57,7 @@ extern "C" {
// git short hash + commit date string returned by qt_version(); for
// binding compat checks, QT_ABI_VERSION is the only number that
// matters.
#define QT_ABI_VERSION 1
#define QT_ABI_VERSION 2
// Returns a static string of the form "<git-hash> (<date>)" identifying
// the exact commit this binary was built from. Safe to call from any
@@ -269,6 +269,19 @@ struct qt_tts_params {
// clamps to >= 0 frames.
float codec_chunk_sec;
float codec_left_context_sec;
// ABI v2. Pre-encoded voice reference, the latent counterpart of
// ref_audio_24k. ref_spk_emb is the speaker embedding produced by
// the speaker encoder (ref_spk_dim f32 values, must equal the
// talker hidden size). ref_codes is the ICL code matrix produced
// by the codec encoder, [num_codebooks, ref_T] row-major.
// ref_spk_emb alone selects clone mode A; ref_spk_emb + ref_codes
// + ref_text selects mode B, mirroring the raw constraints.
// Mutually exclusive with ref_audio_24k and speaker.
const float * ref_spk_emb;
int ref_spk_dim;
const int32_t * ref_codes;
int ref_T;
};
// Initialise to the standard defaults. Strings NULL, seed -1,
@@ -278,6 +291,12 @@ struct qt_tts_params {
// codec_left_context_sec 2.0.
QT_API void qt_tts_default_params(struct qt_tts_params * p);
// Number of RVQ codebooks (K) of the loaded codec. Pre-encoded ICL
// reference codes passed via ref_codes are laid out [K, ref_T]
// row-major; callers reading a packed .rvq stream need K to derive
// ref_T from the code count. Returns 0 on a NULL handle.
QT_API int qt_num_codebooks(const struct qt_context * q);
// Run the full TTS synthesis. Validates the params against the loaded
// model_type (the seven base / custom_voice / voice_design rules),
// resolves the seed, hands off to pipeline_tts_synthesize and fills
+111
View File
@@ -0,0 +1,111 @@
#pragma once
// rvq-file.h: packed RVQ code stream file IO (.rvq).
//
// Flat code stream packed at code_bits per code, LSB-first, no header.
// Layout is [K, T] row-major. K and code_bits are fixed by the codec
// config in the GGUF; T is derived from the file size:
// T = (filesize * 8) / (K * code_bits).
#include "utf8.h"
#include <cstdint>
#include <cstdio>
#include <string>
#include <vector>
// Pack a flat code stream into code_bits-per-code, LSB-first. Output size
// is ceil(N * code_bits / 8) bytes.
static std::vector<uint8_t> rvq_pack_codes(const std::vector<int32_t> & codes, int code_bits) {
const uint32_t mask = (1u << code_bits) - 1u;
const size_t total_bits = codes.size() * (size_t) code_bits;
std::vector<uint8_t> out((total_bits + 7) / 8, 0);
uint64_t acc = 0;
int bits_in_acc = 0;
size_t out_pos = 0;
for (size_t i = 0; i < codes.size(); i++) {
acc |= ((uint64_t) ((uint32_t) codes[i] & mask)) << bits_in_acc;
bits_in_acc += code_bits;
while (bits_in_acc >= 8) {
out[out_pos++] = (uint8_t) (acc & 0xFF);
acc >>= 8;
bits_in_acc -= 8;
}
}
if (bits_in_acc > 0) {
out[out_pos++] = (uint8_t) (acc & 0xFF);
}
return out;
}
// Symmetric unpack: reads N codes from packed bytes.
static std::vector<int32_t> rvq_unpack_codes(const std::vector<uint8_t> & in, size_t n_codes, int code_bits) {
const uint32_t mask = (1u << code_bits) - 1u;
std::vector<int32_t> out(n_codes);
uint64_t acc = 0;
int bits_in_acc = 0;
size_t in_pos = 0;
for (size_t i = 0; i < n_codes; i++) {
while (bits_in_acc < code_bits && in_pos < in.size()) {
acc |= ((uint64_t) in[in_pos++]) << bits_in_acc;
bits_in_acc += 8;
}
out[i] = (int32_t) (acc & mask);
acc >>= code_bits;
bits_in_acc -= code_bits;
}
return out;
}
// Read a .rvq file and unpack it into K*T codes. T is inferred from the
// file size.
static bool rvq_read_file(const char * path, int K, int code_bits, std::vector<int32_t> & codes, int * n_frames) {
FILE * f = utf8_fopen(path, "rb");
if (!f) {
fprintf(stderr, "[RVQ] FATAL: cannot open %s\n", path);
return false;
}
fseek(f, 0, SEEK_END);
long sz = ftell(f);
fseek(f, 0, SEEK_SET);
if (sz <= 0) {
fprintf(stderr, "[RVQ] FATAL: %s is empty\n", path);
fclose(f);
return false;
}
std::vector<uint8_t> buf((size_t) sz);
if (fread(buf.data(), 1, buf.size(), f) != buf.size()) {
fprintf(stderr, "[RVQ] FATAL: short read on %s\n", path);
fclose(f);
return false;
}
fclose(f);
const size_t total_bits = (size_t) sz * 8;
const size_t n_codes = total_bits / (size_t) code_bits;
if (n_codes == 0 || (n_codes % (size_t) K) != 0) {
fprintf(stderr, "[RVQ] FATAL: %s yields %zu codes, not a multiple of K=%d\n", path, n_codes, K);
return false;
}
codes = rvq_unpack_codes(buf, n_codes, code_bits);
*n_frames = (int) (n_codes / (size_t) K);
return true;
}
// Pack and write a .rvq file.
static bool rvq_write_file(const char * path, const std::vector<int32_t> & codes, int code_bits) {
std::vector<uint8_t> packed = rvq_pack_codes(codes, code_bits);
FILE * f = utf8_fopen(path, "wb");
if (!f) {
fprintf(stderr, "[RVQ] FATAL: cannot open %s for write\n", path);
return false;
}
if (fwrite(packed.data(), 1, packed.size(), f) != packed.size()) {
fprintf(stderr, "[RVQ] FATAL: short write on %s\n", path);
fclose(f);
return false;
}
fclose(f);
return true;
}