diff --git a/Assets/dll/prism.dll b/Assets/dll/prism.dll
new file mode 100644
index 00000000000..ffa17d9923a
Binary files /dev/null and b/Assets/dll/prism.dll differ
diff --git a/src/BizHawk.Client.Common/lua/CommonLibs/SpeechLuaLibrary.cs b/src/BizHawk.Client.Common/lua/CommonLibs/SpeechLuaLibrary.cs
new file mode 100644
index 00000000000..06e52fd43c7
--- /dev/null
+++ b/src/BizHawk.Client.Common/lua/CommonLibs/SpeechLuaLibrary.cs
@@ -0,0 +1,150 @@
+using System.Runtime.InteropServices;
+
+namespace BizHawk.Client.Common
+{
+ ///
+ /// Lua access to screen-reader speech and braille output, for blind accessibility. Backed by the
+ /// prism screen-reader abstraction library (https://github.com/ethindp/prism, MPL-2.0), which routes
+ /// to the user's active screen reader (NVDA, JAWS, Narrator/OneCore, ...) and falls back to SAPI.
+ ///
+ [LuaLibrary(released: true)]
+ public sealed class SpeechLuaLibrary : LuaLibraryBase
+ {
+ public SpeechLuaLibrary(ILuaLibraries luaLibsImpl, ApiContainer apiContainer, Action logOutputCallback)
+ : base(luaLibsImpl, apiContainer, logOutputCallback) {}
+
+ public override string Name => "speech";
+
+ [LuaMethodExample("speech.say(\"Charizard, 142 of 142 HP\");")]
+ [LuaMethod("say", "Speaks the given text through the active screen reader (or SAPI fallback). When interrupt is true (the default), any current speech is cancelled first.")]
+ public void Say(string text, bool interrupt = true)
+ {
+ if (!PrismSpeech.Speak(text ?? string.Empty, interrupt, out var error)) Log($"speech.say failed: {error}");
+ }
+
+ [LuaMethodExample("speech.output(\"State 1 saved\");")]
+ [LuaMethod("output", "Speaks and brailles the given text (whichever the active screen reader supports). interrupt defaults to true.")]
+ public void Output(string text, bool interrupt = true)
+ {
+ if (!PrismSpeech.Output(text ?? string.Empty, interrupt, out var error)) Log($"speech.output failed: {error}");
+ }
+
+ [LuaMethodExample("speech.braille(\"HP 142/142\");")]
+ [LuaMethod("braille", "Sends the given text to a connected braille display via the active screen reader, if supported.")]
+ public void Braille(string text)
+ {
+ if (!PrismSpeech.Braille(text ?? string.Empty, out var error)) Log($"speech.braille failed: {error}");
+ }
+
+ [LuaMethodExample("speech.stop();")]
+ [LuaMethod("stop", "Stops/silences any speech currently in progress.")]
+ public void Stop()
+ {
+ if (!PrismSpeech.Stop(out var error)) Log($"speech.stop failed: {error}");
+ }
+ }
+
+ ///
+ /// Minimal P/Invoke wrapper around prism's C API (prism.dll, shipped in dll/). Initialised lazily on
+ /// first use and kept for the process lifetime. If prism or a speech backend isn't available, every
+ /// call becomes a no-op that returns an error string, so missing DLLs never crash EmuHawk.
+ ///
+ internal static class PrismSpeech
+ {
+ // PrismConfig is { uint8_t version }; current ABI version is PRISM_CONFIG_VERSION (2) for the
+ // prism.dll we ship. (See prism.h.)
+ private const byte ConfigVersion = 2;
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct PrismConfig { public byte Version; }
+
+ [DllImport("prism", EntryPoint = "prism_init", CallingConvention = CallingConvention.Cdecl)]
+ private static extern IntPtr PrismInit(ref PrismConfig cfg);
+
+ [DllImport("prism", EntryPoint = "prism_registry_acquire_best", CallingConvention = CallingConvention.Cdecl)]
+ private static extern IntPtr PrismRegistryAcquireBest(IntPtr ctx);
+
+ [DllImport("prism", EntryPoint = "prism_backend_initialize", CallingConvention = CallingConvention.Cdecl)]
+ private static extern int PrismBackendInitialize(IntPtr backend);
+
+ [DllImport("prism", EntryPoint = "prism_backend_speak", CallingConvention = CallingConvention.Cdecl)]
+ private static extern int PrismBackendSpeak(IntPtr backend, [MarshalAs(UnmanagedType.LPUTF8Str)] string text, [MarshalAs(UnmanagedType.I1)] bool interrupt);
+
+ [DllImport("prism", EntryPoint = "prism_backend_output", CallingConvention = CallingConvention.Cdecl)]
+ private static extern int PrismBackendOutput(IntPtr backend, [MarshalAs(UnmanagedType.LPUTF8Str)] string text, [MarshalAs(UnmanagedType.I1)] bool interrupt);
+
+ [DllImport("prism", EntryPoint = "prism_backend_braille", CallingConvention = CallingConvention.Cdecl)]
+ private static extern int PrismBackendBraille(IntPtr backend, [MarshalAs(UnmanagedType.LPUTF8Str)] string text);
+
+ [DllImport("prism", EntryPoint = "prism_backend_stop", CallingConvention = CallingConvention.Cdecl)]
+ private static extern int PrismBackendStop(IntPtr backend);
+
+ private const int PRISM_OK = 0;
+ private const int PRISM_ERROR_ALREADY_INITIALIZED = 15; // prism_registry_acquire_best already returns an initialised backend
+
+ private static readonly object _lock = new();
+ private static bool _tried;
+ private static IntPtr _backend;
+ private static string _initError;
+
+ private static bool EnsureInit(out string error)
+ {
+ lock (_lock)
+ {
+ if (_backend != IntPtr.Zero) { error = null; return true; }
+ if (_tried) { error = _initError; return false; }
+ _tried = true;
+ try
+ {
+ var cfg = new PrismConfig { Version = ConfigVersion };
+ var ctx = PrismInit(ref cfg);
+ if (ctx == IntPtr.Zero) { _initError = "prism_init returned null"; error = _initError; return false; }
+
+ var backend = PrismRegistryAcquireBest(ctx);
+ if (backend == IntPtr.Zero) { _initError = "no speech backend available"; error = _initError; return false; }
+
+ var rc = PrismBackendInitialize(backend);
+ if (rc != PRISM_OK && rc != PRISM_ERROR_ALREADY_INITIALIZED) { _initError = $"prism_backend_initialize returned {rc}"; error = _initError; return false; }
+
+ _backend = backend;
+ error = null;
+ return true;
+ }
+ catch (Exception ex)
+ {
+ _initError = ex.Message;
+ error = _initError;
+ return false;
+ }
+ }
+ }
+
+ public static bool Speak(string text, bool interrupt, out string error)
+ {
+ if (!EnsureInit(out error)) return false;
+ try { return PrismBackendSpeak(_backend, text, interrupt) == PRISM_OK; }
+ catch (Exception ex) { error = ex.Message; return false; }
+ }
+
+ public static bool Output(string text, bool interrupt, out string error)
+ {
+ if (!EnsureInit(out error)) return false;
+ try { return PrismBackendOutput(_backend, text, interrupt) == PRISM_OK; }
+ catch (Exception ex) { error = ex.Message; return false; }
+ }
+
+ public static bool Braille(string text, out string error)
+ {
+ if (!EnsureInit(out error)) return false;
+ try { return PrismBackendBraille(_backend, text) == PRISM_OK; }
+ catch (Exception ex) { error = ex.Message; return false; }
+ }
+
+ public static bool Stop(out string error)
+ {
+ if (!EnsureInit(out error)) return false;
+ try { return PrismBackendStop(_backend) == PRISM_OK; }
+ catch (Exception ex) { error = ex.Message; return false; }
+ }
+ }
+}