180 lines
4.6 KiB
Lua
180 lines
4.6 KiB
Lua
-- 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
|