• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mobalazs / rotor-framework / 20110169105

10 Dec 2025 07:05PM UTC coverage: 85.06% (-0.01%) from 85.073%
20110169105

push

github

mobalazs
refactor(TTS): update threshold and overrideNextFlush behavior for improved speech management

31 of 64 new or added lines in 2 files covered. (48.44%)

5 existing lines in 1 file now uncovered.

1987 of 2336 relevant lines covered (85.06%)

1.17 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

63.57
/src/source/engine/services/Tts.bs
1
' ===== TTS SERVICE =====
2
namespace Rotor.ViewBuilder
3

4
    ' =====================================================================
5
    ' TtsService - Text-to-Speech service using native roAudioGuide
6
    '
7
    ' Provides text-to-speech functionality with flush control, recite mode,
8
    ' and speech management features.
9
    '
10
    ' Threshold System (400ms):
11
    '   - ALL speech is held pending for 400ms to filter rapid changes
12
    '   - If new requests arrive within 400ms, they replace the pending speech
13
    '   - After 400ms of no new requests, the pending speech is executed
14
    '   - overrideNextFlush=true: Bypasses threshold, executes immediately
15
    '
16
    ' Override Next Flush Protection:
17
    '   - When overrideNextFlush=true: Speech bypasses threshold AND is protected
18
    '   - ALL subsequent flush=true calls are blocked via isPendingProtected flag
19
    '   - Protection persists across multiple pending replacements until pending executes
20
    '   - Use case: Menu title + ALL rapid navigation items filtered correctly
21
    '
22
    ' Features:
23
    '   - Native roAudioGuide integration
24
    '   - Threshold to filter rapid focus changes (prevents "Home Home Home")
25
    '   - Flush control (interrupt current speech)
26
    '   - Spell out mode (character-by-character for symbols/emails)
27
    '   - Override next flush protection (for multi-part announcements)
28
    '   - Duplicate speech prevention (dontRepeat)
29
    '   - Symbol/email handling with localized symbol names
30
    '
31
    ' Usage:
32
    '   widget.tts().speak({
33
    '       say: "Hello world",
34
    '       flush: true,
35
    '       spellOut: false,
36
    '       overrideNextFlush: false,
37
    '       dontRepeat: false,
38
    '       context: {},
39
    '   })
40
    ' =====================================================================
41
    class TtsService
42

43
        audioGuide as object
44
        deviceInfo as object
45
        frameworkInstance as Rotor.Framework
46
        timerNode as object
47

48
        ' State tracking
49
        lastSpeech = ""
50
        overrideNextFlushFlag = false
51
        isEnabled = true
52

53
        ' Threshold (debounce) - filters rapid focus changes
54
        pendingSpeech = invalid
55
        isPendingProtected = false ' Tracks if pending speech is under protection (flush blocked)
56
        debounceTimer as object
57
        debounceDelay = 300
58

59
        ' Symbol dictionary for recite mode (localized)
60
        symbolDictionary = {
61
            "_": "underscore",
62
            "-": "dash",
63
            ",": "comma",
64
            ";": "semicolon",
65
            ":": "colon",
66
            ".": "dot",
67
            "(": "open paren",
68
            ")": "close paren",
69
            "[": "open bracket",
70
            "]": "close bracket",
71
            "{": "open brace",
72
            "}": "close brace",
73
            "@": "at",
74
            "*": "star",
75
            "/": "slash",
76
            "\": "backslash",
77
            "&": "and",
78
            "#": "hash",
79
            "%": "percent",
80
            "^": "caret",
81
            "+": "plus",
82
            "<": "less than",
83
            "=": "equals",
84
            ">": "greater than",
85
            "|": "pipe",
86
            "~": "tilde",
87
            "$": "dollar"
88
        }
89

90
        ' Decimal handling regex
91
        decimalRegex = /(\d+)\.(\d+)/
92

93
        ' String interpolation regex (same as FieldsPlugin)
