sound.speech.klatt

index · sound.speech

Overview

Klatt-style cascade/parallel formant synthesizer (after Klatt 1980, JASA; simplified). Pure Lua: synthesis is offline (a whole utterance renders to a buffer in milliseconds under LuaJIT), so there is no C hot path.

Input: a list of phoneme events { ph = "AA", stress = 0|1|2, dur = ms, f0 = Hz } (see sound.speech) + a voice knob table. Output: a MONO sound.buffer at 16 kHz.

Pipeline:

  1. EXPAND events -> segments (stops become closure/burst/aspiration, affricates closure/frication-release; HH inherits the neighbouring vowel's formants).
  2. TRACKS segments -> 5 ms parameter frames. Formants move by boundary dominance: at each boundary the higher-rank neighbour (consonant) fixes the value, and the transition is flown inside the lower-ranked (vowel) segment — the classic Holmes/rsynth scheme. Source amplitudes use fast (~12 ms) ramps and dip to min() at boundaries; f0 interpolates between segment midpoints.
  3. RENDER frames -> samples. Voicing = differentiated Rosenberg glottal pulse (+ flutter); noise = seeded LCG (deterministic — output is a pure function of (events, voice)); cascade branch RNP -> RNZ(anti) -> R1..R5 for voiced/aspirated sound, parallel branch (A2..A6 + bypass, alternating signs) for frication and bursts.

Voice knobs consumed here: formant (scales every resonator frequency — vocal-tract length), breath (shifts voicing into aspiration), flutter (slow f0 wobble), growl (alternate-pitch-period amplitude + period modulation — creak/diplophonia, the gravelly menace), quantize (hold parameter frames in blocks of N — the robot staircase), gain.

Functions

NameSignature
renderrender(events, voice, opts) -> buf, info/err

render(events, voice, opts) -> buf, info/err

Render phoneme events into a mono 16 kHz sample buffer

opts.yield_every (parameter frames) makes the render cooperative: every N frames it calls coroutine.yield(done_fraction) — the incremental path speech.say_job slices a long render across an application's own schedule. All render state lives in locals a yield cannot touch, so the sliced output is byte-identical to the monolithic one by construction.