-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathScreenReader.cs
More file actions
787 lines (696 loc) · 31 KB
/
Copy pathScreenReader.cs
File metadata and controls
787 lines (696 loc) · 31 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
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
namespace DigimonNOAccess
{
/// <summary>
/// Screen reader output for the whole mod.
///
/// Speech goes through Prism, a screen-reader abstraction that talks to whatever
/// the player actually runs - NVDA, JAWS, SAPI, OneCore on Windows, Orca or
/// speech-dispatcher on Linux, VoiceOver on Mac. Nothing here is tied to one
/// screen reader or one operating system.
///
/// prism.dll ships next to the mod assembly and is loaded from there explicitly,
/// so the game folder does not have to be on the native search path. If it is
/// missing or no backend is available the mod stays fully functional and simply
/// says nothing - every method below is a safe no-op in that state.
/// </summary>
public static class ScreenReader
{
private const string PrismFileName = "prism.dll";
private static IntPtr _context = IntPtr.Zero;
private static IntPtr _backend = IntPtr.Zero;
private static ulong _backendFeatures;
private static string _backendName = "";
// Prism backends are not documented as thread-safe. Speech is normally raised
// from the game thread, but Harmony patches and native hooks can fire from
// wherever the game calls them, so every backend touch is serialized.
private static readonly object _speechLock = new object();
private static bool _initialized = false;
private static string _lastMessage = "";
private static ulong _backendId;
// Two engines have to be excluded by hand because they misreport themselves.
// Everything else is judged at runtime by IsBackendLive.
//
// UIA does not speak at all - it only forwards accessibility events to
// whatever assistive technology is listening - so it "works" exactly when
// Narrator is already running, and then the player already has speech.
//
// OneCore claims FEATURE_IS_SUPPORTED_AT_RUNTIME and advertises speech, but
// stays completely silent unless OneCore voices have been installed through
// Windows Settings. Confirmed on Amethyst's machine 2026-07-31: it passed the
// availability probe, appeared in the picker, and said nothing when selected.
// There is no flag that distinguishes it from a working engine, so it is
// excluded by name.
//
// Either can still be forced by setting SpeechEngineId in settings.json -
// Initialize honours a saved preference before it consults this list.
private static readonly ulong[] AlwaysHiddenBackends =
{
PrismInterop.BACKEND_UIA,
PrismInterop.BACKEND_ONE_CORE,
};
[DllImport("user32.dll")]
private static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")]
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
private static readonly uint OurProcessId = (uint)Environment.ProcessId;
private static bool _wasFocused = true;
/// <summary>True when the game window is the one the player is looking at.</summary>
private static bool IsGameFocused()
{
try
{
var hwnd = GetForegroundWindow();
if (hwnd == IntPtr.Zero) return false;
GetWindowThreadProcessId(hwnd, out uint pid);
return pid == OurProcessId;
}
catch
{
// If we cannot tell, assume focused rather than going silent.
return true;
}
}
/// <summary>
/// Called once per frame from Main. Stops speech mid-sentence when the player
/// alt-tabs away, so a long announcement does not keep talking over whatever
/// they switched to. Speak() separately refuses new speech while unfocused.
/// </summary>
public static void UpdateFocusState()
{
if (!_initialized || !ModSettings.SpeakOnlyWhenFocused)
{
_wasFocused = true;
return;
}
bool focused = IsGameFocused();
if (_wasFocused && !focused)
Silence();
_wasFocused = focused;
}
/// <summary>
/// Load Prism and pick a speech backend.
/// </summary>
/// <param name="modFolderPath">
/// Folder holding prism.dll. Defaults to the folder this assembly was loaded from.
/// </param>
/// <returns>True when speech is available.</returns>
public static bool Initialize(string modFolderPath = null)
{
if (_initialized)
return true;
try
{
if (!LoadPrismLibrary(modFolderPath))
return false;
var config = PrismInterop.prism_config_init();
_context = PrismInterop.prism_init(ref config);
if (_context == IntPtr.Zero)
{
DebugLogger.Error("[ScreenReader] prism_init failed - no speech available");
return false;
}
LogAvailableBackends();
// A saved engine preference wins, but only if it actually initializes.
ulong preferred = ModSettings.SpeechEngineId;
if (preferred != 0 && PrismInterop.prism_registry_exists(_context, preferred)
&& TryCreateBackend(preferred))
{
_initialized = true;
DebugLogger.Log($"[ScreenReader] Speaking through '{_backendName}' (saved preference)");
ApplyVoiceAndParams();
return true;
}
// Walk the registry ourselves rather than calling
// prism_registry_create_best. That helper considers every registered
// backend, including UIA and OneCore - both of which initialize
// successfully and advertise speech while producing no audible output
// unless the player has an AT consumer or OneCore voices installed.
// Picking one of those would leave the mod completely silent with no
// indication why, and there is no Tolk fallback any more.
if (!SelectFirstUsableBackend())
{
DebugLogger.Warning("[ScreenReader] Prism found no usable speech backend");
ShutdownContext();
return false;
}
_initialized = true;
DebugLogger.Log($"[ScreenReader] Speaking through '{_backendName}' (features 0x{_backendFeatures:X})");
ApplyVoiceAndParams();
return true;
}
catch (Exception ex)
{
DebugLogger.Error($"[ScreenReader] Failed to initialize Prism: {ex.Message}");
_initialized = false;
return false;
}
}
/// <summary>
/// Load prism.dll from the mod folder. Falls back to the default native search
/// path so a copy in the game root also works.
/// </summary>
private static bool LoadPrismLibrary(string modFolderPath)
{
string folder = modFolderPath;
if (string.IsNullOrEmpty(folder))
folder = Path.GetDirectoryName(typeof(ScreenReader).Assembly.Location);
if (!string.IsNullOrEmpty(folder))
{
string prismPath = Path.Combine(folder, PrismFileName);
if (File.Exists(prismPath))
{
if (NativeLibrary.TryLoad(prismPath, out _))
{
DebugLogger.Log($"[ScreenReader] Loaded {prismPath}");
return true;
}
DebugLogger.Error($"[ScreenReader] Found but could not load {prismPath} - is it the right architecture (x64)?");
return false;
}
DebugLogger.Warning($"[ScreenReader] {PrismFileName} not found at {prismPath}, trying the default search path");
}
// Last resort: let the OS resolve it (e.g. a copy sitting in the game root).
if (NativeLibrary.TryLoad(PrismFileName, out _))
{
DebugLogger.Log($"[ScreenReader] Loaded {PrismFileName} from the default search path");
return true;
}
DebugLogger.Error($"[ScreenReader] {PrismFileName} could not be loaded - the mod will run silently");
return false;
}
private static void LogAvailableBackends()
{
try
{
var count = (ulong)PrismInterop.prism_registry_count(_context);
var names = new List<string>();
for (ulong i = 0; i < count; i++)
{
ulong id = PrismInterop.prism_registry_id_at(_context, (UIntPtr)i);
names.Add(PrismInterop.GetRegistryName(_context, id));
}
DebugLogger.Log($"[ScreenReader] Prism backends ({count}): {string.Join(", ", names)}");
}
catch (Exception ex)
{
DebugLogger.Log($"[ScreenReader] Could not enumerate backends: {ex.Message}");
}
}
/// <summary>
/// Shut down speech and release Prism.
/// </summary>
public static void Shutdown()
{
lock (_speechLock)
{
if (!_initialized && _context == IntPtr.Zero)
return;
_initialized = false;
try
{
if (_backend != IntPtr.Zero)
{
PrismInterop.prism_backend_stop(_backend);
PrismInterop.prism_backend_free(_backend);
_backend = IntPtr.Zero;
}
}
catch (Exception ex)
{
DebugLogger.Log($"[ScreenReader] Error freeing backend: {ex.Message}");
}
ShutdownContext();
DebugLogger.Log("[ScreenReader] Shut down");
}
}
private static void ShutdownContext()
{
try
{
if (_context != IntPtr.Zero)
{
PrismInterop.prism_shutdown(_context);
_context = IntPtr.Zero;
}
}
catch (Exception ex)
{
DebugLogger.Log($"[ScreenReader] Error shutting down Prism context: {ex.Message}");
}
}
/// <summary>
/// Speak text through the screen reader.
/// </summary>
/// <param name="text">Text to speak</param>
/// <param name="interrupt">If true, interrupts current speech</param>
public static void Say(string text, bool interrupt = true)
{
if (string.IsNullOrEmpty(text))
return;
// Append the screen's button-hint bar, if one was just read.
//
// This lives here rather than in AnnouncementBuilder because handlers
// build their announcements in several different ways - some through the
// builder, some by hand - and the hints have to land on all of them. A
// caption is only ever read while composing the announcement for a screen
// that just opened or changed state, so the next thing spoken is that
// announcement. TakePending is one-shot, so moving the cursor afterwards
// does not repeat the hints on every item.
text = AnnouncementBuilder.WithButtonHints(text, ButtonHintCache.TakePending());
_lastMessage = text;
if (!_initialized)
return;
// Drop speech while the player is in another window, so a period spent
// alt-tabbed does not replay at them when they come back.
if (ModSettings.SpeakOnlyWhenFocused && !IsGameFocused())
return;
lock (_speechLock)
{
if (!_initialized || _backend == IntPtr.Zero)
return;
try
{
PrismError err;
if (PrismInterop.Supports(_backendFeatures, PrismInterop.FEATURE_OUTPUT))
err = PrismInterop.prism_backend_output(_backend, text, interrupt);
else
err = PrismInterop.prism_backend_speak(_backend, text, interrupt);
if (err != PrismError.Ok)
DebugLogger.Warning($"[ScreenReader] Speech failed: {PrismInterop.GetErrorString(err)}");
}
catch (Exception ex)
{
DebugLogger.Warning($"[ScreenReader] Speech output failed: {ex.Message}");
}
}
}
/// <summary>
/// Speak text without interrupting current speech.
/// The text will be queued and spoken after current speech finishes.
/// Use this for non-critical messages like field Digimon chatter.
/// </summary>
/// <param name="text">Text to speak</param>
public static void SayQueued(string text)
{
Say(text, interrupt: false);
}
/// <summary>
/// Stop current speech.
/// </summary>
public static void Silence()
{
if (!_initialized)
return;
lock (_speechLock)
{
if (!_initialized || _backend == IntPtr.Zero)
return;
try
{
PrismInterop.prism_backend_stop(_backend);
}
catch (Exception ex)
{
DebugLogger.Log($"[ScreenReader] Error in Silence: {ex.Message}");
}
}
}
/// <summary>
/// Repeat the last spoken message.
/// </summary>
public static void RepeatLast()
{
if (!string.IsNullOrEmpty(_lastMessage))
{
Say(_lastMessage, true);
}
}
/// <summary>
/// Check if screen reader is available.
/// </summary>
public static bool IsAvailable => _initialized;
/// <summary>
/// Name of the speech backend in use, for diagnostics. Empty when unavailable.
/// </summary>
public static string BackendName => _backendName;
/// <summary>Registry ID of the backend in use. 0 when unavailable.</summary>
public static ulong BackendId => _backendId;
public static bool SupportsVoice => PrismInterop.Supports(_backendFeatures, PrismInterop.FEATURE_SET_VOICE);
public static bool SupportsRate => PrismInterop.Supports(_backendFeatures, PrismInterop.FEATURE_SET_RATE);
public static bool SupportsVolume => PrismInterop.Supports(_backendFeatures, PrismInterop.FEATURE_SET_VOLUME);
// === Engine selection ===
/// <summary>
/// Speech engines the player can actually pick: only those Prism reports as
/// usable on this machine right now. Offering one that cannot speak is worse
/// than not offering it, because selecting it goes silent with no explanation.
///
/// If nothing reports itself available the full list is returned instead of an
/// empty one - an unfiltered picker still beats no picker at all.
/// </summary>
public static List<(ulong Id, string Name)> EnumerateBackends()
{
var list = new List<(ulong, string)>();
if (_context == IntPtr.Zero) return list;
try
{
foreach (ulong id in RegistryIds())
{
if (Array.IndexOf(AlwaysHiddenBackends, id) >= 0) continue;
if (!IsBackendLive(id)) continue;
list.Add((id, PrismInterop.GetRegistryName(_context, id)));
}
if (list.Count == 0)
{
DebugLogger.Log("[ScreenReader] No engine reported itself available; listing all of them");
foreach (ulong id in RegistryIds())
{
if (Array.IndexOf(AlwaysHiddenBackends, id) >= 0) continue;
list.Add((id, PrismInterop.GetRegistryName(_context, id)));
}
}
}
catch (Exception ex)
{
DebugLogger.Warning($"[ScreenReader] EnumerateBackends failed: {ex.Message}");
}
return list;
}
/// <summary>
/// Switch speech engine. The replacement is created and initialized BEFORE the
/// old one is released, so a backend the player does not actually have
/// installed leaves them on the working engine instead of going silent.
/// </summary>
public static bool SwitchBackend(ulong newId)
{
if (_context == IntPtr.Zero || newId == 0) return false;
if (newId == _backendId) return true;
if (!PrismInterop.prism_registry_exists(_context, newId))
{
DebugLogger.Warning($"[ScreenReader] Engine 0x{newId:X16} is not in the registry");
return false;
}
IntPtr candidate = PrismInterop.prism_registry_create(_context, newId);
if (candidate == IntPtr.Zero)
{
DebugLogger.Warning($"[ScreenReader] Could not create engine 0x{newId:X16}");
return false;
}
string candidateName = PrismInterop.GetBackendName(candidate);
var err = PrismInterop.prism_backend_initialize(candidate);
if (err != PrismError.Ok && err != PrismError.AlreadyInitialized)
{
DebugLogger.Warning(
$"[ScreenReader] Engine '{candidateName}' failed to initialize: {PrismInterop.GetErrorString(err)} - staying on '{_backendName}'");
PrismInterop.prism_backend_free(candidate);
return false;
}
ulong candidateFeatures = PrismInterop.prism_backend_get_features(candidate);
if (!PrismInterop.Supports(candidateFeatures, PrismInterop.FEATURE_OUTPUT) &&
!PrismInterop.Supports(candidateFeatures, PrismInterop.FEATURE_SPEAK))
{
DebugLogger.Warning($"[ScreenReader] Engine '{candidateName}' cannot speak - staying on '{_backendName}'");
PrismInterop.prism_backend_free(candidate);
return false;
}
IntPtr old;
lock (_speechLock)
{
old = _backend;
_backend = candidate;
_backendId = newId;
_backendName = candidateName;
_backendFeatures = candidateFeatures;
_initialized = true;
}
if (old != IntPtr.Zero)
{
try { PrismInterop.prism_backend_stop(old); } catch { }
PrismInterop.prism_backend_free(old);
}
ApplyVoiceAndParams();
DebugLogger.Log($"[ScreenReader] Switched engine to '{candidateName}'");
return true;
}
/// <summary>
/// Try each registered backend in order and keep the first that actually
/// initializes and can speak, skipping the ones known to be silent in
/// practice. Only if every visible backend fails do we fall back to the
/// hidden ones - a possibly-silent engine still beats no speech at all.
/// </summary>
private static bool SelectFirstUsableBackend()
{
int live = 0;
try
{
// First pass: only engines Prism reports as actually usable right now.
foreach (ulong id in RegistryIds())
{
if (Array.IndexOf(AlwaysHiddenBackends, id) >= 0) continue;
if (!IsBackendLive(id)) continue;
live++;
if (TryCreateBackend(id))
return true;
}
// Second pass: if nothing reported itself available, the runtime check
// may not be populated on this build. Try everything rather than stay
// mute.
foreach (ulong id in RegistryIds())
{
if (Array.IndexOf(AlwaysHiddenBackends, id) >= 0) continue;
if (TryCreateBackend(id))
{
DebugLogger.Warning(
$"[ScreenReader] No engine reported itself available; fell back to '{_backendName}'. "
+ "If you hear nothing, pick another engine in the Speech settings.");
return true;
}
}
DebugLogger.Error($"[ScreenReader] No backend could speak. {live} engine(s) reported available.");
}
catch (Exception ex)
{
DebugLogger.Error($"[ScreenReader] Backend selection failed: {ex.Message}");
}
return false;
}
/// <summary>Every backend ID in the registry, in registry order.</summary>
private static List<ulong> RegistryIds()
{
var ids = new List<ulong>();
try
{
var count = (ulong)PrismInterop.prism_registry_count(_context);
for (ulong i = 0; i < count; i++)
ids.Add(PrismInterop.prism_registry_id_at(_context, (UIntPtr)i));
}
catch (Exception ex)
{
DebugLogger.Warning($"[ScreenReader] Could not enumerate the registry: {ex.Message}");
}
return ids;
}
/// <summary>
/// Whether an engine is actually usable on this machine right now - NVDA
/// running, SAPI voices present, and so on. Prism answers this with
/// FEATURE_IS_SUPPORTED_AT_RUNTIME, so we create the backend just far enough
/// to read its feature mask and free it again without initializing it.
///
/// The already-active backend short-circuits to true: it is demonstrably
/// working, and tearing it down to ask would be pointless.
/// </summary>
private static bool IsBackendLive(ulong id)
{
if (id != 0 && id == _backendId && _backend != IntPtr.Zero)
return true;
IntPtr probe = IntPtr.Zero;
try
{
probe = PrismInterop.prism_registry_create(_context, id);
if (probe == IntPtr.Zero)
return false;
ulong features = PrismInterop.prism_backend_get_features(probe);
bool supported = PrismInterop.Supports(features, PrismInterop.FEATURE_IS_SUPPORTED_AT_RUNTIME);
bool canSpeak = PrismInterop.Supports(features, PrismInterop.FEATURE_OUTPUT)
|| PrismInterop.Supports(features, PrismInterop.FEATURE_SPEAK);
return supported && canSpeak;
}
catch (Exception ex)
{
DebugLogger.Log($"[ScreenReader] Availability probe failed for 0x{id:X16}: {ex.Message}");
return false;
}
finally
{
if (probe != IntPtr.Zero)
{
try { PrismInterop.prism_backend_free(probe); } catch { }
}
}
}
private static bool TryCreateBackend(ulong id)
{
IntPtr backend = PrismInterop.prism_registry_create(_context, id);
if (backend == IntPtr.Zero) return false;
string name = PrismInterop.GetBackendName(backend);
var err = PrismInterop.prism_backend_initialize(backend);
if (err != PrismError.Ok && err != PrismError.AlreadyInitialized)
{
DebugLogger.Warning($"[ScreenReader] Saved engine '{name}' failed to initialize: {PrismInterop.GetErrorString(err)}");
PrismInterop.prism_backend_free(backend);
return false;
}
ulong features = PrismInterop.prism_backend_get_features(backend);
if (!PrismInterop.Supports(features, PrismInterop.FEATURE_OUTPUT) &&
!PrismInterop.Supports(features, PrismInterop.FEATURE_SPEAK))
{
PrismInterop.prism_backend_free(backend);
return false;
}
_backend = backend;
_backendId = id;
_backendName = name;
_backendFeatures = features;
return true;
}
/// <summary>
/// Prism has no backend-to-ID getter on a live instance, so the boot-time
/// "best available" path has to match the name back against the registry.
/// Runs once.
/// </summary>
private static ulong ResolveBackendIdByName(string name)
{
try
{
var count = (ulong)PrismInterop.prism_registry_count(_context);
for (ulong i = 0; i < count; i++)
{
ulong id = PrismInterop.prism_registry_id_at(_context, (UIntPtr)i);
if (PrismInterop.GetRegistryName(_context, id) == name)
return id;
}
}
catch { }
return 0;
}
// === Voices and voice parameters ===
/// <summary>
/// Voice names on the current engine, in index order. Empty when the engine
/// does not do voice selection - screen readers use their own settings.
/// </summary>
public static List<string> EnumerateVoices()
{
var list = new List<string>();
if (!_initialized || _backend == IntPtr.Zero) return list;
if (!PrismInterop.Supports(_backendFeatures, PrismInterop.FEATURE_COUNT_VOICES)) return list;
lock (_speechLock)
{
try
{
if (PrismInterop.Supports(_backendFeatures, PrismInterop.FEATURE_REFRESH_VOICES))
PrismInterop.prism_backend_refresh_voices(_backend);
if (PrismInterop.prism_backend_count_voices(_backend, out UIntPtr count) != PrismError.Ok)
return list;
for (ulong i = 0; i < (ulong)count; i++)
{
if (PrismInterop.prism_backend_get_voice_name(_backend, (UIntPtr)i, out IntPtr namePtr) == PrismError.Ok
&& namePtr != IntPtr.Zero)
{
list.Add(Marshal.PtrToStringUTF8(namePtr) ?? $"Voice {i}");
}
else
{
list.Add($"Voice {i}");
}
}
}
catch (Exception ex)
{
DebugLogger.Warning($"[ScreenReader] EnumerateVoices failed: {ex.Message}");
}
}
return list;
}
/// <summary>
/// The voice the engine is actually speaking with right now, or null if it
/// does not expose one. Used when the player has no saved preference, so the
/// menu names the voice they can actually hear instead of guessing the first
/// in the list.
/// </summary>
public static string CurrentVoiceName
{
get
{
if (!_initialized || _backend == IntPtr.Zero) return null;
if (!PrismInterop.Supports(_backendFeatures, PrismInterop.FEATURE_GET_VOICE)) return null;
if (!PrismInterop.Supports(_backendFeatures, PrismInterop.FEATURE_GET_VOICE_NAME)) return null;
lock (_speechLock)
{
try
{
if (PrismInterop.prism_backend_get_voice(_backend, out UIntPtr index) != PrismError.Ok)
return null;
if (PrismInterop.prism_backend_get_voice_name(_backend, index, out IntPtr namePtr) != PrismError.Ok
|| namePtr == IntPtr.Zero)
return null;
return Marshal.PtrToStringUTF8(namePtr);
}
catch (Exception ex)
{
DebugLogger.Log($"[ScreenReader] Could not read the current voice: {ex.Message}");
return null;
}
}
}
}
/// <summary>
/// Select a voice by name. Names are used rather than indices because indices
/// shift when the voice list is refreshed.
/// </summary>
public static bool SetVoiceByName(string voiceName)
{
if (!_initialized || string.IsNullOrEmpty(voiceName) || !SupportsVoice) return false;
var voices = EnumerateVoices();
int index = voices.IndexOf(voiceName);
if (index < 0) return false;
lock (_speechLock)
{
var err = PrismInterop.prism_backend_set_voice(_backend, (UIntPtr)(ulong)index);
if (err != PrismError.Ok)
{
DebugLogger.Warning($"[ScreenReader] Could not select voice '{voiceName}': {PrismInterop.GetErrorString(err)}");
return false;
}
}
return true;
}
/// <summary>
/// Push the saved voice, rate and volume onto the current engine. Anything the
/// engine does not support is skipped - screen readers deliberately ignore
/// these and use the player's own screen reader settings instead.
/// </summary>
public static void ApplyVoiceAndParams()
{
if (!_initialized || _backend == IntPtr.Zero) return;
if (SupportsVoice && !string.IsNullOrEmpty(ModSettings.SpeechVoice))
SetVoiceByName(ModSettings.SpeechVoice);
lock (_speechLock)
{
try
{
if (SupportsRate)
PrismInterop.prism_backend_set_rate(_backend, Math.Clamp(ModSettings.SpeechRatePercent, 1, 100) / 100f);
if (SupportsVolume)
PrismInterop.prism_backend_set_volume(_backend, Math.Clamp(ModSettings.SpeechVolumePercent, 1, 100) / 100f);
}
catch (Exception ex)
{
DebugLogger.Warning($"[ScreenReader] Could not apply voice parameters: {ex.Message}");
}
}
}
}
}