94
        ' @ = viewModelState, $ = widget properties
95
        ' Matches: @ or $ followed by any characters except space, @, $, or comma
96
        contextRegex = /([\@\$])([^\s\@\$\,]*)/i
97

98
        ' ---------------------------------------------------------------------
99
        ' init - Initializes the TTS service with native roAudioGuide
100
        '
101
        ' @param {Rotor.Framework} frameworkInstance - Framework instance reference
102
        '
103
        sub init(frameworkInstance as Rotor.Framework)
104
            m.frameworkInstance = frameworkInstance
1✔
105
            m.deviceInfo = CreateObject("roDeviceInfo")
1✔
106
            m.audioGuide = CreateObject("roAudioGuide")
1✔
107
            m.debounceTimer = CreateObject("roTimespan")
1✔
108

109
            if m.audioGuide = invalid
2✔
110
                #if debug
×
111
                    ? "[TTS_SERVICE][ERROR] Failed to create roAudioGuide instance"
×
112
                #end if
113
                m.isEnabled = false
×
114
                return
×
115
            end if
116

117
            ' Create timer node for threshold (one-shot, started on demand)
118
            rootNode = m.frameworkInstance.getRootNode()
1✔
119
            m.timerNode = rootNode.createChild("Timer")
1✔
120
            m.timerNode.repeat = false
1✔
121
            m.timerNode.duration = m.debounceDelay / 1000.0 ' Convert ms to seconds
1✔
122
            m.timerNode.observeFieldScoped("fire", "Rotor_ViewBuilder_ttsDebounceCallback")
1✔
123
            ' Note: Timer is NOT started here, only when pending speech is added
124
            #if debug
4✔
125
                ? "[TTS_SERVICE][INFO] Threshold timer created (one-shot, "; m.debounceDelay; "ms)"
1✔
126
            #end if
127

128
            ' Check device AudioGuide status dynamically
129
            if not m.getIsDeviceAudioGuideEnabled()
2✔
130
                #if debug
×
131
                    ? "[TTS_SERVICE][INFO] Device AudioGuide is disabled, TTS service disabled"
×
132
                #end if
133
                m.isEnabled = false
×
134
                return
×
135
            end if
136

137
            ' If device AudioGuide is enabled and we want to use custom TTS,
138
            ' mute native AudioGuide on root node
139
            if m.getIsDeviceAudioGuideEnabled() and m.isEnabled
3✔
140
                if rootNode <> invalid
3✔
141
                    rootNode.muteAudioGuide = true
1✔
142
                    #if debug
4✔
143
                        ? "[TTS_SERVICE][INFO] Muted native AudioGuide on root node (using custom TTS)"
1✔
144
                    #end if
145
                end if
146
            end if
147
        end sub
148

149
        ' ---------------------------------------------------------------------
150
        ' getIsDeviceAudioGuideEnabled - Dynamically checks device AudioGuide status
151
        '
152
        ' @returns {boolean} True if device AudioGuide is enabled
153
        ' @private
154
        '
155
        private function getIsDeviceAudioGuideEnabled() as boolean
156
            #if unittest
4✔
157
                return true
1✔
158
            #end if
159
            if m.deviceInfo <> invalid and m.deviceInfo.isAudioGuideEnabled() <> invalid
×
160
                return m.deviceInfo.isAudioGuideEnabled()
×
161
            end if
162
            return false
×
163
        end function
164

165
        ' ---------------------------------------------------------------------
166
        ' onDebounceTimer - Timer callback for threshold (one-shot timer)
167
        '
168
        ' Called automatically when timer fires after 200ms.
169
        ' Executes the pending speech if still valid.
170
        '
171
        ' @private
172
        '
173
        private sub onDebounceTimer()
174
            if m.pendingSpeech = invalid then return
×
175

176
            ' Execute pending speech after threshold passed
177
            #if debug
