From 9d28c434847601af988db8aa9d421a462f4f47c6 Mon Sep 17 00:00:00 2001 From: Nick Monrad Date: Tue, 2 Jun 2026 21:46:13 +1000 Subject: [PATCH 1/3] fix(#1057): add playback timeout safeguard to AndroidTextToSpeechController When Android TTS drops an onDone/onError callback (audio routing interference, engine glitch), completePlaybackIfDrained waits forever with non-empty utteranceIds and never emits SpeakingStopped, leaving the UI stuck on 'Speaking response...'. Add a 30-second safety-net timeout that fires after the final chunk is queued. If TTS callbacks haven't drained all utterances by then, the timeout force-cleans the playback state and emits SpeakingStopped. Cancels automatically on natural completion or explicit stop(). --- .../voice/AndroidTextToSpeechController.kt | 61 ++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/core/voice/src/main/java/com/kernel/ai/core/voice/AndroidTextToSpeechController.kt b/core/voice/src/main/java/com/kernel/ai/core/voice/AndroidTextToSpeechController.kt index 04fdf1b75..5053a71be 100644 --- a/core/voice/src/main/java/com/kernel/ai/core/voice/AndroidTextToSpeechController.kt +++ b/core/voice/src/main/java/com/kernel/ai/core/voice/AndroidTextToSpeechController.kt @@ -19,9 +19,14 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.delay +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +/** If a TTS utterance callback (onDone/onError/onStop) never fires, force SpeakingStopped after this delay. */ +private const val PLAYBACK_TIMEOUT_MS = 30_000L + private const val TAG = "KernelAI" @Singleton @@ -59,6 +64,13 @@ class AndroidTextToSpeechController @Inject constructor( @Volatile private var nextPlaybackToken: Long = 0L + /** + * Scheduled when the final chunk is queued; cancelled on natural completion ([SpeakingStopped] emitted) + * or when [stop] is called. Prevents the "Speaking response…" stuck state if TTS callbacks drop. + */ + @Volatile + private var playbackTimeoutJob: Job? = null + @Volatile private var activePlayback: ActivePlayback? = null @@ -80,7 +92,7 @@ class AndroidTextToSpeechController @Inject constructor( synchronized(playbackLock) { activePlayback = ActivePlayback(token = playbackToken) } - return enqueuePlaybackChunk( + val result = enqueuePlaybackChunk( playbackToken = playbackToken, text = text, locale = request.locale, @@ -88,6 +100,10 @@ class AndroidTextToSpeechController @Inject constructor( queueMode = TextToSpeech.QUEUE_FLUSH, isFinal = true, ) + if (result is VoiceOutputResult.Spoken) { + schedulePlaybackTimeout() + } + return result } override suspend fun openStreamingSession( @@ -111,6 +127,7 @@ class AndroidTextToSpeechController @Inject constructor( ?.finalChunkQueued = true } completePlaybackIfDrained(playbackToken) + schedulePlaybackTimeout() } return VoiceOutputResult.Spoken } @@ -133,6 +150,7 @@ class AndroidTextToSpeechController @Inject constructor( hasQueuedChunk = true if (isFinal) { isClosed = true + schedulePlaybackTimeout() } } return result @@ -142,6 +160,7 @@ class AndroidTextToSpeechController @Inject constructor( override fun stop() { scope.launch { + cancelPlaybackTimeout() val hadActivePlayback = synchronized(playbackLock) { val hadPlayback = activePlayback != null || activeUtterances.isNotEmpty() activePlayback = null @@ -307,6 +326,7 @@ class AndroidTextToSpeechController @Inject constructor( true } if (shouldEmitStopped) { + cancelPlaybackTimeout() releaseAudioFocus() _events.tryEmit(VoiceOutputEvent.SpeakingStopped) } @@ -341,4 +361,43 @@ class AndroidTextToSpeechController @Inject constructor( audioFocusRequest?.let { audioManager.abandonAudioFocusRequest(it) } audioFocusRequest = null } + + /** + * Schedules a safety-net timeout that forces [VoiceOutputEvent.SpeakingStopped] if TTS + * utterance callbacks never arrive after the final chunk was queued. + * + * Cancelled automatically when [stop] is called or [SpeakingStopped] is naturally emitted + * via [completePlaybackIfDrained]. + */ + private fun schedulePlaybackTimeout() { + cancelPlaybackTimeout() + playbackTimeoutJob = scope.launch { + delay(PLAYBACK_TIMEOUT_MS) + val shouldEmit = synchronized(playbackLock) { + val pb = activePlayback ?: return@synchronized false + if (pb.finalChunkQueued && pb.utteranceIds.isNotEmpty()) { + Log.w( + TAG, + "AndroidTextToSpeechController: playback timeout — $PLAYBACK_TIMEOUT_MS ms " + + "elapsed with ${pb.utteranceIds.size} pending utterance(s) — " + + "forcing SpeakingStopped", + ) + activePlayback = null + activeUtterances.clear() + true + } else { + false + } + } + if (shouldEmit) { + releaseAudioFocus() + _events.tryEmit(VoiceOutputEvent.SpeakingStopped) + } + } + } + + private fun cancelPlaybackTimeout() { + playbackTimeoutJob?.cancel() + playbackTimeoutJob = null + } } From cb0789015866b76e6330705bfc9b61a8a97304b0 Mon Sep 17 00:00:00 2001 From: Nick Monrad Date: Wed, 3 Jun 2026 23:14:57 +1000 Subject: [PATCH 2/3] =?UTF-8?q?fix(#1072):=20calendar=20event=20creation?= =?UTF-8?q?=20=E2=80=94=20natural=20phrasing,=20ordinal=20dates,=20calenda?= =?UTF-8?q?r=20alias?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - QIR regex: new pattern catches "add X to [my] calendar" for any noun X - Date extraction: parse ordinal dates ("9th of June", "June 9th") in extractCalendarHints - Title extraction: add to/in/into to title lookaheads; add "something" to generic titles - resolveDate: strip ordinal suffixes before DateTimeFormatter parsing - load_skill: redirect "calendar" alias to run_intent instructions - Error message: hint that calendar/alarm/SMS use run_intent --- .../kernel/ai/core/skills/LoadSkillSkill.kt | 15 ++++++- .../ai/core/skills/QuickIntentRouter.kt | 42 ++++++++++++++++++- .../skills/natives/NativeIntentHandler.kt | 2 +- .../ai/core/skills/QuickIntentRouterTest.kt | 7 +++- .../skills/natives/NativeIntentHandlerTest.kt | 18 ++++++++ 5 files changed, 78 insertions(+), 6 deletions(-) diff --git a/core/skills/src/main/java/com/kernel/ai/core/skills/LoadSkillSkill.kt b/core/skills/src/main/java/com/kernel/ai/core/skills/LoadSkillSkill.kt index 9ad0f3629..9ceba63e8 100644 --- a/core/skills/src/main/java/com/kernel/ai/core/skills/LoadSkillSkill.kt +++ b/core/skills/src/main/java/com/kernel/ai/core/skills/LoadSkillSkill.kt @@ -61,10 +61,23 @@ class LoadSkillSkill @Inject constructor( override suspend fun execute(call: SkillCall): SkillResult { val skillName = call.arguments["skill_name"]?.takeIf { it.isNotBlank() } ?: return SkillResult.Failure(name, "Missing required parameter: skill_name.") + // Calendar actions are handled by run_intent, not a standalone skill + if (skillName.equals("calendar", ignoreCase = true)) { + val runIntentSkill = skillRegistry.get().get("run_intent") + if (runIntentSkill != null) { + return SkillResult.Success( + "Calendar actions are handled through run_intent. " + + "Use runIntent(intentName=\"create_calendar_event\", parameters={...}).\n\n" + + runIntentSkill.fullInstructions + ) + } + } val skill = skillRegistry.get().get(skillName) ?: return SkillResult.Failure( name, - "Unknown skill: '$skillName'. Available: run_intent, get_weather, query_wikipedia, save_memory, search_memory, get_system_info, run_js", + "Unknown skill: '$skillName'. Available: run_intent, get_weather, " + + "query_wikipedia, save_memory, search_memory, get_system_info, run_js. " + + "Hint: calendar, alarm, SMS, and other device actions use run_intent." ) return SkillResult.Success(skill.fullInstructions) } diff --git a/core/skills/src/main/java/com/kernel/ai/core/skills/QuickIntentRouter.kt b/core/skills/src/main/java/com/kernel/ai/core/skills/QuickIntentRouter.kt index d0e1c1b98..605b5fa5a 100644 --- a/core/skills/src/main/java/com/kernel/ai/core/skills/QuickIntentRouter.kt +++ b/core/skills/src/main/java/com/kernel/ai/core/skills/QuickIntentRouter.kt @@ -939,6 +939,17 @@ class QuickIntentRouter( paramExtractor = { _, _ -> emptyMap() }, ), + // "add something to the calendar for June 9th" / "put lunch in my calendar tomorrow" + IntentPattern( + intentName = "create_calendar_event", + regex = Regex( + """(?:add|create|schedule|put|book)\s+(?!(?:calendar\s+)?voice\s+memo\b)(?!note\b)(.{3,60}?)\s+(?:to|in|on|into)\s+(?:the\s+|my\s+)?calendar\b""", + RegexOption.IGNORE_CASE, + ), + paramExtractor = { _, raw -> extractCalendarHints(raw) }, + requiredSlots = slotContract("create_calendar_event"), + ), + // ── Calendar ── @@ -4322,11 +4333,11 @@ class QuickIntentRouter( // Fall back to the noun phrase immediately after the verb+article, stopping // before any temporal keyword (e.g. "schedule a dentist appointment Friday"). val titleFromFor = Regex( - """(?:^|\s)for\s+(?:a\s+|an\s+)?([a-zA-Z][a-zA-Z\s]{1,40}?)(?=\s+(?:at|from|on|next|this|tomorrow|today|monday|tuesday|wednesday|thursday|friday|saturday|sunday|\d)|$)""", + """(?:^|\s)for\s+(?:a\s+|an\s+)?([a-zA-Z][a-zA-Z\s]{1,40}?)(?=\s+(?:at|from|on|to|in|into|next|this|tomorrow|today|monday|tuesday|wednesday|thursday|friday|saturday|sunday|\d)|$)""", RegexOption.IGNORE_CASE, ).find(raw) val titleFromVerb = Regex( - """(?:add|create|schedule|put|book|set(?:\s+up)?)\s+(?:a\s+|an\s+)?([a-zA-Z][a-zA-Z\s]{1,40}?)(?=\s+(?:for|at|from|on|next|this|tomorrow|today|monday|tuesday|wednesday|thursday|friday|saturday|sunday|\d)|$)""", + """(?:add|create|schedule|put|book|set(?:\s+up)?)\s+(?:a\s+|an\s+)?([a-zA-Z][a-zA-Z\s]{1,40}?)(?=\s+(?:for|at|from|on|to|in|into|next|this|tomorrow|today|monday|tuesday|wednesday|thursday|friday|saturday|sunday|\d)|$)""", RegexOption.IGNORE_CASE, ).find(raw) val DATE_WORDS = setOf( @@ -4342,6 +4353,7 @@ class QuickIntentRouter( "booking", "invite", "entry", + "something", ) val rawTitle = run { val fromFor = titleFromFor?.groupValues?.get(1)?.trim() @@ -4373,6 +4385,32 @@ class QuickIntentRouter( ) dateRegex.find(lower)?.value?.trim()?.let { params["date"] = it } + // ── Date: ordinal and explicit dates ("9th of june", "June 9th") ── + if (!params.containsKey("date")) { + val ordinalDateRegex = Regex( + """\b(?:the\s+)?(\d{1,2})(?:st|nd|rd|th)?\s+(?:of\s+)?(jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\b""", + RegexOption.IGNORE_CASE, + ) + ordinalDateRegex.find(raw)?.let { match -> + val day = match.groupValues[1] + val month = match.groupValues[2].lowercase() + .replaceFirstChar { c -> c.uppercase() } + params["date"] = "$day $month" + } + } + if (!params.containsKey("date")) { + val monthFirstRegex = Regex( + """\b(jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\s+(\d{1,2})(?:st|nd|rd|th)?\b""", + RegexOption.IGNORE_CASE, + ) + monthFirstRegex.find(raw)?.let { match -> + val month = match.groupValues[1].lowercase() + .replaceFirstChar { c -> c.uppercase() } + val day = match.groupValues[2] + params["date"] = "$day $month" + } + } + // ── Time: "at 2pm", "for 2pm", "at 10:30am", "at 10:30 p.m.", "at noon/midnight", "at 10" ─ // Bare hours (no am/pm) are normalised to HH:00 so resolveTime() can parse. val timeRegex = Regex( diff --git a/core/skills/src/main/java/com/kernel/ai/core/skills/natives/NativeIntentHandler.kt b/core/skills/src/main/java/com/kernel/ai/core/skills/natives/NativeIntentHandler.kt index b94659d0d..127bdd13d 100644 --- a/core/skills/src/main/java/com/kernel/ai/core/skills/natives/NativeIntentHandler.kt +++ b/core/skills/src/main/java/com/kernel/ai/core/skills/natives/NativeIntentHandler.kt @@ -2791,7 +2791,7 @@ class NativeIntentHandler @Inject constructor( * Returns null if the string cannot be resolved to a valid date. */ private fun resolveDate(dateStr: String): LocalDate? { - val input = dateStr.trim() + val input = dateStr.trim().replace(Regex("""\b(\d{1,2})(?:st|nd|rd|th)\b"""), "$1") val normalized = input.lowercase() val today = LocalDate.now() diff --git a/core/skills/src/test/java/com/kernel/ai/core/skills/QuickIntentRouterTest.kt b/core/skills/src/test/java/com/kernel/ai/core/skills/QuickIntentRouterTest.kt index 217d0c5fe..e754725ee 100644 --- a/core/skills/src/test/java/com/kernel/ai/core/skills/QuickIntentRouterTest.kt +++ b/core/skills/src/test/java/com/kernel/ai/core/skills/QuickIntentRouterTest.kt @@ -512,7 +512,7 @@ class QuickIntentRouterTest { input: String, expectedTitle: String, expectedDate: String, - expectedTime: String, + expectedTime: String?, ) { val result = regexOnlyRouter.route(input) assertRegexMatch(result, "create_calendar_event", input) @@ -2391,6 +2391,8 @@ class QuickIntentRouterTest { Arguments.of("set up a dentist appointment tomorrow at 5pm", "Dentist Appointment", "tomorrow", "5pm"), Arguments.of("schedule a team meeting on friday at 2pm", "Team Meeting", "friday", "2pm"), Arguments.of("set up a dentist appointment sunday at 3:00 p.m.", "Dentist Appointment", "sunday", "3:00 p.m."), + Arguments.of("add dentist to my calendar on Friday at 2pm", "Dentist", "friday", "2pm"), + Arguments.of("put lunch in my calendar tomorrow", "Lunch", "tomorrow", null), ) @JvmStatic @@ -2401,12 +2403,14 @@ class QuickIntentRouterTest { Arguments.of("set an appointment for 3:00 p.m. Sunday"), Arguments.of("Appointment for 3:00 p.m. Sunday"), Arguments.of("Set an appointment up for 3:00 p.m. On Monday"), + Arguments.of("add something to my calendar"), ) @JvmStatic fun calendarNeedsDatePhrases(): Stream = Stream.of( Arguments.of("set up a dentist appointment", "Dentist Appointment"), Arguments.of("schedule a budget meeting", "Budget Meeting"), + Arguments.of("add dentist appointment to my calendar", "Dentist Appointment"), ) // ── DND ─────────────────────────────────────────────────────────────── @@ -3618,7 +3622,6 @@ class QuickIntentRouterTest { @JvmStatic fun e4bFallthroughPhrases(): Stream = Stream.of( // Calendar (needs NLU for date/time/title extraction) - Arguments.of("add dentist appointment to my calendar"), // Wikipedia / knowledge Arguments.of("tell me about the history of New Zealand"), Arguments.of("who invented the internet"), diff --git a/core/skills/src/test/java/com/kernel/ai/core/skills/natives/NativeIntentHandlerTest.kt b/core/skills/src/test/java/com/kernel/ai/core/skills/natives/NativeIntentHandlerTest.kt index 3057ef964..4d91f7533 100644 --- a/core/skills/src/test/java/com/kernel/ai/core/skills/natives/NativeIntentHandlerTest.kt +++ b/core/skills/src/test/java/com/kernel/ai/core/skills/natives/NativeIntentHandlerTest.kt @@ -2312,4 +2312,22 @@ class NativeIntentHandlerTest { return cursor } + + @Test + fun `resolveDate strips ordinal suffixes from date strings`() { + val method = NativeIntentHandler::class.java.getDeclaredMethod( + "resolveDate", + String::class.java, + ).also { it.isAccessible = true } + + val today = java.time.LocalDate.now() + + // "9th June" → resolveDate should strip "th" and parse "9 June" + val result = method.invoke(handler, "9th June") + assertNotNull(result, "Expected non-null for '9th June'") + val localDate = result as java.time.LocalDate + assertEquals(today.year, localDate.year, "Year defaults to current year") + assertEquals(6, localDate.monthValue, "Month should be June (6)") + assertEquals(9, localDate.dayOfMonth, "Day should be 9") + } } \ No newline at end of file From ebc448e098b0d498ed9ec613670dc92e16eb290c Mon Sep 17 00:00:00 2001 From: Nick Monrad Date: Thu, 4 Jun 2026 00:25:23 +1000 Subject: [PATCH 3/3] test(#1072): add ordinal date test cases in calendarRegexPhrases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "add lunch to my calendar on June 9th at 2pm" — month-first ordinal - "add lunch to my calendar on 9th of June" — ordinal-first with "of" --- .../java/com/kernel/ai/core/skills/QuickIntentRouterTest.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/core/skills/src/test/java/com/kernel/ai/core/skills/QuickIntentRouterTest.kt b/core/skills/src/test/java/com/kernel/ai/core/skills/QuickIntentRouterTest.kt index e754725ee..152974bf0 100644 --- a/core/skills/src/test/java/com/kernel/ai/core/skills/QuickIntentRouterTest.kt +++ b/core/skills/src/test/java/com/kernel/ai/core/skills/QuickIntentRouterTest.kt @@ -2393,6 +2393,8 @@ class QuickIntentRouterTest { Arguments.of("set up a dentist appointment sunday at 3:00 p.m.", "Dentist Appointment", "sunday", "3:00 p.m."), Arguments.of("add dentist to my calendar on Friday at 2pm", "Dentist", "friday", "2pm"), Arguments.of("put lunch in my calendar tomorrow", "Lunch", "tomorrow", null), + Arguments.of("add lunch to my calendar on June 9th at 2pm", "Lunch", "9 June", "2pm"), + Arguments.of("add lunch to my calendar on 9th of June", "Lunch", "9 June", null), ) @JvmStatic