diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkDefaults.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkDefaults.kt new file mode 100644 index 00000000000..2afe245c8e5 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkDefaults.kt @@ -0,0 +1,5 @@ +package ai.openclaw.app.voice + +internal object TalkDefaults { + const val defaultSilenceTimeoutMs = 700L +} diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt index 8b57b47a2f9..e63d012eb0a 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt @@ -60,7 +60,6 @@ class TalkModeManager( private const val defaultModelIdFallback = "eleven_v3" private const val defaultOutputFormatFallback = "pcm_24000" private const val defaultTalkProvider = "elevenlabs" - private const val defaultSilenceTimeoutMs = 700L private const val listenWatchdogMs = 12_000L private const val chatFinalWaitWithSubscribeMs = 45_000L private const val chatFinalWaitWithoutSubscribeMs = 6_000L @@ -118,11 +117,12 @@ class TalkModeManager( } internal fun resolvedSilenceTimeoutMs(talk: JsonObject?): Long { - val primitive = talk?.get("silenceTimeoutMs") as? JsonPrimitive ?: return defaultSilenceTimeoutMs - if (primitive.isString) return defaultSilenceTimeoutMs - val timeout = primitive.content.toDoubleOrNull() ?: return defaultSilenceTimeoutMs + val fallback = TalkDefaults.defaultSilenceTimeoutMs + val primitive = talk?.get("silenceTimeoutMs") as? JsonPrimitive ?: return fallback + if (primitive.isString) return fallback + val timeout = primitive.content.toDoubleOrNull() ?: return fallback if (timeout <= 0 || timeout % 1.0 != 0.0 || timeout > Long.MAX_VALUE.toDouble()) { - return defaultSilenceTimeoutMs + return fallback } return timeout.toLong() } @@ -155,7 +155,7 @@ class TalkModeManager( private var listeningMode = false private var silenceJob: Job? = null - private var silenceWindowMs = defaultSilenceTimeoutMs + private var silenceWindowMs = TalkDefaults.defaultSilenceTimeoutMs private var lastTranscript: String = "" private var lastHeardAtMs: Long? = null private var lastSpokenText: String? = null @@ -1467,7 +1467,7 @@ class TalkModeManager( } configLoaded = true } catch (_: Throwable) { - silenceWindowMs = defaultSilenceTimeoutMs + silenceWindowMs = TalkDefaults.defaultSilenceTimeoutMs defaultVoiceId = envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() } defaultModelId = defaultModelIdFallback if (!modelOverrideActive) currentModelId = defaultModelId diff --git a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt index 218f7511cf0..9188436a183 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt @@ -94,20 +94,20 @@ class TalkModeConfigParsingTest { @Test fun defaultsSilenceTimeoutMsWhenMissing() { - assertEquals(700L, TalkModeManager.resolvedSilenceTimeoutMs(null)) + assertEquals(TalkDefaults.defaultSilenceTimeoutMs, TalkModeManager.resolvedSilenceTimeoutMs(null)) } @Test fun defaultsSilenceTimeoutMsWhenInvalid() { val talk = buildJsonObject { put("silenceTimeoutMs", 0) } - assertEquals(700L, TalkModeManager.resolvedSilenceTimeoutMs(talk)) + assertEquals(TalkDefaults.defaultSilenceTimeoutMs, TalkModeManager.resolvedSilenceTimeoutMs(talk)) } @Test fun defaultsSilenceTimeoutMsWhenString() { val talk = buildJsonObject { put("silenceTimeoutMs", "1500") } - assertEquals(700L, TalkModeManager.resolvedSilenceTimeoutMs(talk)) + assertEquals(TalkDefaults.defaultSilenceTimeoutMs, TalkModeManager.resolvedSilenceTimeoutMs(talk)) } } diff --git a/apps/ios/Sources/Voice/TalkDefaults.swift b/apps/ios/Sources/Voice/TalkDefaults.swift new file mode 100644 index 00000000000..be837945c52 --- /dev/null +++ b/apps/ios/Sources/Voice/TalkDefaults.swift @@ -0,0 +1,3 @@ +enum TalkDefaults { + static let silenceTimeoutMs = 900 +} diff --git a/apps/ios/Sources/Voice/TalkModeManager.swift b/apps/ios/Sources/Voice/TalkModeManager.swift index f59d33dec6a..ad972af7f45 100644 --- a/apps/ios/Sources/Voice/TalkModeManager.swift +++ b/apps/ios/Sources/Voice/TalkModeManager.swift @@ -34,7 +34,7 @@ final class TalkModeManager: NSObject { private typealias SpeechRequest = SFSpeechAudioBufferRecognitionRequest private static let defaultModelIdFallback = "eleven_v3" private static let defaultTalkProvider = "elevenlabs" - private static let defaultSilenceTimeoutMs = 900 + private static let defaultSilenceTimeoutMs = TalkDefaults.silenceTimeoutMs private static let redactedConfigSentinel = "__OPENCLAW_REDACTED__" var isEnabled: Bool = false var isListening: Bool = false diff --git a/apps/ios/Tests/TalkModeConfigParsingTests.swift b/apps/ios/Tests/TalkModeConfigParsingTests.swift index bf600385c35..6fad939cc7a 100644 --- a/apps/ios/Tests/TalkModeConfigParsingTests.swift +++ b/apps/ios/Tests/TalkModeConfigParsingTests.swift @@ -60,7 +60,7 @@ import Testing } @Test func defaultsSilenceTimeoutMsWhenMissing() { - #expect(TalkModeManager.resolvedSilenceTimeoutMs(nil) == 900) + #expect(TalkModeManager.resolvedSilenceTimeoutMs(nil) == TalkDefaults.silenceTimeoutMs) } @Test func defaultsSilenceTimeoutMsWhenInvalid() { @@ -68,7 +68,7 @@ import Testing "silenceTimeoutMs": 0, ] - #expect(TalkModeManager.resolvedSilenceTimeoutMs(TalkConfigParsing.bridgeFoundationDictionary(talk)) == 900) + #expect(TalkModeManager.resolvedSilenceTimeoutMs(TalkConfigParsing.bridgeFoundationDictionary(talk)) == TalkDefaults.silenceTimeoutMs) } @Test func defaultsSilenceTimeoutMsWhenBool() { @@ -76,6 +76,6 @@ import Testing "silenceTimeoutMs": true, ] - #expect(TalkModeManager.resolvedSilenceTimeoutMs(TalkConfigParsing.bridgeFoundationDictionary(talk)) == 900) + #expect(TalkModeManager.resolvedSilenceTimeoutMs(TalkConfigParsing.bridgeFoundationDictionary(talk)) == TalkDefaults.silenceTimeoutMs) } } diff --git a/apps/macos/Sources/OpenClaw/TalkDefaults.swift b/apps/macos/Sources/OpenClaw/TalkDefaults.swift new file mode 100644 index 00000000000..105bac4f390 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/TalkDefaults.swift @@ -0,0 +1,3 @@ +enum TalkDefaults { + static let silenceTimeoutMs = 700 +} diff --git a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift index 1adc3cb0b78..ad5d7e3a886 100644 --- a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift +++ b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift @@ -12,7 +12,7 @@ actor TalkModeRuntime { private let ttsLogger = Logger(subsystem: "ai.openclaw", category: "talk.tts") private static let defaultModelIdFallback = "eleven_v3" private static let defaultTalkProvider = "elevenlabs" - private static let defaultSilenceTimeoutMs = 700 + private static let defaultSilenceTimeoutMs = TalkDefaults.silenceTimeoutMs private final class RMSMeter: @unchecked Sendable { private let lock = NSLock() diff --git a/apps/macos/Tests/OpenClawIPCTests/TalkModeConfigParsingTests.swift b/apps/macos/Tests/OpenClawIPCTests/TalkModeConfigParsingTests.swift index 89fe7f6c996..15797cebf9e 100644 --- a/apps/macos/Tests/OpenClawIPCTests/TalkModeConfigParsingTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/TalkModeConfigParsingTests.swift @@ -33,7 +33,7 @@ struct TalkModeConfigParsingTests { #expect(selection?.config["apiKey"]?.stringValue == "legacy-key") } - @Test func readsConfiguredSilenceTimeoutMs() { + @Test func `reads configured silence timeout ms`() { let talk: [String: AnyCodable] = [ "silenceTimeoutMs": AnyCodable(1500), ] @@ -41,15 +41,15 @@ struct TalkModeConfigParsingTests { #expect(TalkModeRuntime.resolvedSilenceTimeoutMs(talk) == 1500) } - @Test func defaultsSilenceTimeoutMsWhenMissing() { - #expect(TalkModeRuntime.resolvedSilenceTimeoutMs(nil) == 700) + @Test func `defaults silence timeout ms when missing`() { + #expect(TalkModeRuntime.resolvedSilenceTimeoutMs(nil) == TalkDefaults.silenceTimeoutMs) } - @Test func defaultsSilenceTimeoutMsWhenInvalid() { + @Test func `defaults silence timeout ms when invalid`() { let talk: [String: AnyCodable] = [ "silenceTimeoutMs": AnyCodable(0), ] - #expect(TalkModeRuntime.resolvedSilenceTimeoutMs(talk) == 700) + #expect(TalkModeRuntime.resolvedSilenceTimeoutMs(talk) == TalkDefaults.silenceTimeoutMs) } } diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 4b60b48ec75..880ccdd198b 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1669,7 +1669,7 @@ Defaults for Talk mode (macOS/iOS/Android). - `apiKey` and `providers.*.apiKey` accept plaintext strings or SecretRef objects. - `ELEVENLABS_API_KEY` fallback applies only when no Talk API key is configured. - `voiceAliases` lets Talk directives use friendly names. -- `silenceTimeoutMs` controls how long Talk mode waits after user silence before it sends the transcript. Unset keeps the platform default pause window (`700` ms on macOS and Android, `900` ms on iOS). +- `silenceTimeoutMs` controls how long Talk mode waits after user silence before it sends the transcript. Unset keeps the platform default pause window (`700 ms on macOS and Android, 900 ms on iOS`). --- diff --git a/docs/nodes/talk.md b/docs/nodes/talk.md index eadde1682de..0fccaa3681c 100644 --- a/docs/nodes/talk.md +++ b/docs/nodes/talk.md @@ -65,7 +65,7 @@ Supported keys: Defaults: - `interruptOnSpeech`: true -- `silenceTimeoutMs`: when unset, Talk keeps the platform default pause window before sending the transcript (`700` ms on macOS and Android, `900` ms on iOS) +- `silenceTimeoutMs`: when unset, Talk keeps the platform default pause window before sending the transcript (`700 ms on macOS and Android, 900 ms on iOS`) - `voiceId`: falls back to `ELEVENLABS_VOICE_ID` / `SAG_VOICE_ID` (or first ElevenLabs voice when API key is available) - `modelId`: defaults to `eleven_v3` when unset - `apiKey`: falls back to `ELEVENLABS_API_KEY` (or gateway shell profile if available) diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index d22cd9e4597..bab8606fdc6 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -4,6 +4,7 @@ import { } from "../discord/monitor/timeouts.js"; import { MEDIA_AUDIO_FIELD_HELP } from "./media-audio-field-metadata.js"; import { IRC_FIELD_HELP } from "./schema.irc.js"; +import { describeTalkSilenceTimeoutDefaults } from "./talk-defaults.js"; export const FIELD_HELP: Record = { meta: "Metadata fields automatically maintained by OpenClaw to record write/version history for this config file. Keep these values system-managed and avoid manual edits unless debugging migration history.", @@ -163,8 +164,7 @@ export const FIELD_HELP: Record = { "Use this legacy ElevenLabs API key for Talk mode only during migration, and keep secrets in env-backed storage. Prefer talk.providers.elevenlabs.apiKey (fallback: ELEVENLABS_API_KEY).", "talk.interruptOnSpeech": "If true (default), stop assistant speech when the user starts speaking in Talk mode. Keep enabled for conversational turn-taking.", - "talk.silenceTimeoutMs": - "Milliseconds of user silence before Talk mode finalizes and sends the current transcript. Leave unset to keep the platform default pause window (700 ms on macOS and Android, 900 ms on iOS).", + "talk.silenceTimeoutMs": `Milliseconds of user silence before Talk mode finalizes and sends the current transcript. Leave unset to keep the platform default pause window (${describeTalkSilenceTimeoutDefaults()}).`, acp: "ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.", "acp.enabled": "Global ACP feature gate. Keep disabled unless ACP runtime + policy are configured.", diff --git a/src/config/talk-defaults.test.ts b/src/config/talk-defaults.test.ts new file mode 100644 index 00000000000..1be94ef2db4 --- /dev/null +++ b/src/config/talk-defaults.test.ts @@ -0,0 +1,43 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { FIELD_HELP } from "./schema.help.js"; +import { + describeTalkSilenceTimeoutDefaults, + TALK_SILENCE_TIMEOUT_MS_BY_PLATFORM, +} from "./talk-defaults.js"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + +function readRepoFile(relativePath: string): string { + return fs.readFileSync(path.join(repoRoot, relativePath), "utf8"); +} + +describe("talk silence timeout defaults", () => { + it("keeps help text and docs aligned with the policy", () => { + const defaultsDescription = describeTalkSilenceTimeoutDefaults(); + + expect(FIELD_HELP["talk.silenceTimeoutMs"]).toContain(defaultsDescription); + expect(readRepoFile("docs/gateway/configuration-reference.md")).toContain(defaultsDescription); + expect(readRepoFile("docs/nodes/talk.md")).toContain(defaultsDescription); + }); + + it("matches the Apple and Android runtime constants", () => { + const macDefaults = readRepoFile("apps/macos/Sources/OpenClaw/TalkDefaults.swift"); + const iosDefaults = readRepoFile("apps/ios/Sources/Voice/TalkDefaults.swift"); + const androidDefaults = readRepoFile( + "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkDefaults.kt", + ); + + expect(macDefaults).toContain( + `static let silenceTimeoutMs = ${TALK_SILENCE_TIMEOUT_MS_BY_PLATFORM.macos}`, + ); + expect(iosDefaults).toContain( + `static let silenceTimeoutMs = ${TALK_SILENCE_TIMEOUT_MS_BY_PLATFORM.ios}`, + ); + expect(androidDefaults).toContain( + `const val defaultSilenceTimeoutMs = ${TALK_SILENCE_TIMEOUT_MS_BY_PLATFORM.android}L`, + ); + }); +}); diff --git a/src/config/talk-defaults.ts b/src/config/talk-defaults.ts new file mode 100644 index 00000000000..ddbd2e4f90c --- /dev/null +++ b/src/config/talk-defaults.ts @@ -0,0 +1,11 @@ +export const TALK_SILENCE_TIMEOUT_MS_BY_PLATFORM = { + macos: 700, + android: 700, + ios: 900, +} as const; + +export function describeTalkSilenceTimeoutDefaults(): string { + const macos = TALK_SILENCE_TIMEOUT_MS_BY_PLATFORM.macos; + const ios = TALK_SILENCE_TIMEOUT_MS_BY_PLATFORM.ios; + return `${macos} ms on macOS and Android, ${ios} ms on iOS`; +}