Files

190 lines
4.8 KiB
Lua
Raw Permalink Normal View History

-- 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 then
return
end
-- Log before the availability check, so immediate alerts are visible in the
-- testbed transcript exactly like queued speech.
if speech.logToConsole then
print("[speech]* " .. text)
end
if 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