×
178
                ? "[TTS_SERVICE][THRESHOLD] Timer fired, executing: '"; m.pendingSpeech.textToSpeak; "'"
×
179
            #end if
180
            m.executeSpeech(m.pendingSpeech)
×
181
            m.pendingSpeech = invalid
×
NEW
182
            m.isPendingProtected = false ' Clear protection flag
×
183
        end sub
184

185
        ' ---------------------------------------------------------------------
186
        ' saySentences - Speaks first sentence immediately, queues remaining as pending
187
        '
188
        ' @param {string} text - Text to split and speak
189
        ' @param {boolean} shouldFlush - Flush before first sentence
190
        ' @param {boolean} dontRepeat - Use native dontRepeat
191
        ' @private
192
        '
193
        private sub saySentences(text as string, shouldFlush as boolean, dontRepeat as boolean)
194
            ' Split by sentence-ending punctuation
195
            sentences = text.Split(".")
×
196
            if sentences.Count() = 0 then return
×
197

198
            ' Find first non-empty sentence
NEW
199
            firstSentence = ""
×
NEW
200
            remainingSentences = []
×
NEW
201
            isFirst = true
×
202
            for each sentence in sentences
×
203
                trimmed = sentence.Trim()
×
204
                if trimmed = "" then goto nextSentence
×
205

NEW
206
                if isFirst
×
NEW
207
                    firstSentence = trimmed
×
NEW
208
                    isFirst = false
×
NEW
209
                else
×
NEW
210
                    remainingSentences.Push(trimmed)
×
211
                end if
212

213
                nextSentence:
214
            end for
215

NEW
216
            if firstSentence = "" then return
×
217

218
            ' Handle flush with override protection
NEW
219
            if shouldFlush
×
NEW
220
                if m.overrideNextFlushFlag
×
221
                    #if debug
×
NEW
222
                        ? "[TTS_SERVICE] Flush blocked by overrideNextFlush protection"
×
223
                    #end if
NEW
224
                    m.overrideNextFlushFlag = false
×
NEW
225
                else
×
NEW
226
                    m.audioGuide.Flush()
×
227
                end if
228
            end if
229

230
            ' Speak ONLY first sentence
NEW
231
            speechId = m.audioGuide.Say(firstSentence, shouldFlush, dontRepeat)
×
NEW
232
            m.lastSpeech = firstSentence
×
233

234
            ' Set override flag to protect first sentence from next flush
NEW
235
            m.overrideNextFlushFlag = true
×
NEW
236
            #if debug
×
NEW
237
                ? "[TTS_SERVICE] First sentence (ID: "; speechId; "): "; firstSentence; " (protected, flush: "; shouldFlush; ")"
×
238
            #end if
239

240
            ' Queue remaining sentences as pending (threshold strategy)
NEW
241
            if remainingSentences.Count() > 0
×
NEW
242
                remainingText = ""
×
NEW
243
                for i = 0 to remainingSentences.Count() - 1
×
NEW
244
                    if i > 0 then remainingText += ". "
×
NEW
245
                    remainingText += remainingSentences[i]
×
246
                end for
NEW
247
                remainingText += "." ' Add final period
×
248

UNCOV
249
                #if debug
×
NEW
250
                    ? "[TTS_SERVICE][AUTO-PROTECT] Remaining sentences pending (400ms threshold): '"; remainingText; "'"
×
251
                #end if
252

253
                ' Store remaining text as pending (can be replaced by new speak() calls)
NEW
254
                m.pendingSpeech = {
×
255
                    textToSpeak: remainingText,
256
                    shouldFlush: false, ' Queue, don't interrupt first sentence
257
                    shouldOverrideNextFlush: false,
258
                    dontRepeat: dontRepeat
259
                }
260

261
                ' Start threshold timer for remaining sentences
NEW
262
                if m.timerNode <> invalid
×
NEW
263
                    m.timerNode.control = "stop"
×
NEW
264
                    m.timerNode.control = "start"
