-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathScreenReader.cs
More file actions
290 lines (249 loc) · 10.1 KB
/
Copy pathScreenReader.cs
File metadata and controls
290 lines (249 loc) · 10.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
using System;
using System.Runtime.InteropServices;
namespace DateEverythingAccess
{
/// <summary>
/// Minimal Tolk wrapper for screen reader announcements.
/// </summary>
public static class ScreenReader
{
private static readonly object _speechLock = new object();
[DllImport("Tolk.dll")]
private static extern void Tolk_Load();
[DllImport("Tolk.dll")]
private static extern void Tolk_Unload();
[DllImport("Tolk.dll")]
private static extern bool Tolk_IsLoaded();
[DllImport("Tolk.dll")]
private static extern bool Tolk_HasSpeech();
[DllImport("Tolk.dll", CharSet = CharSet.Unicode)]
private static extern bool Tolk_Output(string text, bool interrupt);
[DllImport("Tolk.dll")]
private static extern bool Tolk_Silence();
[DllImport("Tolk.dll", CharSet = CharSet.Unicode)]
private static extern IntPtr Tolk_DetectScreenReader();
// Enables Tolk's built-in Windows SAPI driver as a fallback voice. Must be called
// BEFORE Tolk_Load() to take effect. When no screen reader is running, SAPI lets the
// mod still speak aloud through the OS voice instead of going silent.
[DllImport("Tolk.dll")]
private static extern bool Tolk_TrySAPI(bool trySAPI);
private static bool _available;
private static bool _usingSapiFallback;
/// <summary>
/// True when Tolk has speech but no live screen reader is running, i.e. output is
/// going through the built-in SAPI voice fallback.
/// </summary>
public static bool IsUsingSapiFallback => _usingSapiFallback;
private static bool _initialized;
private static string _lastSpokenText;
private static string _lastRepeatableText;
// Coalesced-cycle interrupt. The ambient world announcers (nearby object, room,
// screen summary, status) run as a chain every poll tick. They must NOT cut each
// other off WITHIN a tick (room + a newly-in-range object that appear on the same
// tick should both speak), but the FIRST of them in a tick SHOULD interrupt
// whatever stale announcement is still playing/queued from an earlier tick —
// otherwise walking quickly past objects queues their names behind one another
// (the bug: world focus changes pile up while menu focus changes cut off). Flow:
// the watcher calls BeginCoalescedCycle() once at the top of the ambient chain;
// the first SayCoalesced() that actually emits consumes the flag and interrupts,
// the rest in that tick append. Menus don't use this — they call Say(interrupt:true)
// directly. See the ambient block in AccessibilityWatcher.Update.
private static bool _coalescedInterruptPending;
/// <summary>
/// Loads Tolk and detects the active screen reader.
/// </summary>
public static void Initialize()
{
if (_initialized)
return;
try
{
// Enable the SAPI fallback BEFORE loading so Tolk can speak through the OS
// voice when no screen reader is running. Tolk still prefers a live screen
// reader when one is present; SAPI is only used as the last-resort driver.
Tolk_TrySAPI(true);
Tolk_Load();
_available = Tolk_IsLoaded() && Tolk_HasSpeech();
if (_available)
{
IntPtr srNamePtr = Tolk_DetectScreenReader();
if (srNamePtr != IntPtr.Zero)
{
string srName = Marshal.PtrToStringUni(srNamePtr);
Main.Log.LogInfo("Screen reader detected: " + srName);
}
else
{
// No screen reader, but Tolk still has speech — that's the SAPI voice.
_usingSapiFallback = true;
Main.Log.LogInfo("No screen reader detected; using SAPI voice fallback.");
}
}
else
{
Main.Log.LogWarning("No screen reader detected and no SAPI voice available; Tolk output disabled.");
}
}
catch (DllNotFoundException)
{
Main.Log.LogError("Tolk.dll or nvdaControllerClient64.dll is missing from the game directory.");
_available = false;
}
catch (Exception ex)
{
Main.Log.LogError("Failed to initialize Tolk: " + ex.Message);
_available = false;
}
_initialized = true;
}
/// <summary>
/// Speaks text through Tolk and optionally remembers it for replay.
/// </summary>
public static void Say(string text, bool interrupt = true, bool remember = true, bool rememberAsRepeatable = false)
{
if (string.IsNullOrWhiteSpace(text))
return;
if (remember || rememberAsRepeatable)
{
lock (_speechLock)
{
if (remember)
{
_lastSpokenText = text;
}
if (rememberAsRepeatable)
{
_lastRepeatableText = text;
}
}
}
DebugLogger.LogScreenReader(text);
if (!_available)
return;
try
{
Output(text, interrupt);
}
catch (Exception ex)
{
Main.Log.LogWarning("ScreenReader.Say failed: " + ex.Message);
}
}
/// <summary>
/// Arms the next coalesced announcement of this poll tick to interrupt. Call once
/// at the start of the ambient announcer chain.
/// </summary>
public static void BeginCoalescedCycle()
{
_coalescedInterruptPending = true;
}
/// <summary>
/// Speaks an ambient announcement that coalesces within a poll tick: the FIRST one
/// to emit after BeginCoalescedCycle() interrupts stale speech from earlier ticks;
/// later ones in the same tick append so co-occurring announcements all play. This
/// makes world focus changes (looking from object to object while walking) cut off
/// the previous one instead of queueing, matching menu behaviour, without one
/// ambient announcer clobbering another that fired on the same tick.
/// </summary>
public static void SayCoalesced(string text, bool remember = true, bool rememberAsRepeatable = false)
{
if (string.IsNullOrWhiteSpace(text))
return;
bool interrupt = _coalescedInterruptPending;
_coalescedInterruptPending = false;
Say(text, interrupt: interrupt, remember: remember, rememberAsRepeatable: rememberAsRepeatable);
}
/// <summary>
/// Sends text to Tolk, issuing an explicit silence first when interrupting.
///
/// Tolk's own interrupt flag (Tolk_Output(text, true)) bundles a cancel + speak, but with NVDA in SLEEP MODE
/// that bundled cancel does not clear NVDA's speech queue, so a new focus-change announcement gets appended
/// BEHIND whatever is still being spoken instead of cutting it off. Calling Tolk_Silence() (→ NVDA controller
/// cancelSpeech) as a separate, ordered call before the output reliably flushes the queue first, so the latest
/// announcement is spoken immediately. Harmless when NVDA is awake (silence then speak is the normal interrupt).
/// </summary>
private static void Output(string text, bool interrupt)
{
if (interrupt)
{
try
{
Tolk_Silence();
}
catch
{
// A failed pre-silence must not block the speak that follows.
}
}
Tolk_Output(text, interrupt);
}
/// <summary>
/// Repeats the most recently spoken text when one is available.
/// </summary>
public static bool RepeatLastSpoken(bool interrupt = true)
{
string lastRepeatableText;
string lastSpokenText;
lock (_speechLock)
{
lastRepeatableText = _lastRepeatableText;
lastSpokenText = _lastSpokenText;
}
if (!string.IsNullOrWhiteSpace(lastRepeatableText))
{
lastSpokenText = lastRepeatableText;
}
if (string.IsNullOrWhiteSpace(lastSpokenText))
return false;
DebugLogger.LogScreenReader(lastSpokenText);
if (!_available)
return true;
try
{
Output(lastSpokenText, interrupt);
}
catch (Exception ex)
{
Main.Log.LogWarning("ScreenReader.RepeatLastSpoken failed: " + ex.Message);
}
return true;
}
/// <summary>
/// Stops any current speech output.
/// </summary>
public static void Stop()
{
if (!_available)
return;
try
{
Tolk_Silence();
}
catch
{
}
}
/// <summary>
/// Unloads Tolk and clears cached speech state.
/// </summary>
public static void Shutdown()
{
if (!_initialized)
return;
try
{
Tolk_Unload();
}
catch
{
}
_initialized = false;
_available = false;
lock (_speechLock)
{
_lastSpokenText = null;
_lastRepeatableText = null;
}
}
}
}