-- SPDX-FileCopyrightText: © 2026 Vladimir Zorin -- SPDX-License-Identifier: LicenseRef-OWL-1.0-or-later -- Licensed under OWL v1.0+. See LICENSE. -- Exercises sound.speech (the formant TTS) without touching audio hardware: -- text normalization, the G2P dictionary + letter-to-sound rules, prosody -- (durations, contour, the hand-set-values hook), and the Klatt renderer -- (determinism, sample sanity, pitch and formant placement via Goertzel, -- knob effects, the resample contract). local testimony = require("testimony") local text = require("sound.speech.text") local g2p = require("sound.speech.g2p") local speech = require("sound.speech") local A = testimony local RATE = speech.RATE -- phones of a word as one string, stress digits included local function phones(w) local out = {} for _, e in ipairs(g2p.word(w)) do out[#out + 1] = e.ph .. (e.stress > 0 and e.stress or "") end return table.concat(out, " ") end -- Goertzel power at frequency f over frames [a, b] of a mono buffer local function goertzel(buf, f, a, b) local w = 2 * math.pi * f / RATE local cw = 2 * math.cos(w) local s1, s2 = 0, 0 for i = a, b do local s0 = buf:get(i, 1) + cw * s1 - s2 s2 = s1 s1 = s0 end return s1 * s1 + s2 * s2 - cw * s1 * s2 end -- a clean sustained vowel for spectral checks: fixed pitch, no wobble local function vowel_buf(ph, f0, formant) local buf, info = speech.synth( { { ph = ph, dur = 400, f0 = f0 } }, { flutter = 0, breath = 0, formant = formant or 1.0 } ) assert(buf, info) return buf end local STEADY_A, STEADY_B = math.floor(0.15 * RATE), math.floor(0.30 * RATE) local t = testimony.new("== sound.speech ==") -- ── text normalization ───────────────────────────────────────────── t:that("text: words are lowercased and stripped", function() local toks = text.normalize("Hello, WORLD!") A.assert_equal("hello", toks[1].word) A.assert_equal(",", toks[2].punct) A.assert_equal("world", toks[3].word) A.assert_equal("!", toks[4].punct) end) t:that("text: numbers expand to words", function() local toks = text.normalize("pad 3") A.assert_equal("three", toks[2].word) toks = text.normalize("42") A.assert_equal("forty", toks[1].word) A.assert_equal("two", toks[2].word) toks = text.normalize("1500") A.assert_equal("one", toks[1].word) A.assert_equal("thousand", toks[2].word) A.assert_equal("five", toks[3].word) A.assert_equal("hundred", toks[4].word) end) t:that("text: decimals and negatives", function() local toks = text.normalize("-3.5") A.assert_equal("minus", toks[1].word) A.assert_equal("three", toks[2].word) A.assert_equal("point", toks[3].word) A.assert_equal("five", toks[4].word) end) t:that("text: abbreviations and emphasis markup", function() local toks = text.normalize("dr *stop*") A.assert_equal("doctor", toks[1].word) A.assert_equal("stop", toks[2].word) A.assert_true(toks[2].emph, "starred word is emphasized") end) -- ── G2P ──────────────────────────────────────────────────────────── t:that("g2p: dictionary hits carry real stress", function() A.assert_equal("DH AH", phones("the")) A.assert_equal("W AH1 N", phones("one")) A.assert_equal("D OW1 N T", phones("don't")) end) t:that("g2p: magic-e and common digraphs", function() A.assert_equal("M EY1 K", phones("make")) A.assert_equal("T AY1 M", phones("time")) A.assert_equal("F OW1 N", phones("phone")) A.assert_equal("CH EH1 K", phones("check")) A.assert_equal("N OW1", phones("know")) A.assert_equal("L AY1 T", phones("light")) end) t:that("g2p: -tion / -sion", function() A.assert_equal("S T EY1 SH AH N", phones("station")) A.assert_equal("M IH1 SH AH N", phones("mission")) A.assert_equal("V IH1 ZH AH N", phones("vision")) end) t:that("g2p: plural voicing", function() A.assert_equal("K AE1 T S", phones("cats")) A.assert_equal("D AA1 G Z", phones("dogs")) A.assert_equal("B AA1 K S IH Z", phones("boxes")) end) t:that("g2p: -ed allomorphs", function() A.assert_equal("G R AE1 N T IH D", phones("granted")) A.assert_equal("W AO1 K T", phones("walked")) A.assert_equal("P L EY1 D", phones("played")) end) t:that("g2p: rule words get first-vowel stress", function() local evs = g2p.word("docking") local first_vowel for _, e in ipairs(evs) do if e.ph == "AA" then first_vowel = e break end end A.assert_true(first_vowel and first_vowel.stress == 1, "first vowel carries primary stress") end) -- ── prosody & the events API ─────────────────────────────────────── t:that("phonemize: every event carries dur and f0", function() local evs = assert(speech.phonemize("Docking granted.")) A.assert_true(#evs > 5, "got events") for _, e in ipairs(evs) do A.assert_true(e.dur and e.dur > 0, "dur set") A.assert_true(e.f0 and e.f0 > 0, "f0 set") end A.assert_equal("SIL", evs[#evs].ph, "sentence ends in a pause") end) t:that("phonemize: question rises where a statement falls", function() local q = assert(speech.phonemize("ready?")) local s = assert(speech.phonemize("ready.")) -- compare the f0 of the LAST vowel local last_f0 = function(evs) for i = #evs, 1, -1 do if evs[i].ph ~= "SIL" then return evs[i].f0 end end end A.assert_true(last_f0(q) > last_f0(s), "question terminal is higher") end) t:that("phonemize: speed knob compresses speech", function() local slow = assert(speech.phonemize("system nominal", { speed = 1.0 })) local fast = assert(speech.phonemize("system nominal", { speed = 2.0 })) local total = function(evs) local d = 0 for _, e in ipairs(evs) do d = d + e.dur end return d end A.assert_true(total(fast) < total(slow) * 0.7, "faster speech is shorter") end) t:that("synth: hand-set dur and f0 survive prosody", function() local evs = { { ph = "AA", dur = 333, f0 = 99 }, { ph = "M" } } local buf, info = speech.synth(evs, {}) A.assert_true(buf ~= nil, tostring(info)) A.assert_equal(333, evs[1].dur, "hand-set duration untouched") A.assert_equal(99, evs[1].f0, "hand-set pitch untouched") A.assert_true(evs[2].dur and evs[2].dur > 0, "missing fields filled") end) -- ── the renderer ─────────────────────────────────────────────────── t:that("say: deterministic — same input, identical samples", function() local a = assert(speech.say("Docking request granted.", { voice = "computer" })) local b = assert(speech.say("Docking request granted.", { voice = "computer" })) A.assert_equal(a:tostring(), b:tostring()) end) t:that("say: samples are finite, bounded, and not silence", function() local buf = assert(speech.say("warning", {})) local peak, energy = 0, 0 for i = 1, buf:frames() do local s = buf:get(i, 1) A.assert_true(s == s, "no NaN") local a = s < 0 and -s or s if a > peak then peak = a end energy = energy + s * s end A.assert_true(peak <= 1.0, "bounded") A.assert_true(energy / buf:frames() > 1e-4, "audible") end) t:that("synth: duration follows the event durations", function() local buf, info = speech.synth({ { ph = "AA", dur = 500, f0 = 100 } }, {}) A.assert_true(buf ~= nil, tostring(info)) -- rendered length is the event total rounded up to a 5 ms frame A.assert_true(math.abs(info.dur - 0.5) < 0.01, "0.5s vowel renders ~0.5s") A.assert_equal(buf:frames(), math.floor(info.dur * RATE + 0.5)) end) t:that("synth: pitch lands on the commanded f0", function() local buf = vowel_buf("AA", 100) local on = goertzel(buf, 100, STEADY_A, STEADY_B) local off = goertzel(buf, 131, STEADY_A, STEADY_B) -- not a harmonic A.assert_true(on > off * 10, "f0=100 line dominates") buf = vowel_buf("AA", 140) on = goertzel(buf, 140, STEADY_A, STEADY_B) off = goertzel(buf, 100, STEADY_A, STEADY_B) A.assert_true(on > off * 10, "f0=140 line dominates") end) t:that("synth: the formant knob moves the spectrum", function() -- AA has F1 ~ 730; at formant = 1.3 that pole sits near 950. With f0 = -- 100 the energy lives on the harmonics, so probe the ones nearest the -- pole in each case: 700 Hz (plain) vs 1000 Hz (scaled). local plain = vowel_buf("AA", 100, 1.0) local scaled = vowel_buf("AA", 100, 1.3) local p_plain = goertzel(plain, 700, STEADY_A, STEADY_B) / goertzel(plain, 1000, STEADY_A, STEADY_B) local p_scaled = goertzel(scaled, 700, STEADY_A, STEADY_B) / goertzel(scaled, 1000, STEADY_A, STEADY_B) A.assert_true(p_plain > 1, "unscaled: the 700 Hz harmonic rides F1") A.assert_true(p_scaled < p_plain, "scaled: energy shifted up toward 1 kHz") end) t:that("synth: the growl knob puts energy on the subharmonic", function() -- alternate-period modulation is period doubling: with growl on, a line -- appears at f0/2 that a clean render does not have local clean = vowel_buf("AA", 100) local rough = assert(speech.synth({ { ph = "AA", dur = 400, f0 = 100 } }, { flutter = 0, breath = 0, growl = 0.5 })) local sub_clean = goertzel(clean, 50, STEADY_A, STEADY_B) / goertzel(clean, 100, STEADY_A, STEADY_B) local sub_rough = goertzel(rough, 50, STEADY_A, STEADY_B) / goertzel(rough, 100, STEADY_A, STEADY_B) A.assert_true(sub_rough > sub_clean * 5, "f0/2 line rises under growl") -- still deterministic and bounded local again = assert(speech.synth({ { ph = "AA", dur = 400, f0 = 100 } }, { flutter = 0, breath = 0, growl = 0.5 })) A.assert_equal(rough:tostring(), again:tostring()) for i = 1, rough:frames(), 11 do local s = rough:get(i, 1) A.assert_true(s == s and s >= -1 and s <= 1, "bounded, finite") end end) t:that("synth: whisper has no pitch line", function() local voiced = vowel_buf("AA", 100) local buf = assert(speech.synth({ { ph = "AA", dur = 400, f0 = 100 } }, { breath = 1.0, flutter = 0 })) local vr = goertzel(voiced, 100, STEADY_A, STEADY_B) local wr = goertzel(buf, 100, STEADY_A, STEADY_B) A.assert_true(wr < vr / 10, "breath = 1 kills the harmonic") end) t:that("say: resample contract (opts.rate)", function() local buf, info = speech.say("test", {}) local buf48, info48 = speech.say("test", { rate = 48000 }) A.assert_true(buf48 ~= nil, tostring(info48)) A.assert_equal(48000, info48.rate) local want = math.floor(buf:frames() * 48000 / RATE) A.assert_true(math.abs(buf48:frames() - want) <= 3, "3x the frames at 48 kHz") end) t:that("presets all render", function() for name in pairs(speech.voices) do local buf, info = speech.say("stand by", { voice = name }) A.assert_true(buf ~= nil, name .. ": " .. tostring(info)) A.assert_true(info.dur > 0.2, name .. " produced audio") end end) t:that("errors: unknown voice, unknown phoneme, empty text", function() local buf, err = speech.say("hi", { voice = "nope" }) A.assert_true(buf == nil and err:match("unknown voice"), "unknown voice") buf, err = speech.synth({ { ph = "QQ" } }, {}) A.assert_true(buf == nil and err:match("unknown phoneme"), "unknown phoneme") buf, err = speech.say("...", {}) A.assert_true(buf == nil, "nothing to say") end) t:conclude()