Merge pull request #5 from andimarafioti/codex/voice-ref-abi

Add voice reference extraction ABI
This commit is contained in:
Pascal
2026-06-24 17:33:58 +02:00
committed by GitHub
4 changed files with 180 additions and 3 deletions
+10
View File
@@ -149,6 +149,16 @@ qt_audio_free(&audio);
qt_free(q);
```
Base voice-clone latents can also be precomputed in-process, replacing
the `qwen-codec --talker ref.wav` shell-out: `qt_extract_voice_ref`
takes the decoded `.wav` contents as mono float32 PCM at 24 kHz and
fills a `struct qt_voice_ref` with the `.spk`-equivalent speaker
embedding plus the `.rvq`-equivalent `[num_codebooks, ref_T]` code
matrix. Pass those buffers back through `qt_tts_params.ref_spk_emb` /
`ref_codes`, and for reference-WAV-plus-transcription ICL mode keep
passing the transcript as `qt_tts_params.ref_text`. Release the buffers
with `qt_voice_ref_free`.
`tests/abi-c.c` is built with `-std=c99 -Wall -Werror -pedantic` on
every build (the `test-abi-c` target), so any regression that breaks
plain C consumability fails the build, not just an opt-in target.
+118
View File
@@ -20,6 +20,7 @@
#include "bpe.h"
#include "pipeline-tts.h"
#include "qt-error.h"
#include "speaker-encoder-extract.h"
#include "version.h"
#include <atomic>
@@ -27,9 +28,11 @@
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <new>
#include <random>
#include <stdexcept>
#include <string>
#include <vector>
// Internal definition of the opaque handle. C++ types are fine here
// because nothing in this struct ever crosses the public ABI boundary :
@@ -300,6 +303,121 @@ void qt_free(struct qt_context * q) {
delete q;
}
void qt_voice_ref_free(struct qt_voice_ref * ref) {
if (!ref) {
return;
}
if (ref->ref_spk_emb) {
std::free(ref->ref_spk_emb);
}
if (ref->ref_codes) {
std::free(ref->ref_codes);
}
ref->ref_spk_emb = nullptr;
ref->ref_spk_dim = 0;
ref->ref_codes = nullptr;
ref->ref_T = 0;
ref->num_codebooks = 0;
}
enum qt_status qt_extract_voice_ref(struct qt_context * q,
const float * ref_audio_24k,
int ref_n_samples,
struct qt_voice_ref * out) {
if (out) {
qt_voice_ref_free(out);
}
if (!q || !ref_audio_24k || !out) {
qt_set_error("qt_extract_voice_ref: q, ref_audio_24k or out is NULL");
return QT_STATUS_INVALID_PARAMS;
}
if (ref_n_samples < TOKENIZER_HOP_LENGTH) {
qt_set_error("qt_extract_voice_ref: ref_audio_24k too short for RVQ encode (%d samples, need at least %d)",
ref_n_samples, TOKENIZER_HOP_LENGTH);
return QT_STATUS_INVALID_PARAMS;
}
const std::string & mt = q->pt.model_type;
if (mt != "base") {
qt_set_error("qt_extract_voice_ref: voice references are only valid for base models (loaded: %s)", mt.c_str());
return QT_STATUS_MODE_INVALID;
}
if (!q->pt.has_speaker_encoder) {
qt_set_error("qt_extract_voice_ref: loaded base model has no speaker encoder");
return QT_STATUS_GENERATE_FAILED;
}
if (q->pt.num_code_groups <= 0) {
qt_set_error("qt_extract_voice_ref: invalid codebook count %d", q->pt.num_code_groups);
return QT_STATUS_GENERATE_FAILED;
}
try {
std::vector<float> emb;
if (!speaker_encoder_extract(&q->pt.speaker_encoder, q->pt.sched, ref_audio_24k, ref_n_samples, emb)) {
qt_set_error("qt_extract_voice_ref: speaker embedding extraction failed");
return QT_STATUS_GENERATE_FAILED;
}
if ((int) emb.size() != q->pt.talker.hidden_size) {
qt_set_error("qt_extract_voice_ref: speaker embedding size %zu mismatches talker hidden %d", emb.size(),
q->pt.talker.hidden_size);
return QT_STATUS_GENERATE_FAILED;
}
const int aligned_n = (ref_n_samples / TOKENIZER_HOP_LENGTH) * TOKENIZER_HOP_LENGTH;
const int ref_T = aligned_n / TOKENIZER_HOP_LENGTH;
std::vector<int32_t> codes = pipeline_codec_encode(&q->pt.codec, ref_audio_24k, aligned_n);
if (codes.empty()) {
qt_set_error("qt_extract_voice_ref: pipeline_codec_encode returned empty codes");
return QT_STATUS_GENERATE_FAILED;
}
const int num_codebooks = q->pt.num_code_groups;
if ((codes.size() % (size_t) num_codebooks) != 0) {
qt_set_error("qt_extract_voice_ref: encoded code count %zu is not divisible by %d", codes.size(),
num_codebooks);
return QT_STATUS_GENERATE_FAILED;
}
const int codes_T = (int) (codes.size() / (size_t) num_codebooks);
if (codes_T != ref_T) {
qt_set_error("qt_extract_voice_ref: encoded frame count %d mismatches aligned frame count %d", codes_T,
ref_T);
return QT_STATUS_GENERATE_FAILED;
}
const size_t emb_bytes = emb.size() * sizeof(float);
const size_t codes_bytes = codes.size() * sizeof(int32_t);
float * emb_copy = (float *) std::malloc(emb_bytes);
int32_t * codes_copy = (int32_t *) std::malloc(codes_bytes);
if (!emb_copy || !codes_copy) {
std::free(emb_copy);
std::free(codes_copy);
qt_set_error("qt_extract_voice_ref: malloc failed for %zu emb bytes and %zu code bytes", emb_bytes,
codes_bytes);
return QT_STATUS_OOM;
}
std::memcpy(emb_copy, emb.data(), emb_bytes);
std::memcpy(codes_copy, codes.data(), codes_bytes);
out->ref_spk_emb = emb_copy;
out->ref_spk_dim = (int) emb.size();
out->ref_codes = codes_copy;
out->ref_T = ref_T;
out->num_codebooks = num_codebooks;
qt_log(QT_LOG_INFO, "[Qwen] Extracted voice ref: spk_dim=%d K=%d T=%d (%d/%d samples)", out->ref_spk_dim,
out->num_codebooks, out->ref_T, aligned_n, ref_n_samples);
return QT_STATUS_OK;
} catch (const std::bad_alloc &) {
qt_set_error("qt_extract_voice_ref: out of memory");
qt_voice_ref_free(out);
return QT_STATUS_OOM;
} catch (const std::exception & e) {
qt_set_error("%s", e.what());
qt_log(QT_LOG_ERROR, "[Qwen] %s", e.what());
qt_voice_ref_free(out);
return QT_STATUS_GENERATE_FAILED;
}
}
enum qt_status qt_synthesize(struct qt_context * q, const struct qt_tts_params * params, struct qt_audio * out) {
if (!q || !params) {
qt_set_error("qt_synthesize: q or params is NULL");
+38
View File
@@ -137,6 +137,44 @@ QT_API struct qt_context * qt_init(const struct qt_init_params * params);
// Safe on NULL.
QT_API void qt_free(struct qt_context * q);
// Precomputed Base-model voice reference latents. Plain POD: both
// pointers are malloc allocated by qt_extract_voice_ref, owned by the
// struct, released by qt_voice_ref_free. Do not free either pointer
// directly nor reassign without freeing first. Zero initialise before
// first use: `struct qt_voice_ref ref = {0};`.
//
// ref_spk_emb is the speaker embedding equivalent to a raw .spk file.
// ref_codes is the RVQ code matrix equivalent to a raw .rvq file,
// laid out [num_codebooks, ref_T] row-major (T fastest).
struct qt_voice_ref {
float * ref_spk_emb;
int ref_spk_dim;
int32_t * ref_codes;
int ref_T;
int num_codebooks;
};
// Extract reusable voice-clone conditioning from a decoded reference
// .wav/audio buffer: mono float32 PCM at 24 kHz. Requires a loaded Base
// model with speaker encoder weights. The speaker embedding consumes the
// full input buffer, matching --ref-wav clone mode A. RVQ encoding
// truncates to the codec hop boundary, matching qwen-codec --talker
// ref.wav / --ref-rvq.
// For reference-WAV-plus-transcription ICL mode, pass the returned
// ref_spk_emb and ref_codes back to qt_synthesize together with the
// transcript in qt_tts_params.ref_text.
//
// On success fills out with malloc-owned buffers. On failure leaves out
// empty and stores a diagnostic in qt_last_error().
QT_API enum qt_status qt_extract_voice_ref(struct qt_context * q,
const float * ref_audio_24k,
int ref_n_samples,
struct qt_voice_ref * out);
// Release the speaker embedding and RVQ code buffers and reset the
// struct to empty. Safe on a zero initialised struct.
QT_API void qt_voice_ref_free(struct qt_voice_ref * ref);
// Cooperative cancellation callback. Returns true to request the
// synthesis to abort. Polled at the top of every Talker decode step in
// the autoregressive loop, so the cancel granularity is roughly one
+14 -3
View File
@@ -94,6 +94,9 @@ int main(void) {
struct qt_audio audio = { 0 };
qt_audio_free(&audio);
struct qt_voice_ref voice_ref = { 0 };
qt_voice_ref_free(&voice_ref);
/* Install the log callback before the failing init so the [Qwen]
* ERROR line lands on stub_log instead of stderr. */
qt_log_set(stub_log, NULL);
@@ -136,9 +139,9 @@ int main(void) {
* rejected up front, before any allocation. */
struct qt_init_params future_iparams;
qt_init_default_params(&future_iparams);
future_iparams.talker_path = "irrelevant.gguf";
future_iparams.codec_path = "irrelevant.gguf";
future_iparams.abi_version = QT_ABI_VERSION + 1;
future_iparams.talker_path = "irrelevant.gguf";
future_iparams.codec_path = "irrelevant.gguf";
future_iparams.abi_version = QT_ABI_VERSION + 1;
struct qt_context * rejected = qt_init(&future_iparams);
if (rejected != NULL) {
fprintf(stderr, "[Probe] qt_init accepted a future abi_version\n");
@@ -153,6 +156,13 @@ int main(void) {
return 3;
}
rc = qt_extract_voice_ref(NULL, NULL, 0, &voice_ref);
if (rc != QT_STATUS_INVALID_PARAMS) {
fprintf(stderr, "[Probe] qt_extract_voice_ref(NULL) returned %d, expected %d\n", (int) rc,
(int) QT_STATUS_INVALID_PARAMS);
return 11;
}
int frames = qt_duration_sec_to_tokens(NULL, 1.0f);
if (frames < 1) {
fprintf(stderr, "[Probe] qt_duration_sec_to_tokens returned %d, expected >= 1\n", frames);
@@ -177,6 +187,7 @@ int main(void) {
qt_free(NULL);
qt_audio_free(&audio);
qt_voice_ref_free(&voice_ref);
return 0;
}