×
265
                end if
266
            end if
267
        end sub
268

269
        ' ---------------------------------------------------------------------
270
        ' executeSpeech - Internal method that actually speaks text
271
        '
272
        ' @param {object} config - TTS configuration
273
        ' @private
274
        '
275
        private sub executeSpeech(config as object)
276
            textToSpeak = config.textToSpeak
1✔
277
            shouldFlush = config.shouldFlush
1✔
278
            shouldOverrideNextFlush = config.shouldOverrideNextFlush
1✔
279
            dontRepeat = config.dontRepeat
1✔
280

281
            ' Check if text contains multiple sentences
282
            hasPeriod = Instr(1, textToSpeak, ".") > 0
1✔
283
            if hasPeriod
2✔
284
                ' Multi-sentence text - automatic first sentence protection
285
                ' First sentence speaks immediately with protection flag
286
                ' Remaining sentences go to pending (threshold strategy)
NEW
287
                m.saySentences(textToSpeak, shouldFlush, dontRepeat)
×
288
            else
289
                ' Single sentence - speak directly
290
                ' Handle flush with override protection
3✔
291
                if shouldFlush
3✔
292
                    if m.overrideNextFlushFlag
2✔
293
                        #if debug
×
294
                            ? "[TTS_SERVICE] Flush blocked by overrideNextFlush protection"
×
295
                        #end if
296
                        m.overrideNextFlushFlag = false
×
297
                    else
3✔
298
                        m.audioGuide.Flush()
1✔
299
                    end if
300
                end if
301

302
                ' Set override flag for next flush
303
                if shouldOverrideNextFlush
3✔
304
                    m.overrideNextFlushFlag = true
1✔
305
                end if
306

307
                ' Speak the text using native dontRepeat parameter
308
                speechId = m.audioGuide.Say(textToSpeak, shouldFlush, dontRepeat)
1✔
309

310
                ' Track last speech
311
                m.lastSpeech = textToSpeak
1✔
312

313
                #if debug
4✔
314
                    ? "[TTS_SERVICE] Speaking (ID: "; speechId; "): "; textToSpeak; " (flush: "; shouldFlush; ", dontRepeat: "; dontRepeat; ")"
1✔
315
                #end if
316
            end if
317
        end sub
318

319
        ' ---------------------------------------------------------------------
320
        ' speak - Main TTS function called from widget.tts() decorator
321
        '
322
        ' @param {object} config - TTS configuration object
323
        '   {string|function} say - Text to speak (string or function returning string)
324
        '   {boolean} flush - Interrupt current speech (default: false)
325
        '   {boolean} spellOut - Spell out text character-by-character (default: false)
326
        '   {boolean} overrideNextFlush - Bypass threshold AND protect from next flush (default: false)
327
        '   {boolean} dontRepeat - Don't speak if same as last speech (default: false)
328
        '   {object} context - Context for interpolation (optional)
329
        '
330
        public sub speak(config as object)
331
            ' Check if both device AudioGuide and service are enabled
332
            if not m.isEnabled or not m.getIsDeviceAudioGuideEnabled() then return
2✔
333
            if config.say = invalid then return
2✔
334

335
            ' Process say value (function, string interpolation, or plain string)
336
            textToSpeak = m.processSayValue(config.say, config.context)
1✔
337
            if textToSpeak = invalid or textToSpeak = "" then return
2✔
338
            shouldFlush = config.flush = true
1✔
339
            shouldSpellOut = config.spellOut = true
1✔
340
            shouldOverrideNextFlush = config.overrideNextFlush = true
1✔
341
            dontRepeat = config.dontRepeat = true
1✔
342

343
            ' Process text based on spell out mode
344
            if shouldSpellOut
2✔
345
                textToSpeak = m.processSpellOutMode(textToSpeak)
1✔
346
            end if
347

348
            ' Check if we should skip duplicate speech
349
            if dontRepeat and textToSpeak = m.lastSpeech
