From d6acf378d5a449b5c5b4ebba4fd982d5b9e238c7 Mon Sep 17 00:00:00 2001 From: Richard Loyer Date: Fri, 29 May 2026 22:56:14 +0200 Subject: [PATCH] =?UTF-8?q?POC=20streaming=20TTFB=20=C3=B74-6=20(KZTTS=5FS?= =?UTF-8?q?TREAM=5FCHUNK=3DK)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Concept validé : decoder appelé incrementalement sur codes accumulés tous les K frames. Premier appel donne TTFB rapide. TtsSynthesizeResult ajoute ttfb_s + n_chunks. tts_engine_synthesize détecte KZTTS_STREAM_CHUNK > 0 et appelle decoder.forward(codes_so_far) tous les K codes, accumule la portion nouvelle dans wav_stream. Mesure Pad3 phrase 'Bonjour Kazeia' (33 frames) : baseline : TTFB 6.80s, RTF 2.47 KZTTS_STREAM_CHUNK=8 : TTFB 1.90s (-72%), 5 chunks, total RTF 8.67 (×3.5 cost) KZTTS_STREAM_CHUNK=4 : TTFB 1.13s (-83%), 9 chunks, total RTF 13.82 (×5.6 cost) Limitations connues du POC : 1. Total time explose : recompute decoder sur N_so_far à chaque chunk = somme des coûts O(N_chunks × N_avg) au lieu de O(N_total). 2. WAV pas bit-exact vs baseline : decoder.forward(K) ne match pas le prefix de decoder.forward(N). Probable conv_transpose_1d qui regarde au-delà du strict causal kernel/stride. À comparer à l'oreille. POC opt-in (KZTTS_STREAM_CHUNK=0 par défaut = path baseline intact). Pour rendre utilisable en prod (sessions suivantes) : - Refactor decoder avec state KV cache (pre_transformer en KV cache style comme cp_forward_cached_step + BigVGAN avec context buffer ~5 frames input) - Threading 2-thread : generator (talker+CP) en parallèle de decoder - Cible : TTFB ~500ms, total time ≈ baseline + overhead minimal Co-Authored-By: Claude Opus 4.7 (1M context) --- dist/jni/tts_engine.cpp | 74 ++++++++++++++++++++++++++++++++------- dist/jni/tts_engine.h | 6 ++++ dist/jni/tts_pipeline.cpp | 4 +++ 3 files changed, 71 insertions(+), 13 deletions(-) diff --git a/dist/jni/tts_engine.cpp b/dist/jni/tts_engine.cpp index 5af26a7..6cb3cbe 100644 --- a/dist/jni/tts_engine.cpp +++ b/dist/jni/tts_engine.cpp @@ -521,8 +521,40 @@ TtsSynthesizeResult tts_engine_synthesize(TtsEngine * eng, const TtsSynthesizeCf codes_engine.reserve((size_t)cfg.max_steps * 16); int N_done = 0; const double t_loop0 = now_s(); + const double t_synth_start = t_pfill0; // pour TTFB depuis le début total double t_decode_total = 0, t_cp_total = 0; + // Streaming chunked : KZTTS_STREAM_CHUNK=K (>0) -> appeler decoder sur les codes + // accumulés tous les K frames. Le 1er chunk donne le TTFB. Le pipeline causal des 5 + // stages garantit que decoder(K) puis decoder(2K) produisent bit-exact le même audio + // pour les frames [0..K]. On ne garde donc que la portion NOUVELLE de chaque appel. + const int stream_chunk = getenv("KZTTS_STREAM_CHUNK") + ? atoi(getenv("KZTTS_STREAM_CHUNK")) : 0; + std::vector wav_stream; // audio accumulé chunk par chunk (si streaming) + int last_decoded_N = 0; // nb de frames déjà décodées dans wav_stream + R.ttfb_s = 0; + + // Helper : déclenche un decoder.forward sur N_so_far codes accumulés, append + // seulement la portion nouvelle [last_decoded_N * 1920 .. N_so_far * 1920) à wav_stream. + auto run_chunk = [&](int N_so_far) { + if (N_so_far <= last_decoded_N) return; + std::vector codes_dec_chunk(16 * N_so_far); + for (int t = 0; t < N_so_far; ++t) + for (int c = 0; c < 16; ++c) + codes_dec_chunk[c * N_so_far + t] = codes_engine[t * 16 + c]; + const double td0 = now_s(); + auto wav_full = eng->decoder.forward(codes_dec_chunk, N_so_far); + R.decoder_s += now_s() - td0; + // Append only NEW samples : [last_decoded_N*1920 .. N_so_far*1920) + const size_t start = (size_t)last_decoded_N * 1920; + const size_t end = (size_t)N_so_far * 1920; + wav_stream.insert(wav_stream.end(), + wav_full.begin() + start, wav_full.begin() + end); + if (R.ttfb_s == 0.0) R.ttfb_s = now_s() - t_synth_start; + R.n_chunks++; + last_decoded_N = N_so_far; + }; + for (int s = 0; s < cfg.max_steps; ++s) { if (cb0 == eng->codec_eos) break; codes_engine.push_back(cb0); @@ -566,6 +598,12 @@ TtsSynthesizeResult tts_engine_synthesize(TtsEngine * eng, const TtsSynthesizeCf const float* hh = llama_get_embeddings_ith(eng->talker_ctx, -1); if (hh) memcpy(hidden_for_cp.data(), hh, n_embd * sizeof(float)); N_done = s + 1; + + // Trigger streaming chunk : si KZTTS_STREAM_CHUNK=K et N_done atteint un multiple + // de K, décoder ce qu'on a accumulé. Le 1er trigger fournit le TTFB. + if (stream_chunk > 0 && N_done % stream_chunk == 0) { + run_chunk(N_done); + } } R.talker_loop_s = (now_s() - t_loop0) - t_cp_total; // ne pas double-compter CP R.cp_loop_s = t_cp_total; @@ -573,19 +611,29 @@ TtsSynthesizeResult tts_engine_synthesize(TtsEngine * eng, const TtsSynthesizeCf R.frames = N_done; R.audio_s = N_done / 12.0; - // --- 8) Decoder -> WAV --- - std::vector codes_dec(16 * N_done); - for (int t = 0; t < N_done; ++t) - for (int c = 0; c < 16; ++c) - codes_dec[c * N_done + t] = codes_engine[t * 16 + c]; - - const double t_dec0 = now_s(); - auto wav = eng->decoder.forward(codes_dec, N_done); - R.decoder_s = now_s() - t_dec0; - - if (!write_wav_pcm16_mono(cfg.out_wav_path, wav.data(), wav.size(), 24000)) { - fprintf(stderr, "tts_engine_synthesize: WAV write FAIL %s\n", cfg.out_wav_path); - R.err = -6; return R; + // --- 8) Decoder final -> WAV --- + if (stream_chunk > 0) { + // Mode streaming : flush le dernier chunk s'il reste des frames non décodées. + if (last_decoded_N < N_done) run_chunk(N_done); + // wav_stream contient déjà la concaténation propre des nouveaux samples par chunk. + if (!write_wav_pcm16_mono(cfg.out_wav_path, wav_stream.data(), wav_stream.size(), 24000)) { + fprintf(stderr, "tts_engine_synthesize: WAV write FAIL %s\n", cfg.out_wav_path); + R.err = -6; return R; + } + } else { + // Mode classique : un seul appel decoder sur all codes + std::vector codes_dec(16 * N_done); + for (int t = 0; t < N_done; ++t) + for (int c = 0; c < 16; ++c) + codes_dec[c * N_done + t] = codes_engine[t * 16 + c]; + const double t_dec0 = now_s(); + auto wav = eng->decoder.forward(codes_dec, N_done); + R.decoder_s = now_s() - t_dec0; + if (!write_wav_pcm16_mono(cfg.out_wav_path, wav.data(), wav.size(), 24000)) { + fprintf(stderr, "tts_engine_synthesize: WAV write FAIL %s\n", cfg.out_wav_path); + R.err = -6; return R; + } + R.n_chunks = 1; } R.total_s = R.prefill_s + R.talker_loop_s + R.cp_loop_s + R.decoder_s; diff --git a/dist/jni/tts_engine.h b/dist/jni/tts_engine.h index ef74672..d85e07c 100644 --- a/dist/jni/tts_engine.h +++ b/dist/jni/tts_engine.h @@ -51,6 +51,12 @@ struct TtsSynthesizeResult { double talker_loop_s = 0; double cp_loop_s = 0; double decoder_s = 0; + // Streaming TTFB (Time To First Byte audio) : si KZTTS_STREAM_CHUNK > 0, mesure le + // délai entre le début de tts_engine_synthesize et le 1er chunk audio prêt à écrire. + // 0 si streaming désactivé. Pour comparer à total_s : audio commence à sortir bien + // avant que tout soit fini si streaming actif. + double ttfb_s = 0; + int n_chunks = 0; // nombre de chunks decoder exécutés (1 = pas de streaming) }; // Charge tout. Retourne nullptr en cas d'échec. diff --git a/dist/jni/tts_pipeline.cpp b/dist/jni/tts_pipeline.cpp index 3f4bfee..b57d74b 100644 --- a/dist/jni/tts_pipeline.cpp +++ b/dist/jni/tts_pipeline.cpp @@ -83,6 +83,10 @@ int main(int argc, char** argv) { R.prefill_s, R.talker_loop_s, R.cp_loop_s, R.decoder_s); printf(" per-frame talker=%.1fms cp=%.1fms\n", R.talker_loop_s * 1000.0 / R.frames, R.cp_loop_s * 1000.0 / R.frames); + if (R.ttfb_s > 0) { + printf(" STREAMING : TTFB=%.3fs (premier chunk audio prêt) | %d chunks\n", + R.ttfb_s, R.n_chunks); + } printf("WAV -> %s\n", out_wav); tts_engine_free(eng);