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), Kokoro-82M and Pocket TTS.
The first 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. Pocket TTS is a
different lineage. A small language model reads the text as tokens and
predicts, frame by frame, a compressed picture of the sound; a codec decoder
turns those frames into samples. It has no phoneme step, and it speaks in a
voice it takes from a few seconds of audio. caps reports the rate each
model emits; all four emit 24 kHz.
Run it with a TTS GGUF — the phoneme families want their two front-end packs beside the file, a Pocket file stands alone:
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
daslang.exe -jit ... -- pocket-tts-en-q8.gguf hello.wav --clone me.wav
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. g2p_pack_path names the phoneme pack the loader
takes from a directory: tts_g2p.bin when it is there, else the
American-only tts_g2p_en_us.bin - the smaller pack a browser build ships,
which reads no British voice.
var m <- load_tts_model("kitten-nano.gguf") // tts_g2p.bin + tts_postag.bin sit beside it
print("phoneme pack: {base_name(g2p_pack_path(dir_name("kitten-nano.gguf")))}\n")
A Pocket file reads text, so no pack sits beside it. tts_needs_packs
answers that from the file’s own architecture metadata, before any load, so a
program can check the file set it is about to serve.
if (tts_needs_packs(path)) {
print("wants tts_g2p.bin + tts_postag.bin beside it\n")
}
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
A Pocket model reads text, not phonemes. tts_has_phonemes answers false
for it, and tts_phonemize panics rather than invent a string. The English
normalizer still runs in front of the English file; the other languages read
their text as it is.
if (tts_has_phonemes(m)) {
print("{tts_phonemize(m, spoken, lang)}\n")
} else {
print("{spoken}\n") // the text itself is what the model reads
}
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. A Pocket
synthesis fills four slots of its own — the prompt, the backbone, the head and
the codec — and leaves the others at zero. 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 stages a family ran 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.
caps().speed says whether the model honours a speed at all. Pocket TTS
has nothing that scales a duration, so it reads false there, and a speed
other than 1.0 panics.
if (c.speed) {
let quick <- synthesize(m, "daslang speaks.", voice, 1.25)
}
let other <- synthesize(m, "daslang speaks.", c.voices[1])
8.7.17.9. Cloning a voice
caps().cloning says whether the model takes a voice from a recording.
A Pocket TTS file with its codec encoder does: a few seconds of one speaker,
mono, at the model’s own rate, become a voice in the roster. load_audio_mono
decodes a wav, flac, mp3 or ogg file to that rate, and tts_register_voice
adds the samples under the name you give. From then on the name works like any
bundled voice. A clip longer than a minute is refused; a Pocket file converted
without the encoder (a one-voice file for a page) reports cloning false and
refuses by name; a phoneme model panics here: it has no voice to take.
if (c.cloning) {
let clip <- load_audio_mono("me.wav", c.sample_rate)
tts_register_voice(m, "me", clip, c.sample_rate)
let mine <- synthesize(m, "daslang speaks in my voice.", "me")
}
8.7.17.10. Giving the scratch back
A synthesis leaves its activation rows inside the model, sized to the largest
one so far, so the next one pays no allocation. On a phone’s browser tab that
idle memory is what runs out first. tts_release_scratch gives the rows back,
and the next synthesis grows them again for a few milliseconds. Call it when
nothing is queued: the browser examples’ speech threads call it after half a
second with no request waiting.
tts_release_scratch(m)
let again <- synthesize(m, "It speaks as before.", voice)
The rows are sized by the longest chunk a synthesis speaks, so the peak a say
holds is the chunk cap’s. A Kitten or Kokoro model speaks a sentence of up to
TTS_CHUNK_CHARS codepoints in one piece; tts_set_chunk_chars lowers
that, and the same text speaks in more, shorter pieces at a lower peak. Each
piece is its own synthesis, so a phrase cut in two is read as two phrases.
tts_set_chunk_chars(m, 120)
let low <- synthesize(m, "The same sentence, spoken in shorter pieces at a lower peak.", voice)
8.7.17.11. Prepared voice prompts
tts_prepare_voice_prompt encodes a reference into a TtsVoicePrompt.
This step needs a Pocket model with its codec encoder. The value can be serialized
with daslib/json_boost and shipped separately from the model weights.
tts_voice_prompt_valid checks the format, language, sample rate, codec geometry
and bounded latent frames against the receiving model.
tts_register_voice_prompt copies a prepared prompt into a named voice. It also
works with a compatible encoder-free Pocket file. An empty name or invalid prompt
returns false and leaves the previous voice intact. The caller owns its prompt;
the model builds conditioning state when the voice is first synthesized.
Matching geometry does not make prompts portable across unrelated model revisions.
The tutorial’s --clone path also demonstrates a JSON round trip and writes
speech from the restored prompt:
let clip <- load_audio_mono("me.wav", c.sample_rate)
var inscope prompt <- tts_prepare_voice_prompt(m, clip, c.sample_rate)
var inscope restored = TtsVoicePrompt()
if (sscan_json(sprint_json(prompt, false), restored)
&& tts_voice_prompt_valid(m, restored)) {
if (tts_register_voice_prompt(m, "prepared", restored)) {
let speech <- synthesize(m, "daslang speaks.", "prepared")
write_wav_pcm16("prepared.wav", speech.pcm, speech.sample_rate)
}
}
8.7.17.12. The 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. A published Pocket file already holds Q8_0 weights, so its q8 lane reads them as they are and its f32 lane dequantizes them. The small Pocket files hold Q4_K planes for the backbone and the codec transformers too: an unpinned load serves those planes as they are through the engine’s K-quant kernels, a third lane, while a pin to q8 or f32 requantizes or dequantizes them at load.
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