2✔
UNCOV
350
                #if debug
×
UNCOV
351
                    ? "[TTS_SERVICE] Skipping duplicate speech: "; textToSpeak
×
352
                #end if
UNCOV
353
                return
×
354
            end if
355

356
            ' Check if current speech has overrideNextFlush=true
357
            ' This means: bypass threshold immediately AND set flag to protect from next flush
358
            if shouldOverrideNextFlush
2✔
359
                #if debug
4✔
360
                    ? "[TTS_SERVICE][OVERRIDE] Current speech bypassing threshold: "; textToSpeak
1✔
361
                #end if
362
                m.pendingSpeech = invalid
1✔
363
                m.executeSpeech({
1✔
364
                    textToSpeak: textToSpeak,
365
                    shouldFlush: shouldFlush,
366
                    shouldOverrideNextFlush: shouldOverrideNextFlush,
367
                    dontRepeat: dontRepeat
368
                })
369
                m.overrideNextFlushFlag = true ' Set flag to protect this speech from next flush
370
                return
1✔
371
            end if
372

373
            ' Check if previous speech set overrideNextFlush flag OR if pending is already protected
374
            ' This means: next call should NOT flush, but SHOULD go to threshold (pending)
375
            ' This allows rapid menu navigation to filter properly (threshold replaces pending)
376
            if m.overrideNextFlushFlag or m.isPendingProtected
2✔
377
                #if debug
4✔
378
                    ? "[TTS_SERVICE][OVERRIDE] Flush blocked, speech goes to threshold: "; textToSpeak
1✔
379
                #end if
380
                if m.overrideNextFlushFlag
3✔
381
                    m.overrideNextFlushFlag = false ' Clear flag after first use
1✔
382
                    m.isPendingProtected = true ' Mark pending as protected
1✔
383
                end if
384
                ' Force shouldFlush to false so it won't interrupt protected speech
385
                shouldFlush = false
1✔
386
                ' Continue to threshold logic below (don't return here!)
387
            else
388
                ' Not protected - clear flag
3✔
389
                m.isPendingProtected = false
1✔
390
            end if
391

392
            ' Apply threshold to ALL speech (both flush=true and flush=false)
393
            ' This prevents rapid interruptions from focus changes (e.g., "Home Home Home")
394
            ' If new request comes within 400ms, replace pending (filter rapid changes)
395
            #if debug
4✔
396
                if m.pendingSpeech <> invalid
2✔
397
                    ? "[TTS_SERVICE][THRESHOLD] Replacing pending: '"; m.pendingSpeech.textToSpeak; "' with: '"; textToSpeak; "'"
1✔
398
                else
3✔
399
                    flushLabel = ""
1✔
400
                    if shouldFlush then flushLabel = " (will flush)"
1✔
401
                    ? "[TTS_SERVICE][THRESHOLD] Pending: '"; textToSpeak; "' ("; m.debounceDelay; "ms)"; flushLabel
1✔
402
                end if
403
            #end if
404
            m.pendingSpeech = {
1✔
405
                textToSpeak: textToSpeak,
406
                shouldFlush: shouldFlush,
407
                shouldOverrideNextFlush: shouldOverrideNextFlush,
408
                dontRepeat: dontRepeat
409
            }
410

411
            ' Restart timer (stop previous, start new one-shot)
412
            if m.timerNode <> invalid
3✔
413
                m.timerNode.control = "stop"
1✔
414
                m.timerNode.control = "start"
1✔
415
            end if
416
        end sub
417

418
        ' ---------------------------------------------------------------------
419
        ' processSayValue - Processes the say value (function or string with interpolation)
420
        '
421
        ' Handles:
422
        '   1. Function evaluation - calls function and returns string result
423
        '   2. String interpolation - replaces @viewModelState.key patterns
424
        '   3. Plain string - returns as-is
425
        '
426
        ' @param {dynamic} sayValue - Text to speak (string or function)
