diff --git a/dist/build_test_tokenizer.sh b/dist/build_test_tokenizer.sh new file mode 100755 index 0000000..d8d69d0 --- /dev/null +++ b/dist/build_test_tokenizer.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +NDK_ROOT="${ANDROID_NDK_ROOT:-/opt/Kazeia/android-ndk-r27d}" +CXX="$NDK_ROOT/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android31-clang++" +JNI="$HERE/jni" +INC="$HERE/include" +LIB="$HERE/lib" +OUT="$HERE/b-jni/test_tokenizer" +mkdir -p "$HERE/b-jni" +"$CXX" -std=c++17 -O2 -fPIE \ + -march=armv8.6-a+i8mm+bf16+dotprod+fp16 \ + -I"$INC" \ + "$JNI/kazeia_text_tokenizer.cpp" "$JNI/test_tokenizer.cpp" \ + -L"$LIB" -lllama -lggml -lggml-base -lggml-cpu \ + -Wl,-rpath,'$ORIGIN' \ + -llog -ldl -lm \ + -o "$OUT" +ls -la "$OUT" diff --git a/dist/build_tts_pipeline.sh b/dist/build_tts_pipeline.sh index 72be5d5..904d5bd 100755 --- a/dist/build_tts_pipeline.sh +++ b/dist/build_tts_pipeline.sh @@ -34,6 +34,7 @@ LIBS=( "$JNI/tts_pipeline.cpp" "$JNI/cp_inference.cpp" "$JNI/sampler.cpp" + "$JNI/kazeia_text_tokenizer.cpp" "$DECODER_A" -L"$LIB" -lllama -lggml -lggml-base -lggml-cpu -Wl,-rpath,'$ORIGIN' diff --git a/dist/jni/kazeia_text_tokenizer.cpp b/dist/jni/kazeia_text_tokenizer.cpp new file mode 100644 index 0000000..1e207fb --- /dev/null +++ b/dist/jni/kazeia_text_tokenizer.cpp @@ -0,0 +1,66 @@ +#include "kazeia_text_tokenizer.h" +#include "llama.h" +#include + +bool kz_tok_load(KzTextTokenizer & t, const char * gguf_path) { + auto mp = llama_model_default_params(); + mp.vocab_only = true; + mp.n_gpu_layers = 0; // par sécurité (les backends NPU ignorent ça en vocab_only mais explicite). + t.model = llama_model_load_from_file(gguf_path, mp); + if (!t.model) { + fprintf(stderr, "kz_tok_load: failed to load %s\n", gguf_path); + return false; + } + t.vocab = llama_model_get_vocab(t.model); + if (!t.vocab) { + fprintf(stderr, "kz_tok_load: no vocab in %s\n", gguf_path); + llama_model_free(t.model); + t.model = nullptr; + return false; + } + fprintf(stderr, "kz_tok_load: %s (n_tokens=%d)\n", gguf_path, llama_vocab_n_tokens(t.vocab)); + return true; +} + +void kz_tok_free(KzTextTokenizer & t) { + if (t.model) llama_model_free(t.model); + t.model = nullptr; + t.vocab = nullptr; +} + +std::vector kz_tok_encode(const KzTextTokenizer & t, + const std::string & text, + bool parse_special) { + if (!t.vocab) { fprintf(stderr, "kz_tok_encode: vocab not loaded\n"); return {}; } + // Dimension : 1 token / octet est une borne sup confortable (en pratique on tokenize plutôt + // à ~4 octets/token). On alloue avec marge et on resize au retour. + int32_t cap = (int32_t)text.size() + 16; + std::vector out(cap); + int32_t n = llama_tokenize(t.vocab, text.c_str(), (int32_t)text.size(), + out.data(), cap, /*add_special=*/false, parse_special); + if (n < 0) { + // sous-dimensionné : on retente avec la taille demandée. + out.resize(-n); + n = llama_tokenize(t.vocab, text.c_str(), (int32_t)text.size(), + out.data(), -n, /*add_special=*/false, parse_special); + } + if (n < 0) { fprintf(stderr, "kz_tok_encode: tokenize failed (%d)\n", n); return {}; } + out.resize(n); + return out; +} + +std::vector kz_tok_encode_tts_prompt(const KzTextTokenizer & t, + const std::string & content) { + // Format chat Qwen3-TTS exact, vérifié sur dump golden (16 tokens pour + // "Bonjour je m'appelle Kazeia") : + // <|im_start|>assistant\n{content}<|im_end|>\n<|im_start|>assistant\n + // + // Note : le 2e <|im_start|>assistant\n est le "cue" pour la génération côté talker. + // Le talker répond par les codes audio à la suite. + std::string wrapped; + wrapped.reserve(content.size() + 64); + wrapped += "<|im_start|>assistant\n"; + wrapped += content; + wrapped += "<|im_end|>\n<|im_start|>assistant\n"; + return kz_tok_encode(t, wrapped, /*parse_special=*/true); +} diff --git a/dist/jni/kazeia_text_tokenizer.h b/dist/jni/kazeia_text_tokenizer.h new file mode 100644 index 0000000..1c38be7 --- /dev/null +++ b/dist/jni/kazeia_text_tokenizer.h @@ -0,0 +1,43 @@ +// Tokenizer texte pour Qwen3-TTS, wrappé sur libllama. +// +// Plutôt que de porter le BPE Qwen3 (NFC + pre-tokenize regex unicode + byte-level +// + merges + ignore_merges) à la main en C++, on charge n'importe quelle GGUF Qwen3 +// en `vocab_only=true` -> ~50 MB RAM au lieu des 2-4 GB de poids, et on appelle +// llama_tokenize(parse_special=true) qui gère tout, bit-exact contre HF. +// +// Tradeoff assumé : dépendance libllama (qu'on a déjà), 50 MB RAM en plus, vs +// quelques centaines de lignes risquées à porter et à maintenir. +// +// Pour le TTS, on a juste besoin d'envelopper le contenu dans le template chat +// Qwen3 attendu par le talker : +// <|im_start|>assistant\n{content}<|im_end|>\n<|im_start|>assistant\n +// +// (déduit du dump golden : 16 tokens [151644, 77091, 198, 81581, ..., 151645, +// 198, 151644, 77091, 198] pour "Bonjour je m'appelle Kazeia"). +#pragma once +#include +#include +#include + +struct llama_model; +struct llama_vocab; + +struct KzTextTokenizer { + llama_model * model = nullptr; + const llama_vocab * vocab = nullptr; +}; + +// Charge le vocab depuis un GGUF Qwen3 (vocab_only=true). Aucun poids n'est chargé. +// IMPORTANT : llama_backend_init() doit déjà avoir été appelé par l'appelant. +bool kz_tok_load(KzTextTokenizer & t, const char * gguf_path); +void kz_tok_free(KzTextTokenizer & t); + +// Tokenize raw : parse_special=true pour reconnaître <|im_start|> etc. comme tokens. +std::vector kz_tok_encode(const KzTextTokenizer & t, + const std::string & text, + bool parse_special); + +// Enveloppe `content` dans le template chat Qwen3-TTS et tokenize. Renvoie la séquence +// prête à être feed dans la construction prefill_embeds. +std::vector kz_tok_encode_tts_prompt(const KzTextTokenizer & t, + const std::string & content); diff --git a/dist/jni/test_tokenizer.cpp b/dist/jni/test_tokenizer.cpp new file mode 100644 index 0000000..4295cfd --- /dev/null +++ b/dist/jni/test_tokenizer.cpp @@ -0,0 +1,57 @@ +// Test bit-exact du tokenizer C++ vs input_ids_full.bin (golden HF tokenizer). +// Usage : test_tokenizer "" +// ex: test_tokenizer Qwen3-4B-Q4_0.gguf tts_dump/input_ids_full.bin "Bonjour je m'appelle Kazeia" +// +// Critère : tokens C++ == tokens golden (byte-pour-byte). +#include "kazeia_text_tokenizer.h" +#include "llama.h" +#include +#include +#include +#include +#include + +int main(int argc, char ** argv) { + if (argc < 4) { + fprintf(stderr, "usage: %s \"\"\n", argv[0]); + return 1; + } + const char * vocab_path = argv[1]; + const char * golden_path = argv[2]; + const std::string content = argv[3]; + + llama_backend_init(); + + KzTextTokenizer tok; + if (!kz_tok_load(tok, vocab_path)) return 2; + + auto ids = kz_tok_encode_tts_prompt(tok, content); + printf("ids (%zu):", ids.size()); + for (auto i : ids) printf(" %d", i); + printf("\n"); + + // Lire golden + std::ifstream f(golden_path, std::ios::binary | std::ios::ate); + if (!f) { fprintf(stderr, "cannot open golden %s\n", golden_path); return 3; } + size_t n_bytes = (size_t)f.tellg(); + f.seekg(0); + std::vector golden(n_bytes / sizeof(int32_t)); + f.read((char*)golden.data(), n_bytes); + printf("golden (%zu):", golden.size()); + for (auto i : golden) printf(" %d", i); + printf("\n"); + + bool ok = (ids.size() == golden.size()); + if (ok) { + for (size_t i = 0; i < ids.size(); ++i) { + if (ids[i] != golden[i]) { ok = false; printf(" diff at %zu: got %d, want %d\n", i, ids[i], golden[i]); } + } + } else { + printf(" size mismatch: got %zu, want %zu\n", ids.size(), golden.size()); + } + printf(ok ? "OK BIT-EXACT\n" : "MISMATCH\n"); + + kz_tok_free(tok); + llama_backend_free(); + return ok ? 0 : 4; +} diff --git a/dist/jni/tts_pipeline.cpp b/dist/jni/tts_pipeline.cpp index 899b0b8..5fa2e66 100644 --- a/dist/jni/tts_pipeline.cpp +++ b/dist/jni/tts_pipeline.cpp @@ -24,6 +24,7 @@ #include "ggml-backend.h" #include "cp_inference.h" #include "sampler.h" +#include "kazeia_text_tokenizer.h" #include "decoder.h" // Kazeia decoder ggml (linké via libqwen3tts-decoder.a) using namespace kazeia::tts; @@ -94,12 +95,23 @@ static bool write_wav_pcm16_mono(const char* path, const float* samples, size_t } int main(int argc, char** argv) { - if (argc < 4) { printf("usage: %s [cpu|htp] [max_steps]\n", argv[0]); return 1; } + if (argc < 4) { + printf("usage: %s [cpu|htp] [max_steps]\n", argv[0]); + printf(" Texte arbitraire (au lieu de input_ids_full.bin dumpé) :\n"); + printf(" KZTTS_VOCAB_GGUF=/path/to/qwen3.gguf KZTTS_TEXT=\"phrase libre\" %s ...\n", argv[0]); + return 1; + } const char* gguf = argv[1]; std::string D = argv[2]; if (D.back() != '/') D += '/'; const char* OUT_WAV = argv[3]; bool force_cpu = (argc >= 5 && !strcmp(argv[4], "cpu")); int max_steps_arg = (argc >= 6) ? atoi(argv[5]) : 64; + // Texte arbitraire : si KZTTS_TEXT et KZTTS_VOCAB_GGUF sont posés, on tokenize la phrase + // au lieu de relire input_ids_full.bin. Vérifié bit-exact contre le golden HF tokenizer + // sur "Bonjour je m'appelle Kazeia" via test_tokenizer. + const char * kz_text = getenv("KZTTS_TEXT"); + const char * kz_vocab_gguf = getenv("KZTTS_VOCAB_GGUF"); + const bool use_kz_tok = (kz_text && kz_vocab_gguf && *kz_text && *kz_vocab_gguf); // ----- 0) Constants from manifest_text int text_vocab = 151936, text_hidden = 2048, hidden = 1024; @@ -143,7 +155,27 @@ int main(int argc, char** argv) { auto tp_fc2_b = read_f32(D + "tp_fc2_b.bin", (size_t)hidden); auto tok_embd = read_f32(D + "talker_tok_embd.bin", (size_t)3072 * hidden); auto xvector = read_f32(D + "damien_xvector.bin", (size_t)hidden); - auto input_ids = read_i32(D + "input_ids_full.bin", (size_t)input_ids_len); + + // input_ids : depuis le tokenizer C++ (texte arbitraire) ou depuis le dump golden. + // L'engin de l'app initialisera llama_backend_init plus bas dans la section talker ; + // pour pouvoir charger le vocab maintenant, on l'initialise dès ici (idempotent côté llama). + std::vector input_ids; + KzTextTokenizer kz_tok; + if (use_kz_tok) { + // Init backend AVANT chargement vocab. Le talker plus bas ré-init (idempotent côté + // llama). HMX off posé ici par sécurité (le talker le repose ensuite ; sans ça un + // init précoce du backend pourrait verrouiller HMX=on selon l'archi du vocab gguf). + setenv("GGML_HEXAGON_USE_HMX", "0", 1); + llama_backend_init(); + if (!kz_tok_load(kz_tok, kz_vocab_gguf)) { printf("vocab load FAILED: %s\n", kz_vocab_gguf); return 1; } + input_ids = kz_tok_encode_tts_prompt(kz_tok, kz_text); + input_ids_len = (int)input_ids.size(); + if (input_ids_len < 8) { printf("kz_tok: trop court (%d, min=8)\n", input_ids_len); return 1; } + printf("kz_tok: \"%s\" -> %d tokens\n", kz_text, input_ids_len); + } else { + auto v = read_i32(D + "input_ids_full.bin", (size_t)input_ids_len); + input_ids = std::move(v); + } auto tts_pad_emb_ref = read_f32(D + "tts_pad_embed.bin", (size_t)hidden); // sanity (void)tts_pad_emb_ref; @@ -406,5 +438,6 @@ int main(int argc, char** argv) { cp_free(cp_state); llama_free(ctx); llama_model_free(m); + if (use_kz_tok) kz_tok_free(kz_tok); return 0; }