8.7.17. dasLLAMA-16 — Text to Speech
dasLLAMA speaks through one small surface: a loader that reads the family from
the GGUF, a caps() call that lists the voices, and two verbs —
synthesize for the whole text at once, synthesize_stream for one
sentence at a time. The same program runs KittenTTS (kitten-nano and
kitten-mini) and Kokoro-82M.
All three are StyleTTS2-lineage models, and they share one assembly. The text
becomes phonemes, a text encoder predicts how long each phoneme lasts, a
prosody branch predicts pitch and energy, and a decoder plus an iSTFT
generator — inverse short-time Fourier transform, the step that turns
per-frame spectra back into samples — write the waveform. caps reports the
rate each model emits; the three above emit 24 kHz.
Run it with a TTS GGUF that has its two front-end packs beside it:
daslang.exe -jit tutorials/dasLLAMA/16_text_to_speech.das -- kitten-nano.gguf hello.wav
daslang.exe -jit ... -- kokoro-82m.gguf hello.wav --voice af_heart
daslang.exe -jit ... -- kitten-nano.gguf hello.wav --f32
Use -jit. Speech synthesis runs the same tuned kernels the language models
run, and the interpreter is far too slow for them.
8.7.17.1. One loader, the packs beside the model
load_tts_model reads the family from the GGUF’s own architecture metadata,
so no family name appears in the call. Two more files travel with the model:
tts_g2p.bin and tts_postag.bin, and the loader reads them from the
model’s directory. A file that is not a TTS family panics and names the
architecture it found.
var m <- load_tts_model("kitten-nano.gguf") // tts_g2p.bin + tts_postag.bin sit beside it
8.7.17.2. caps(): ask, don’t assume
caps answers what the loaded model serves: the voice names it accepts, the
language each one speaks, the rate of the PCM it hands back, and whether it can
clone a voice from a recording. Ask for a voice or a language caps does not
carry and the call panics — the request is never quietly swapped for something
the model does have.
voices can come back empty. A model file may carry packs whose
languages this front end does not phonemize — Kokoro ships fifty-four in nine
languages, and caps lists the twenty-eight it can drive — so a build that
phonemizes none of them offers none, and there is no first voice to fall back
on. Check before you index.
let c <- caps(m)
print("{length(c.voices)} voices, {c.sample_rate} Hz, clones a voice: {c.cloning}\n")
if (empty(c.voices)) {
print("this model has no voice the front end can drive\n")
return
}
let voice = c.voices[0]
8.7.17.3. Speak it
synthesize takes the text and hands back TtsAudio: mono f32 PCM, the
sample rate, and the timings. write_wav_pcm16 writes that PCM as a 16-bit
RIFF/WAVE file, which is what a player expects.
let audio <- synthesize(m, "daslang speaks.", voice)
write_wav_pcm16("hello.wav", audio.pcm, audio.sample_rate)
8.7.17.4. What the model receives
The two packs beside the GGUF are the text front end, and they run before any
model weight is touched. tts_postag.bin carries a tokenizer and a
part-of-speech tagger; tts_g2p.bin carries the lexicons that turn words
into phonemes. Three passes run in order: normalization rewrites numbers,
money, dates, times and abbreviations into the words a reader says, the tagger
labels each token, and the grapheme-to-phoneme pass turns the labelled tokens
into the 45 US phoneme symbols the model was trained on. The tag is what
settles a heteronym — read as a verb and read as a past tense are the
same letters and different sounds.
synthesize runs all three for you. Two facade calls run them alone, so you
can see what the model is asked to say. tts_normalize is the first pass,
and it needs no model — the normalizer is rules, not a pack.
print("{tts_normalize("Dr. Chen read 3.5 pages and paid $12.")}\n")
// output: Doctor Chen read three point five pages and paid twelve dollars.
tts_phonemize is the last pass, and it takes one already-normalized
sentence — the same piece synthesize hands the model. It answers in the
front end’s own inventory, before any family rewrites those symbols into the
ones it was trained on, so two models with different symbol tables give you the
same string here.
let spoken = tts_normalize("Dr. Chen read 3.5 pages and paid $12.")
print("{tts_phonemize(m, spoken)}\n")
// output: dˈɑktəɹ ʧˈɛn ɹˈɛd θɹˈi pˈYnt fˈIv pˈAʤᵻz ænd pˈAd twˈɛlv dˈɑləɹz.
read comes out ɹˈɛd, the past tense, because the tagger called it one.
That two-argument form reads American English. A synthesis, though,
phonemizes in the dialect the voice was trained on — Kokoro’s bf_emma
reads en-gb where af_heart reads en-us — so a document about what
a particular voice will say has to ask in that voice’s language, or it
describes a different sound. caps carries one language per voice in
voice_langs, beside voices; tts_voice_lang answers for a family
alias too, and panics on a voice the model cannot drive with the message a
synthesis would give.
let lang = tts_voice_lang(m, voice) // "en-us" | "en-gb"
print("{voice} reads {lang}\n")
print("{tts_phonemize(m, spoken, lang)}\n") // the string that voice is asked to say
The front end is where synthesize spends its first microseconds, and
TtsTimings counts them separately from the model stages.
8.7.17.5. Where the time went
TtsTimings is microseconds of wall clock per stage, model loading
excluded: the front end, then bert — a phoneme-level language model that gives
each phoneme the context of its neighbours — the text encoder, the durations,
the prosody branch, the decoder, the source and the generator. rtf is the
real-time factor, seconds of work per second of audio produced, so a number
below 1 is faster than real time. timings_line puts the whole split on one
line.
print("{timings_line(audio.timings, audio.sample_rate)}\n")
print("rtf {rtf(audio.timings, audio.sample_rate)}\n")
8.7.17.6. One sentence at a time
synthesize_stream runs the same pieces and hands each one to the block as
soon as it exists, in order. A player can start on the first sentence while
the second is still being made, so the wait a listener feels is the first
chunk, not the whole text. Both forms walk the same chunk list, and each chunk
draws its source noise from its own index, so the streamed audio and the
buffered audio are the same samples.
synthesize_stream(m, "First sentence. Second sentence.", voice, 1.0) $(chunk) {
print("{length(chunk.pcm)} samples at {chunk.sample_rate} Hz\n")
}
8.7.17.7. The chunker
One synthesis takes one sentence-sized piece, and tts_chunks is the
splitter synthesize runs over the normalized text. It cuts at sentence
ends and splits a sentence past TTS_CHUNK_CHARS codepoints on a word
boundary. Given the model, it follows that family’s own driver: Kitten’s
appends a comma to a piece the split left bare, so the model hears a prosodic
close; Kokoro’s sends the text as it is, because its voices read an added mark
out loud as a breath. The cap counts codepoints, not bytes — an em dash costs
three bytes and one character. A period inside Dr. or 3.5 is not a
sentence end.
for (piece in tts_chunks(m, "Dr. Smith read it. It ran to 3.5 pages.")) {
print("{piece}\n")
}
// -> "Dr. Smith read it."
// -> "It ran to 3.5 pages."
8.7.17.8. Speed and voice
speed scales the predicted durations, so the same words come out in less
time at the same pitch. The voice is a name from caps().voices; each one is
a style vector the model was trained with, and switching costs nothing but the
lookup. A KittenTTS voice carries its own speed prior on top of the number you
pass, so the same speed on two voices is not the same tempo — hold the
voice fixed when you want to read the knob.
let quick <- synthesize(m, "daslang speaks.", voice, 1.25)
let other <- synthesize(m, "daslang speaks.", c.voices[1])
8.7.17.9. The two weight lanes
The decoder and generator matrix multiplies are served from one of two prepared images beside the GGUF. The q8 lane holds those weights as Q8_0 quants and is what a load serves by default. The f32 lane holds the file’s own planes; it is the reference the parity tests hold the q8 lane against.
tts_serves_q8 answers which lane the next load takes. set_tts_q8 pins
it, and reset_tts_q8 returns to the default. The pin is process-wide
state, so it must not outlive the load it was set for: a stale pin changes the
lane of the next model this process loads. Pin through defer and every path
out resets it, a panic included.
defer() {
reset_tts_q8()
}
set_tts_q8(false) // the f32 reference lane, for this load only
print("next load serves q8: {tts_serves_q8()}\n")
var reference <- load_tts_model("kitten-nano.gguf")
See also
Full source: tutorials/dasLLAMA/16_text_to_speech.das
Previous tutorial: dasLLAMA-15 — The Prefix Cache by Hand
The other direction, speech to text: dasLLAMA-07 — Speech to Text
Serving speech over HTTP: dasLLAMA-13 — Serving Many Conversations
The speech CLI: utils/dasllama-server/txt2wav.das; a client for the
server’s /v1/audio/speech route: examples/dasLLAMA/speak_server.das