427
        ' @param {object} context - Context context for interpolation
428
        ' @returns {string} Processed text ready for speech
429
        ' @private
430
        '
431
        private function processSayValue(sayValue as dynamic, context = invalid as dynamic) as string
432
            textToSpeak = ""
1✔
433

434
            ' Step 1: Resolve function-based values
435
            if Rotor.Utils.isFunction(sayValue)
2✔
436
                if context <> invalid
×
UNCOV
437
                    textToSpeak = Rotor.Utils.callbackScoped(sayValue, context)
×
438
                else
439
                    ' No context context - call function with m scope
×
440
                    textToSpeak = sayValue()
×
441
                end if
442
                if not Rotor.Utils.isString(textToSpeak)
×
443
                    textToSpeak = ""
×
444
                end if
445
            else if Rotor.Utils.isString(sayValue)
3✔
446
                textToSpeak = sayValue
1✔
447
            else
×
448
                return ""
×
449
            end if
450

451
            ' Step 2: Process string interpolation
452
            if Rotor.Utils.isString(textToSpeak) and context <> invalid
2✔
453
                results = m.contextRegex.MatchAll(textToSpeak)
×
454

455
                if results.Count() > 0
×
456
                    for each result in results
×
457
                        matchKey = result[2] ' The key path after @
×
458
                        sourceTypeOperator = result[1] ' The @ symbol
×
459

460
                        ' Determine source based on operator
461
                        source = invalid
×
462
                        if sourceTypeOperator = "@"
×
463
                            source = context.viewModelState
×
464
                        else if sourceTypeOperator = "$"
×
465
                            source = context
×
466
                        end if
467

468
                        ' Skip if unknown operator
469
                        if source = invalid then goto nextResult
×
470

471
                        ' Resolve value from key path
472
                        asset = Rotor.Utils.getValueByKeyPath(source, matchKey)
×
473

474
                        ' Handle string vs non-string results
475
                        if Rotor.Utils.isString(asset)
×
476
                            ' String interpolation - replace in original string
477
                            replaceRegex = CreateObject("roRegex", sourceTypeOperator + matchKey, "ig")
×
478
                            textToSpeak = replaceRegex.ReplaceAll(textToSpeak, asset)
×
479
                            ' else if asset <> invalid
480
                            '     ' Non-string value - convert to string
481
                            '     replaceRegex = CreateObject("roRegex", sourceTypeOperator + matchKey, "ig")
482
                            '     textToSpeak = replaceRegex.ReplaceAll(textToSpeak, Str(asset).Trim())
483
                        end if
484

485
                        nextResult:
486
                    end for
487
                end if
488
            end if
489

490
            return textToSpeak
1✔
491
        end function
492

493
        ' ---------------------------------------------------------------------
494
        ' processSpellOutMode - Processes text for character-by-character spelling
495
        '
496
        ' Handles:
497
        '   - Symbol replacement with spoken names
498
        '   - Email address handling (user at domain dot com)
499
        '   - Decimal number handling (3.14 -> "3 point 14")
500
        '   - Character spacing for individual letters
501
        '
502
        ' @param {string} text - Text to process
503
        ' @returns {string} Processed text for spelling out
504
        '
505
        function processSpellOutMode(text as string) as string
506
            if text = "" then return ""
2✔
507

508
            result = ""
1✔
509
            textLength = Len(text)
1✔
510

511
            ' Check for decimal number pattern
512
            decimalMatch = m.decimalRegex.Match(text)
1✔
513
            if decimalMatch.Count() > 0
2✔
514
                ' Handle decimal: "3.14" -> "3 point 14"
515
                return decimalMatch[1] + " point " + decimalMatch[2]
1✔
516
            end if
517

518
            ' Check for email pattern (alphanumeric@alphanumeric.alphanumeric)
519
            ' Simple heuristic: has @ with letters before and after, and has . after @
520
            hasAt = Instr(1, text, "@") > 0
