-- SPDX-FileCopyrightText: © 2026 Vladimir Zorin -- SPDX-License-Identifier: LicenseRef-OWL-1.0-or-later -- Licensed under OWL v1.0+. See LICENSE. -- Manual audition harness for sound.speech — the tool for tuning the phoneme -- tables, prosody and voice presets by ear. NOT part of run_tests.bash. -- -- lilush tests/sound/manual/speak.lua play all presets -- lilush tests/sound/manual/speak.lua computer one preset -- lilush tests/sound/manual/speak.lua computer "text..." custom line -- lilush tests/sound/manual/speak.lua --wav [dir] dump WAVs instead -- (default ./speech_demo) -- -- Playback needs a real PCM device (see smoke.lua: card/device via -- LILUSH_SND_CARD / LILUSH_SND_DEVICE). The WAV path needs no hardware — -- listen off-box, or feed a spectrogram tool when tuning formants. local sound = require("sound") -- fall back to a direct require when running against a binary built before -- the speech module existed (dev tree via ./?.lua) local speech = sound.speech or require("sound.speech") local LINES = { "Docking request granted. Proceed to pad 3.", "Warning. Hull temperature critical.", "You have arrived. Welcome to Haven station.", } -- ── tiny WAV writer (PCM s16, mono) ────────────────────────────────── local u16 = function(v) return string.char(v % 256, math.floor(v / 256) % 256) end local u32 = function(v) local b1 = v % 256 local b2 = math.floor(v / 256) % 256 local b3 = math.floor(v / 65536) % 256 local b4 = math.floor(v / 16777216) % 256 return string.char(b1, b2, b3, b4) end local write_wav = function(path, buf, rate) local n = buf:frames() local parts = {} for i = 1, n do local s = buf:get(i, 1) if s > 1 then s = 1 elseif s < -1 then s = -1 end local v = math.floor(s * 32767 + 0.5) if v < 0 then v = v + 65536 end parts[i] = string.char(v % 256, math.floor(v / 256) % 256) end local data = table.concat(parts) local f = assert(io.open(path, "wb")) f:write("RIFF", u32(36 + #data), "WAVE") f:write("fmt ", u32(16), u16(1), u16(1), u32(rate), u32(rate * 2), u16(2), u16(16)) f:write("data", u32(#data), data) f:close() end -- ── argument parsing ───────────────────────────────────────────────── local wav_dir local preset_arg, text_arg local i = 1 while arg[i] do if arg[i] == "--wav" then wav_dir = arg[i + 1] and not arg[i + 1]:match("^%-") and arg[i + 1] or "./speech_demo" if wav_dir == arg[i + 1] then i = i + 1 end elseif not preset_arg then preset_arg = arg[i] else text_arg = (text_arg and text_arg .. " " or "") .. arg[i] end i = i + 1 end local presets = {} if preset_arg then assert(speech.voices[preset_arg], "unknown preset: " .. tostring(preset_arg)) presets = { preset_arg } else for name in pairs(speech.voices) do presets[#presets + 1] = name end table.sort(presets) end local lines = text_arg and { text_arg } or LINES -- ── WAV mode (no hardware needed) ──────────────────────────────────── if wav_dir then os.execute("mkdir -p '" .. wav_dir .. "'") for _, name in ipairs(presets) do for li, line in ipairs(lines) do local buf, info = speech.say(line, { voice = name }) assert(buf, info) local path = ("%s/%s_%d.wav"):format(wav_dir, name, li) write_wav(path, buf, info.rate) print(("%-32s %.2fs %q"):format(path, info.dur, line)) end end return end -- ── playback mode ──────────────────────────────────────────────────── local lev = require("lev") local card = tonumber(os.getenv("LILUSH_SND_CARD")) or 0 local devno = tonumber(os.getenv("LILUSH_SND_DEVICE")) or 0 local dev, err = sound.open({ card = card, device = devno, rate = 48000, channels = 2 }) if not dev then io.stderr:write(("could not open audio device hw:%d,%d: %s\n"):format(card, devno, tostring(err))) io.stderr:write("(no device? use --wav to dump files instead)\n") os.exit(1) end local mx = sound.mixer({ channels = dev:channels() }) lev.run(function() local token = lev.cancel_token() sound.run(dev, mx, { cancel = token }) for _, name in ipairs(presets) do for _, line in ipairs(lines) do local buf, info = speech.say(line, { voice = name, rate = dev:rate() }) assert(buf, info) print(("%-10s %q"):format(name, line)) io.stdout:flush() mx:play(buf, { gain = 0.9 }) lev.sleep(info.dur + 0.4) end end token:cancel() lev.sleep(0.2) print("done.") end)