POC streaming TTFB ÷4-6 (KZTTS_STREAM_CHUNK=K)

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) <noreply@anthropic.com>
This commit is contained in:
Richard Loyer 2026-05-29 22:56:14 +02:00
parent 27d893c73f
commit d6acf378d5
3 changed files with 71 additions and 13 deletions

View File

@ -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<float> 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<int32_t> 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<int32_t> 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<int32_t> 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;

View File

@ -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.

View File

@ -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);