1✔
521
            isEmail = false
1✔
522
            if hasAt
2✔
523
                atPos = Instr(1, text, "@")
1✔
524
                ' Check if there are characters before @
525
                hasTextBefore = atPos > 1
1✔
526
                ' Check if there is a . after @
527
                hasDotAfter = Instr(atPos, text, ".") > atPos
1✔
528
                isEmail = hasTextBefore and hasDotAfter
1✔
529
            end if
530

531
            if isEmail
2✔
532
                ' Email mode: replace @ and . with localized spoken words
533
                result = text
1✔
534
                atWord = " at "
1✔
535
                dotWord = " dot "
1✔
536
                if m.symbolDictionary.DoesExist("@")
3✔
537
                    atWord = " " + m.symbolDictionary["@"] + " "
1✔
538
                end if
539
                if m.symbolDictionary.DoesExist(".")
3✔
540
                    dotWord = " " + m.symbolDictionary["."] + " "
1✔
541
                end if
542
                result = result.Replace("@", atWord)
1✔
543
                result = result.Replace(".", dotWord)
1✔
544
                return result
1✔
545
            end if
546

547
            ' Character-by-character recitation with symbol replacement
548
            for i = 0 to textLength - 1
1✔
549
                char = Mid(text, i + 1, 1)
1✔
550

551
                ' Check if character is a symbol
552
                if m.symbolDictionary.DoesExist(char)
2✔
553
                    result = result + m.symbolDictionary[char] + " "
1✔
554
                else
555
                    ' Regular character - add with space for separation
3✔
556
                    result = result + char + " "
1✔
557
                end if
558
            end for
559

560
            return result.Trim()
1✔
561
        end function
562

563
        ' ---------------------------------------------------------------------
564
        ' stopSpeech - Immediately stops all speech and cancels pending
565
        '
566
        public sub stopSpeech()
567
            if not m.isEnabled or not m.getIsDeviceAudioGuideEnabled() then return
2✔
568
            m.audioGuide.Flush()
1✔
569
            m.overrideNextFlushFlag = false
1✔
570
            m.pendingSpeech = invalid
1✔
571
            m.isPendingProtected = false
1✔
572

573
            ' Stop timer if running
574
            if m.timerNode <> invalid
3✔
575
                m.timerNode.control = "stop"
1✔
576
            end if
577

578
            #if debug
4✔
579
                ? "[TTS_SERVICE] Stop speech - flushed all speech and canceled pending"
1✔
580
            #end if
581
        end sub
582

583
        ' ---------------------------------------------------------------------
584
        ' setSymbolDictionary - Updates symbol dictionary for localization
585
        '
586
        ' @param {object} dictionary - Symbol to spoken word mapping
587
        '
588
        sub setSymbolDictionary(dictionary as object)
589
            m.symbolDictionary = dictionary
1✔
590
        end sub
591

592
        ' ---------------------------------------------------------------------
593
        ' enable - Enables TTS service and disables native AudioGuide
594
        '
595
        public sub enable()
596
            if not m.getIsDeviceAudioGuideEnabled()
2✔
597
                #if debug
×
598
                    ? "[TTS_SERVICE][WARNING] Cannot enable - device AudioGuide is disabled"
×
599
                #end if
600
                return
×
601
            end if
602

603
            m.isEnabled = true
1✔
604

605
            ' Mute native AudioGuide on root node
606
            rootNode = m.frameworkInstance.getRootNode()
1✔
607
            if rootNode <> invalid
3✔
608
                rootNode.muteAudioGuide = true
1✔
609
                #if debug
4✔
610
                    ? "[TTS_SERVICE][INFO] Enabled TTS, muted native AudioGuide"
1✔
611
                #end if
612
            end if
613
        end sub
614

615
        ' ---------------------------------------------------------------------
616
        ' disable - Disables TTS service and re-enables native AudioGuide
617
        '
618
        public sub disable()
