fix(talk): prevent double TTS playback when system voice times out (#53511)

Merged via squash.

Prepared head SHA: 864d556fa6
Co-authored-by: hongsw <1100974+hongsw@users.noreply.github.com>
Co-authored-by: grp06 <1573959+grp06@users.noreply.github.com>
Reviewed-by: @grp06
This commit is contained in:
Seungwoo hong
2026-03-27 07:37:40 +09:00
committed by GitHub
parent 0f5a77d058
commit 138a92373b
5 changed files with 128 additions and 13 deletions

View File

@@ -51,11 +51,11 @@ public final class TalkSystemSpeechSynthesizer: NSObject {
}
self.currentUtterance = utterance
let estimatedSeconds = max(3.0, min(180.0, Double(trimmed.count) * 0.08))
let watchdogTimeout = Self.watchdogTimeoutSeconds(text: trimmed, language: language ?? utterance.voice?.language)
self.watchdog?.cancel()
self.watchdog = Task { @MainActor [weak self] in
guard let self else { return }
try? await Task.sleep(nanoseconds: UInt64(estimatedSeconds * 1_000_000_000))
try? await Task.sleep(nanoseconds: UInt64(watchdogTimeout * 1_000_000_000))
if Task.isCancelled { return }
guard self.currentToken == token else { return }
if self.synth.isSpeaking {
@@ -63,7 +63,7 @@ public final class TalkSystemSpeechSynthesizer: NSObject {
}
self.finishCurrent(
with: NSError(domain: "TalkSystemSpeechSynthesizer", code: 408, userInfo: [
NSLocalizedDescriptionKey: "system TTS timed out after \(estimatedSeconds)s",
NSLocalizedDescriptionKey: "system TTS timed out after \(watchdogTimeout)s",
]))
}
@@ -83,6 +83,37 @@ public final class TalkSystemSpeechSynthesizer: NSObject {
}
}
static func watchdogTimeoutSeconds(text: String, language: String?) -> Double {
// Estimate speech duration per language, then apply 3x safety margin.
// The watchdog is a hang guard normal completion relies on didFinish.
//
// Speech rates based on Pellegrino et al. (2019) syllable-per-second data,
// adjusted for TTS synthesis (slower than natural speech):
// https://www.science.org/doi/10.1126/sciadv.aaw2594
// Japanese: 7.84 SPS -> ~0.20s/char (mixed kana/kanji avg ~1.5 mora/char)
// Korean: 5.96 SPS -> ~0.25s/char (1 char = 1 syllable)
// Chinese: 5.18 SPS -> ~0.28s/char (1 char = 1 syllable)
// English: 6.19 SPS -> ~0.08s/char (avg ~5 chars/syllable)
let normalizedLanguage = language?.lowercased() ?? "en"
let perCharSeconds: Double
let minSeconds: Double
if normalizedLanguage.hasPrefix("ko") {
perCharSeconds = 0.25
minSeconds = 10.0
} else if normalizedLanguage.hasPrefix("zh") {
perCharSeconds = 0.28
minSeconds = 10.0
} else if normalizedLanguage.hasPrefix("ja") {
perCharSeconds = 0.20
minSeconds = 10.0
} else {
perCharSeconds = 0.08
minSeconds = 3.0
}
let estimatedSeconds = max(minSeconds, min(300.0, Double(text.count) * perCharSeconds))
return estimatedSeconds * 3.0
}
private func matchesCurrentUtterance(_ utteranceID: ObjectIdentifier) -> Bool {
guard let currentUtterance = self.currentUtterance else { return false }
return ObjectIdentifier(currentUtterance) == utteranceID

View File

@@ -0,0 +1,44 @@
import XCTest
@testable import OpenClawKit
final class TalkSystemSpeechSynthesizerTests: XCTestCase {
func testWatchdogTimeoutDefaultsToLatinProfile() {
let timeout = TalkSystemSpeechSynthesizer.watchdogTimeoutSeconds(
text: String(repeating: "a", count: 100),
language: nil)
XCTAssertEqual(timeout, 24.0, accuracy: 0.001)
}
func testWatchdogTimeoutUsesKoreanProfile() {
let timeout = TalkSystemSpeechSynthesizer.watchdogTimeoutSeconds(
text: String(repeating: "", count: 100),
language: "ko-KR")
XCTAssertEqual(timeout, 75.0, accuracy: 0.001)
}
func testWatchdogTimeoutUsesChineseProfile() {
let timeout = TalkSystemSpeechSynthesizer.watchdogTimeoutSeconds(
text: String(repeating: "", count: 100),
language: "zh-CN")
XCTAssertEqual(timeout, 84.0, accuracy: 0.001)
}
func testWatchdogTimeoutUsesJapaneseProfile() {
let timeout = TalkSystemSpeechSynthesizer.watchdogTimeoutSeconds(
text: String(repeating: "", count: 100),
language: "ja-JP")
XCTAssertEqual(timeout, 60.0, accuracy: 0.001)
}
func testWatchdogTimeoutClampsVeryLongUtterances() {
let timeout = TalkSystemSpeechSynthesizer.watchdogTimeoutSeconds(
text: String(repeating: "a", count: 10_000),
language: "en-US")
XCTAssertEqual(timeout, 900.0, accuracy: 0.001)
}
}