-- speech.lua — the mod's only route to text-to-speech. -- -- Invariant: speech must NEVER throw. If the TTS backend is missing or broken, -- every function here becomes a no-op and the game runs exactly as vanilla. -- -- Utterances are queued within a frame rather than spoken immediately. A single -- game turn routinely emits several console.print calls ("You hit the rat.", -- "The rat dies.", "You feel cold."), and speaking each one with interrupt would -- leave you hearing only the last. flush() joins the frame's queue into one -- utterance, in order. local speech = {} local tts local available = false local failureReason local queue = {} local pendingInterrupt = false -- Mirror every utterance to stdout. Worth keeping on by default during -- development: it makes speech verifiable from the lovec console log rather than -- only by ear, which matters when diagnosing whether a hook fired at all versus -- fired with empty text. speech.logToConsole = true -------------------------------------------------------------------------------- -- Initialisation -------------------------------------------------------------------------------- function speech.initialize() -- tolk.lua calls Tolk_Load() at require time and raises if the DLL is absent, -- so this pcall is load-bearing, not defensive garnish. local ok, result = pcall(require, "accessibility.vendor.love2talk.Love2talk") if not ok then available = false failureReason = tostring(result) print("[accessibility] TTS unavailable, running silent: " .. failureReason) return false end tts = result available = true print("[accessibility] TTS initialised") return true end function speech.isAvailable() return available end function speech.getFailureReason() return failureReason end -- Whether Tolk found a real speech channel (running screen reader, or SAPI -- fallback). isAvailable() only means the library loaded. function speech.hasSpeech() if not available or not tts.hasSpeech then return false end local ok, result = pcall(tts.hasSpeech) return ok and result or false end -------------------------------------------------------------------------------- -- Text normalisation -------------------------------------------------------------------------------- -- Screen readers handle a single clean line far better than raw game strings, -- which arrive with newlines (the game wraps its own text) and padding. local function normalise(text) if text == nil then return nil end if type(text) ~= "string" then text = tostring(text) end text = text:gsub("[\r\n]+", ". ") text = text:gsub("%s+", " ") text = text:gsub("^%s+", ""):gsub("%s+$", "") if text == "" then return nil end return text end speech.normalise = normalise -------------------------------------------------------------------------------- -- Speaking -------------------------------------------------------------------------------- -- Queue text to be spoken at the end of this frame. -- interrupt=true discards anything already queued this frame AND cuts off whatever -- the screen reader is currently saying. Use it for keypress-driven output, where -- the user expects an immediate answer to the key they just pressed. function speech.say(text, interrupt) text = normalise(text) if not text then return end if interrupt then queue = {text} pendingInterrupt = true else queue[#queue + 1] = text end end -- Convenience: the common case for hotkeys. function speech.interrupt(text) speech.say(text, true) end -- Speak immediately, bypassing the queue. Reserve this for urgent alerts (taking -- damage) where waiting for end-of-frame is wrong. function speech.immediate(text, interrupt) text = normalise(text) if not text or not available then return end pcall(tts.say, text, interrupt ~= false) end -- Called once per frame from init.lua's update hook. function speech.flush() if #queue == 0 then return end local text = table.concat(queue, ". ") local shouldInterrupt = pendingInterrupt queue = {} pendingInterrupt = false if speech.logToConsole then print("[speech]" .. (shouldInterrupt and "!" or " ") .. " " .. text) end if not available then return end pcall(tts.say, text, shouldInterrupt) end function speech.silence() queue = {} pendingInterrupt = false if not available or not tts.silence then return end pcall(tts.silence) end -- Unreliable under screen readers (they don't report speaking state); only -- meaningful with the SAPI fallback. Do not use for timing. function speech.isSpeaking() if not available then return false end local ok, result = pcall(tts.isSpeaking) return ok and result or false end return speech