619
            m.isEnabled = false
1✔
620
            m.stopSpeech()
1✔
621

622
            ' Unmute native AudioGuide on root node if device supports it
623
            if m.getIsDeviceAudioGuideEnabled()
3✔
624
                rootNode = m.frameworkInstance.getRootNode()
1✔
625
                if rootNode <> invalid
3✔
626
                    rootNode.muteAudioGuide = false
1✔
627
                    #if debug
4✔
628
                        ? "[TTS_SERVICE][INFO] Disabled TTS, unmuted native AudioGuide"
1✔
629
                    #end if
630
                end if
631
            end if
632
        end sub
633

634
        ' ---------------------------------------------------------------------
635
        ' toggleAudioGuide - Toggles TTS service on/off or sets explicit state
636
        '
637
        ' @param {dynamic} enableState - Optional: true = enable, false = disable, invalid = toggle
638
        ' @returns {boolean} New enabled state
639
        '
640
        public function toggleAudioGuide(enableState = invalid as dynamic) as boolean
641
            ' Check device AudioGuide status dynamically
642
            if not m.getIsDeviceAudioGuideEnabled()
2✔
643
                #if debug
×
644
                    ? "[TTS_SERVICE][WARNING] Cannot toggle - device AudioGuide is disabled"
×
645
                #end if
646
                return m.isEnabled
×
647
            end if
648

649
            ' Determine new state
650
            if enableState = invalid
2✔
651
                ' Toggle mode - switch current state
652
                m.isEnabled = not m.isEnabled
1✔
653
            else
654
                ' Explicit set mode
3✔
655
                m.isEnabled = enableState
1✔
656
            end if
657

658
            ' Get root node
659
            rootNode = m.frameworkInstance.getRootNode()
1✔
660
            if rootNode = invalid then return m.isEnabled
1✔
661

662
            if m.isEnabled
3✔
663
                ' Enabling TTS - mute native AudioGuide
664
                rootNode.muteAudioGuide = true
1✔
665
                #if debug
4✔
666
                    ? "[TTS_SERVICE][INFO] TTS enabled, muted native AudioGuide"
1✔
667
                #end if
668
            else
669
                ' Disabling TTS - stop current speech and unmute native AudioGuide
3✔
670
                m.audioGuide.Flush()
1✔
671
                m.overrideNextFlushFlag = false
1✔
672
                rootNode.muteAudioGuide = false
1✔
673
                #if debug
4✔
674
                    ? "[TTS_SERVICE][INFO] TTS disabled, unmuted native AudioGuide"
1✔
675
                #end if
676
            end if
677

678
            return m.isEnabled
1✔
679
        end function
680

681
        ' ---------------------------------------------------------------------
682
        ' destroy - Cleans up TTS service resources
683
        '
684
        sub destroy()
685
            ' Stop and remove timer node
686
            if m.timerNode <> invalid
3✔
687
                m.timerNode.control = "stop"
1✔
688
                m.timerNode.unobserveFieldScoped("fire")
1✔
689
                m.timerNode = invalid
1✔
690
            end if
691

692
            ' Cancel pending speech
693
            m.pendingSpeech = invalid
1✔
694

695
            ' Disable TTS and unmute native AudioGuide
696
            m.toggleAudioGuide(false)
1✔
697

698
            m.audioGuide = invalid
1✔
699
            m.deviceInfo = invalid
1✔
700
            m.debounceTimer = invalid
1✔
701
            m.frameworkInstance = invalid
1✔
702
        end sub
703

704
    end class
705

706
    ' =============================================================================
707
    ' Global callback for TTS threshold timer
708
    ' =============================================================================
709
    sub ttsDebounceCallback(event as object)
710
        framework = GetGlobalAA().rotor_framework_helper.frameworkInstance
×
711
        if framework = invalid then return
×
712
        framework.ttsService.onDebounceTimer()
×
713
    end sub
714

715
end namespace
716

STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc