diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 4d9b24ede5b..a397cd09231 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -171,7 +171,7 @@ }, { "kind": "ui-state-text", - "line": 265, + "line": 268, "path": "apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt", "source": "Off", "surface": "android", @@ -219,7 +219,7 @@ }, { "kind": "conditional-branch", - "line": 2913, + "line": 2922, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed: no secure gateway endpoint was detected. Enable gateway TLS or Tailscale Serve, or use a trusted private LAN address with Unencrypted selected.", "surface": "android", @@ -227,7 +227,7 @@ }, { "kind": "conditional-branch", - "line": 2915, + "line": 2924, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed: secure endpoint reached, but TLS fingerprint verification timed out. Check Tailscale Serve or gateway TLS and retry.", "surface": "android", @@ -235,7 +235,7 @@ }, { "kind": "conditional-branch", - "line": 2917, + "line": 2926, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed: couldn't reach the secure gateway endpoint for this host.", "surface": "android", @@ -2587,7 +2587,7 @@ }, { "kind": "ui-named-argument", - "line": 228, + "line": 230, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Provider limits and quota health.", "surface": "android", @@ -2595,7 +2595,7 @@ }, { "kind": "ui-named-argument", - "line": 228, + "line": 230, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Usage", "surface": "android", @@ -2603,7 +2603,7 @@ }, { "kind": "ui-named-argument", - "line": 248, + "line": 250, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connect the gateway to load usage.", "surface": "android", @@ -2611,7 +2611,7 @@ }, { "kind": "ui-named-argument", - "line": 253, + "line": 255, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No usage data yet.", "surface": "android", @@ -2619,7 +2619,7 @@ }, { "kind": "ui-named-argument", - "line": 254, + "line": 256, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Provider limits will appear here when your gateway reports them.", "surface": "android", @@ -2627,7 +2627,7 @@ }, { "kind": "ui-named-argument", - "line": 289, + "line": 291, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Cron Jobs", "surface": "android", @@ -2635,7 +2635,7 @@ }, { "kind": "ui-named-argument", - "line": 289, + "line": 291, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Scheduled OpenClaw work from your gateway.", "surface": "android", @@ -2643,7 +2643,7 @@ }, { "kind": "ui-named-argument", - "line": 300, + "line": 302, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Android shows scheduled work status. Create and edit schedules from the desktop app.", "surface": "android", @@ -2651,7 +2651,7 @@ }, { "kind": "ui-named-argument", - "line": 310, + "line": 312, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connect the gateway to load cron jobs.", "surface": "android", @@ -2659,7 +2659,7 @@ }, { "kind": "ui-named-argument", - "line": 315, + "line": 317, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No scheduled jobs.", "surface": "android", @@ -2667,7 +2667,7 @@ }, { "kind": "ui-named-argument", - "line": 316, + "line": 318, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Create recurring OpenClaw work from the desktop app.", "surface": "android", @@ -2675,7 +2675,7 @@ }, { "kind": "ui-named-argument", - "line": 351, + "line": 353, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Inspect scheduled gateway work.", "surface": "android", @@ -2683,7 +2683,7 @@ }, { "kind": "ui-named-argument", - "line": 365, + "line": 367, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connect the gateway to inspect cron jobs.", "surface": "android", @@ -2691,7 +2691,7 @@ }, { "kind": "conditional-branch", - "line": 373, + "line": 375, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Cron job not loaded.", "surface": "android", @@ -2699,7 +2699,7 @@ }, { "kind": "conditional-branch", - "line": 373, + "line": 375, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Loading cron job…", "surface": "android", @@ -2707,7 +2707,7 @@ }, { "kind": "ui-named-argument", - "line": 395, + "line": 397, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Agents", "surface": "android", @@ -2715,7 +2715,7 @@ }, { "kind": "ui-named-argument", - "line": 395, + "line": 397, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Choose and inspect the assistants available on this gateway.", "surface": "android", @@ -2723,7 +2723,7 @@ }, { "kind": "ui-named-argument", - "line": 406, + "line": 408, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connect the gateway to load agents.", "surface": "android", @@ -2731,7 +2731,7 @@ }, { "kind": "ui-named-argument", - "line": 410, + "line": 412, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No agents loaded yet.", "surface": "android", @@ -2739,7 +2739,7 @@ }, { "kind": "ui-named-argument", - "line": 436, + "line": 438, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Approvals", "surface": "android", @@ -2747,7 +2747,7 @@ }, { "kind": "ui-named-argument", - "line": 436, + "line": 438, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Review actions that need your attention.", "surface": "android", @@ -2755,7 +2755,7 @@ }, { "kind": "conditional-branch", - "line": 447, + "line": 449, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Refresh", "surface": "android", @@ -2763,7 +2763,7 @@ }, { "kind": "conditional-branch", - "line": 447, + "line": 449, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Refreshing", "surface": "android", @@ -2771,7 +2771,7 @@ }, { "kind": "ui-named-argument", - "line": 460, + "line": 462, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Gateway disconnected.", "surface": "android", @@ -2779,7 +2779,7 @@ }, { "kind": "ui-named-argument", - "line": 461, + "line": 463, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connect the gateway to load approval requests in the app.", "surface": "android", @@ -2787,7 +2787,7 @@ }, { "kind": "ui-named-argument", - "line": 467, + "line": 469, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No gateway approvals.", "surface": "android", @@ -2795,7 +2795,7 @@ }, { "kind": "ui-named-argument", - "line": 468, + "line": 470, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Exec approval requests will appear here while this phone is connected.", "surface": "android", @@ -2803,7 +2803,7 @@ }, { "kind": "ui-named-argument", - "line": 475, + "line": 477, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Session activity", "surface": "android", @@ -2811,7 +2811,7 @@ }, { "kind": "ui-named-argument", - "line": 476, + "line": 478, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Chat tool calls waiting in the active session remain visible here.", "surface": "android", @@ -2819,7 +2819,7 @@ }, { "kind": "ui-named-argument", - "line": 490, + "line": 492, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "How this phone appears to OpenClaw.", "surface": "android", @@ -2827,7 +2827,7 @@ }, { "kind": "ui-named-argument", - "line": 490, + "line": 492, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Profile", "surface": "android", @@ -2835,7 +2835,7 @@ }, { "kind": "ui-named-argument", - "line": 493, + "line": 495, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Device name", "surface": "android", @@ -2843,7 +2843,7 @@ }, { "kind": "ui-named-argument", - "line": 494, + "line": 496, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Save Profile", "surface": "android", @@ -2851,7 +2851,7 @@ }, { "kind": "ui-named-argument", - "line": 513, + "line": 515, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Configure voice, transport, and playback.", "surface": "android", @@ -2859,7 +2859,7 @@ }, { "kind": "ui-named-argument", - "line": 513, + "line": 515, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Talk Provider Setup", "surface": "android", @@ -2867,7 +2867,7 @@ }, { "kind": "ui-named-argument", - "line": 516, + "line": 518, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Audio Test", "surface": "android", @@ -2875,7 +2875,7 @@ }, { "kind": "ui-named-argument", - "line": 517, + "line": 519, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Check that OpenClaw can speak clearly on this phone.", "surface": "android", @@ -2883,7 +2883,7 @@ }, { "kind": "conditional-branch", - "line": 520, + "line": 522, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Enable speaker", "surface": "android", @@ -2891,7 +2891,7 @@ }, { "kind": "conditional-branch", - "line": 520, + "line": 522, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Mute speaker", "surface": "android", @@ -2899,7 +2899,7 @@ }, { "kind": "conditional-branch", - "line": 521, + "line": 523, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Assistant speech muted", "surface": "android", @@ -2907,7 +2907,7 @@ }, { "kind": "conditional-branch", - "line": 521, + "line": 523, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Replies play aloud", "surface": "android", @@ -2915,7 +2915,7 @@ }, { "kind": "conditional-branch", - "line": 523, + "line": 525, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Muted", "surface": "android", @@ -2923,7 +2923,7 @@ }, { "kind": "conditional-branch", - "line": 523, + "line": 525, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "On", "surface": "android", @@ -2931,7 +2931,7 @@ }, { "kind": "ui-named-argument", - "line": 527, + "line": 529, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Done", "surface": "android", @@ -2939,7 +2939,7 @@ }, { "kind": "ui-named-argument", - "line": 537, + "line": 539, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Realtime Talk", "surface": "android", @@ -2947,7 +2947,7 @@ }, { "kind": "ui-named-argument", - "line": 538, + "line": 540, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Dictation", "surface": "android", @@ -2955,7 +2955,7 @@ }, { "kind": "conditional-branch", - "line": 666, + "line": 664, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Allowlist", "surface": "android", @@ -2963,7 +2963,7 @@ }, { "kind": "conditional-branch", - "line": 666, + "line": 664, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Blocklist", "surface": "android", @@ -2971,7 +2971,7 @@ }, { "kind": "ui-named-argument", - "line": 699, + "line": 697, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Choose what reaches OpenClaw.", "surface": "android", @@ -2979,7 +2979,7 @@ }, { "kind": "ui-named-argument", - "line": 699, + "line": 697, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Notifications", "surface": "android", @@ -2987,7 +2987,7 @@ }, { "kind": "conditional-branch", - "line": 703, + "line": 701, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Alerts stay on this phone.", "surface": "android", @@ -2995,7 +2995,7 @@ }, { "kind": "conditional-branch", - "line": 703, + "line": 701, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw can receive selected alerts.", "surface": "android", @@ -3003,7 +3003,7 @@ }, { "kind": "conditional-branch", - "line": 715, + "line": 713, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Granted", "surface": "android", @@ -3011,7 +3011,7 @@ }, { "kind": "conditional-branch", - "line": 715, + "line": 713, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Setup", "surface": "android", @@ -3019,7 +3019,7 @@ }, { "kind": "conditional-branch", - "line": 720, + "line": 718, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Check Access", "surface": "android", @@ -3027,7 +3027,7 @@ }, { "kind": "conditional-branch", - "line": 720, + "line": 718, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Open System Access", "surface": "android", @@ -3035,7 +3035,7 @@ }, { "kind": "ui-named-argument", - "line": 730, + "line": 728, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Forwarding Mode", "surface": "android", @@ -3043,7 +3043,7 @@ }, { "kind": "ui-named-argument", - "line": 779, + "line": 777, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "App Filter", "surface": "android", @@ -3051,7 +3051,7 @@ }, { "kind": "conditional-branch", - "line": 786, + "line": 784, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Close App Picker", "surface": "android", @@ -3059,7 +3059,7 @@ }, { "kind": "conditional-branch", - "line": 786, + "line": 784, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Open App Picker", "surface": "android", @@ -3067,7 +3067,7 @@ }, { "kind": "ui-named-argument", - "line": 791, + "line": 789, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Search apps", "surface": "android", @@ -3075,7 +3075,7 @@ }, { "kind": "ui-named-argument", - "line": 794, + "line": 792, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Show System Apps", "surface": "android", @@ -3083,7 +3083,7 @@ }, { "kind": "ui-named-argument", - "line": 795, + "line": 793, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Include Android and background packages.", "surface": "android", @@ -3091,7 +3091,7 @@ }, { "kind": "ui-named-argument", - "line": 802, + "line": 800, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No matching apps.", "surface": "android", @@ -3099,7 +3099,7 @@ }, { "kind": "ui-named-argument", - "line": 813, + "line": 811, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Showing ${visibleApps.size} of ${apps.size}. Refine search for more.", "surface": "android", @@ -3107,7 +3107,7 @@ }, { "kind": "ui-named-argument", - "line": 1052, + "line": 1050, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Choose what this phone can share.", "surface": "android", @@ -3115,7 +3115,7 @@ }, { "kind": "ui-named-argument", - "line": 1052, + "line": 1050, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Phone Capabilities", "surface": "android", @@ -3123,7 +3123,7 @@ }, { "kind": "conditional-branch", - "line": 1061, + "line": 1059, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Allow photo library access.", "surface": "android", @@ -3131,7 +3131,7 @@ }, { "kind": "conditional-branch", - "line": 1061, + "line": 1059, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Selected or full photo access granted.", "surface": "android", @@ -3139,7 +3139,7 @@ }, { "kind": "conditional-branch", - "line": 1071, + "line": 1069, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "App list stays on this phone.", "surface": "android", @@ -3147,7 +3147,7 @@ }, { "kind": "conditional-branch", - "line": 1071, + "line": 1069, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw can list launcher-visible apps.", "surface": "android", @@ -3155,7 +3155,7 @@ }, { "kind": "ui-named-argument", - "line": 1082, + "line": 1080, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Location", "surface": "android", @@ -3163,7 +3163,7 @@ }, { "kind": "ui-named-argument", - "line": 1090, + "line": 1088, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Always allows requested location checks while OpenClaw is in the background; Android shows this in the persistent node notification.", "surface": "android", @@ -3171,7 +3171,7 @@ }, { "kind": "ui-call", - "line": 1115, + "line": 1113, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Allow background location?", "surface": "android", @@ -3179,7 +3179,7 @@ }, { "kind": "ui-call", - "line": 1117, + "line": 1115, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw only checks location when your paired Gateway requests it. ", "surface": "android", @@ -3187,7 +3187,7 @@ }, { "kind": "ui-call", - "line": 1130, + "line": 1128, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Open Settings", "surface": "android", @@ -3195,7 +3195,7 @@ }, { "kind": "ui-call", - "line": 1135, + "line": 1133, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Not Now", "surface": "android", @@ -3203,7 +3203,7 @@ }, { "kind": "ui-call", - "line": 1183, + "line": 1181, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Replace gateway setup?", "surface": "android", @@ -3211,7 +3211,7 @@ }, { "kind": "ui-call", - "line": 1198, + "line": 1196, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Replace setup", "surface": "android", @@ -3219,7 +3219,7 @@ }, { "kind": "ui-call", - "line": 1214, + "line": 1212, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Forget gateway?", "surface": "android", @@ -3227,7 +3227,7 @@ }, { "kind": "ui-call", - "line": 1227, + "line": 1225, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Cancel", "surface": "android", @@ -3235,7 +3235,7 @@ }, { "kind": "ui-named-argument", - "line": 1233, + "line": 1231, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connection between this phone and OpenClaw.", "surface": "android", @@ -3243,7 +3243,7 @@ }, { "kind": "conditional-branch", - "line": 1237, + "line": 1235, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connected", "surface": "android", @@ -3251,7 +3251,7 @@ }, { "kind": "conditional-branch", - "line": 1237, + "line": 1235, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Offline", "surface": "android", @@ -3259,7 +3259,7 @@ }, { "kind": "conditional-branch", - "line": 1238, + "line": 1236, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Not paired", "surface": "android", @@ -3267,7 +3267,7 @@ }, { "kind": "conditional-branch", - "line": 1238, + "line": 1236, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Online", "surface": "android", @@ -3275,7 +3275,7 @@ }, { "kind": "ui-named-argument", - "line": 1248, + "line": 1246, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Reconnect", "surface": "android", @@ -3283,7 +3283,7 @@ }, { "kind": "ui-named-argument", - "line": 1249, + "line": 1247, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Disconnect", "surface": "android", @@ -3291,7 +3291,7 @@ }, { "kind": "ui-named-argument", - "line": 1253, + "line": 1251, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Gateways", "surface": "android", @@ -3299,7 +3299,7 @@ }, { "kind": "ui-named-argument", - "line": 1255, + "line": 1253, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No paired gateways.", "surface": "android", @@ -3307,7 +3307,7 @@ }, { "kind": "ui-call", - "line": 1275, + "line": 1273, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Forget", "surface": "android", @@ -3315,7 +3315,7 @@ }, { "kind": "ui-named-argument", - "line": 1297, + "line": 1295, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Gateway setup", "surface": "android", @@ -3323,7 +3323,7 @@ }, { "kind": "ui-named-argument", - "line": 1299, + "line": 1297, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Scan or paste a setup code to add another gateway.", "surface": "android", @@ -3331,7 +3331,7 @@ }, { "kind": "ui-named-argument", - "line": 1306, + "line": 1304, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Add gateway", "surface": "android", @@ -3339,7 +3339,7 @@ }, { "kind": "ui-named-argument", - "line": 1307, + "line": 1305, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Setup Code", "surface": "android", @@ -3347,7 +3347,7 @@ }, { "kind": "ui-named-argument", - "line": 1311, + "line": 1309, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Android can scan or paste an existing setup code, but this gateway does not expose setup-code generation to the app yet. Generate the QR/code on the gateway host with openclaw qr, then scan it here or paste the setup code below.", "surface": "android", @@ -3355,7 +3355,7 @@ }, { "kind": "ui-named-argument", - "line": 1320, + "line": 1318, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connection Setup", "surface": "android", @@ -3363,7 +3363,7 @@ }, { "kind": "ui-named-argument", - "line": 1321, + "line": 1319, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Setup code", "surface": "android", @@ -3371,7 +3371,7 @@ }, { "kind": "ui-named-argument", - "line": 1323, + "line": 1321, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Host", "surface": "android", @@ -3379,7 +3379,7 @@ }, { "kind": "ui-named-argument", - "line": 1324, + "line": 1322, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Port", "surface": "android", @@ -3387,7 +3387,7 @@ }, { "kind": "ui-named-argument", - "line": 1326, + "line": 1324, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connection security", "surface": "android", @@ -3395,7 +3395,7 @@ }, { "kind": "conditional-branch", - "line": 1330, + "line": 1328, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Secure (TLS)", "surface": "android", @@ -3403,7 +3403,7 @@ }, { "kind": "conditional-branch", - "line": 1330, + "line": 1328, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Unencrypted", "surface": "android", @@ -3411,7 +3411,7 @@ }, { "kind": "ui-named-argument", - "line": 1347, + "line": 1345, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Token", "surface": "android", @@ -3419,7 +3419,7 @@ }, { "kind": "ui-named-argument", - "line": 1348, + "line": 1346, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Bootstrap", "surface": "android", @@ -3427,7 +3427,7 @@ }, { "kind": "ui-named-argument", - "line": 1350, + "line": 1348, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Password", "surface": "android", @@ -3435,7 +3435,7 @@ }, { "kind": "ui-named-argument", - "line": 1355, + "line": 1353, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Save & Connect", "surface": "android", @@ -3443,7 +3443,7 @@ }, { "kind": "ui-named-argument", - "line": 1372, + "line": 1370, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Enter a valid setup code or gateway address.", "surface": "android", @@ -3451,7 +3451,7 @@ }, { "kind": "ui-named-argument", - "line": 1398, + "line": 1396, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Appearance", "surface": "android", @@ -3459,7 +3459,7 @@ }, { "kind": "ui-named-argument", - "line": 1398, + "line": 1396, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Theme and translated Android text.", "surface": "android", @@ -3467,7 +3467,7 @@ }, { "kind": "ui-named-argument", - "line": 1410, + "line": 1408, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Theme", "surface": "android", @@ -3475,7 +3475,7 @@ }, { "kind": "ui-named-argument", - "line": 1420, + "line": 1418, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "App language", "surface": "android", @@ -3483,7 +3483,7 @@ }, { "kind": "ui-named-argument", - "line": 1422, + "line": 1420, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Changes Android text that OpenClaw has translated. Screens with English-only copy stay unchanged.", "surface": "android", @@ -3491,7 +3491,7 @@ }, { "kind": "ui-named-argument", - "line": 1459, + "line": 1457, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Selected", "surface": "android", @@ -3499,7 +3499,7 @@ }, { "kind": "conditional-branch", - "line": 1505, + "line": 1503, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Ready", "surface": "android", @@ -3507,7 +3507,7 @@ }, { "kind": "ui-named-argument", - "line": 1534, + "line": 1532, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "About", "surface": "android", @@ -3515,7 +3515,7 @@ }, { "kind": "ui-named-argument", - "line": 1534, + "line": 1532, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw for Android.", "surface": "android", @@ -3523,7 +3523,7 @@ }, { "kind": "ui-named-argument", - "line": 1547, + "line": 1545, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Gateway", "surface": "android", @@ -3531,7 +3531,7 @@ }, { "kind": "ui-named-argument", - "line": 1549, + "line": 1547, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Runtime", "surface": "android", @@ -3539,7 +3539,7 @@ }, { "kind": "ui-named-argument", - "line": 1552, + "line": 1550, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Update", "surface": "android", @@ -3547,7 +3547,7 @@ }, { "kind": "ui-named-argument", - "line": 1563, + "line": 1561, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "© 2026 OpenClaw Foundation — MIT License.", "surface": "android", @@ -3555,7 +3555,7 @@ }, { "kind": "ui-named-argument", - "line": 1580, + "line": 1578, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw logo", "surface": "android", @@ -3563,7 +3563,7 @@ }, { "kind": "ui-named-argument", - "line": 1582, + "line": 1580, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw", "surface": "android", @@ -3571,7 +3571,7 @@ }, { "kind": "ui-named-argument", - "line": 1583, + "line": 1581, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Personal AI on your devices", "surface": "android", @@ -3579,7 +3579,7 @@ }, { "kind": "ui-named-argument", - "line": 1642, + "line": 1640, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Licenses", "surface": "android", @@ -3587,7 +3587,7 @@ }, { "kind": "conditional-branch", - "line": 1643, + "line": 1641, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw appreciates its partners in the open-source community.", "surface": "android", @@ -3595,7 +3595,7 @@ }, { "kind": "ui-named-argument", - "line": 1652, + "line": 1650, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No license notices are packaged in this build.", "surface": "android", @@ -3603,7 +3603,7 @@ }, { "kind": "ui-named-argument", - "line": 1678, + "line": 1676, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Open ${license.title}", "surface": "android", @@ -3611,7 +3611,7 @@ }, { "kind": "conditional-branch", - "line": 1713, + "line": 1711, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Check", "surface": "android", @@ -3619,7 +3619,7 @@ }, { "kind": "ui-named-argument", - "line": 1746, + "line": 1744, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Back", "surface": "android", @@ -3627,7 +3627,7 @@ }, { "kind": "conditional-branch", - "line": 1820, + "line": 1818, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Sending", "surface": "android", @@ -3635,7 +3635,7 @@ }, { "kind": "conditional-branch", - "line": 1829, + "line": 1827, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Allow Once", "surface": "android", @@ -3643,7 +3643,7 @@ }, { "kind": "conditional-branch", - "line": 1829, + "line": 1827, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Allowing", "surface": "android", @@ -3651,7 +3651,7 @@ }, { "kind": "conditional-branch", - "line": 1837, + "line": 1835, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Always", "surface": "android", @@ -3659,7 +3659,7 @@ }, { "kind": "conditional-branch", - "line": 1837, + "line": 1835, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Saving", "surface": "android", @@ -3667,7 +3667,7 @@ }, { "kind": "conditional-branch", - "line": 1845, + "line": 1843, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Deny", "surface": "android", @@ -3675,7 +3675,7 @@ }, { "kind": "conditional-branch", - "line": 1845, + "line": 1843, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Denying", "surface": "android", @@ -3683,7 +3683,7 @@ }, { "kind": "conditional-branch", - "line": 1870, + "line": 1868, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Review", "surface": "android", @@ -3691,7 +3691,7 @@ }, { "kind": "conditional-branch", - "line": 1898, + "line": 1896, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Issue", "surface": "android", @@ -3699,7 +3699,7 @@ }, { "kind": "conditional-branch", - "line": 1928, + "line": 1926, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Enabled", "surface": "android", @@ -3707,7 +3707,7 @@ }, { "kind": "conditional-branch", - "line": 1942, + "line": 1940, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No", "surface": "android", @@ -3715,7 +3715,7 @@ }, { "kind": "conditional-branch", - "line": 1942, + "line": 1940, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Yes", "surface": "android", @@ -3723,7 +3723,7 @@ }, { "kind": "ui-named-argument", - "line": 1960, + "line": 1958, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Last Error", "surface": "android", @@ -3731,7 +3731,7 @@ }, { "kind": "ui-named-argument", - "line": 1963, + "line": 1961, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Delivery Error", "surface": "android", @@ -3739,7 +3739,7 @@ }, { "kind": "ui-named-argument", - "line": 2002, + "line": 2000, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Tap to copy", "surface": "android", @@ -3747,7 +3747,7 @@ }, { "kind": "ui-toast", - "line": 2017, + "line": 2015, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "$title copied", "surface": "android", @@ -3755,7 +3755,7 @@ }, { "kind": "conditional-branch", - "line": 2055, + "line": 2053, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Default assistant", "surface": "android", @@ -3763,7 +3763,7 @@ }, { "kind": "conditional-branch", - "line": 2057, + "line": 2055, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Default", "surface": "android", @@ -3771,7 +3771,7 @@ }, { "kind": "conditional-branch", - "line": 2113, + "line": 2111, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Needs attention", "surface": "android", @@ -3779,7 +3779,7 @@ }, { "kind": "conditional-branch", - "line": 2116, + "line": 2114, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Waiting ${minutes}m", "surface": "android", @@ -3787,7 +3787,7 @@ }, { "kind": "conditional-branch", - "line": 2116, + "line": 2114, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Waiting for review", "surface": "android", @@ -3795,7 +3795,7 @@ }, { "kind": "conditional-branch", - "line": 2144, + "line": 2142, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "${job.scheduleLabel} · ${formatCronWake(job.nextRunAtMs)} · ${job.promptPreview}", "surface": "android", @@ -3803,7 +3803,7 @@ }, { "kind": "conditional-branch", - "line": 2151, + "line": 2149, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No limits reported", "surface": "android", @@ -3811,7 +3811,7 @@ }, { "kind": "conditional-branch", - "line": 2182, + "line": 2180, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Off", "surface": "android", @@ -3819,7 +3819,7 @@ }, { "kind": "conditional-branch", - "line": 2203, + "line": 2201, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "System Event Text", "surface": "android", @@ -3827,7 +3827,7 @@ }, { "kind": "conditional-branch", - "line": 2204, + "line": 2202, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Agent Prompt", "surface": "android", @@ -3835,7 +3835,7 @@ }, { "kind": "conditional-branch", - "line": 2205, + "line": 2203, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Command", "surface": "android", @@ -3843,7 +3843,7 @@ }, { "kind": "conditional-branch", - "line": 2206, + "line": 2204, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Payload Text", "surface": "android", @@ -4731,7 +4731,7 @@ }, { "kind": "ui-named-argument", - "line": 296, + "line": 319, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt", "source": "Dictation", "surface": "android", @@ -4739,7 +4739,7 @@ }, { "kind": "ui-named-argument", - "line": 297, + "line": 320, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt", "source": "Transcribe then send", "surface": "android", @@ -4747,7 +4747,7 @@ }, { "kind": "ui-named-argument", - "line": 299, + "line": 322, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt", "source": "Dictation settings", "surface": "android", @@ -4755,7 +4755,7 @@ }, { "kind": "conditional-branch", - "line": 310, + "line": 333, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt", "source": "Sending to chat...", "surface": "android", @@ -4763,7 +4763,7 @@ }, { "kind": "conditional-branch", - "line": 310, + "line": 333, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt", "source": "Start speaking...", "surface": "android", @@ -4771,7 +4771,7 @@ }, { "kind": "ui-named-argument", - "line": 339, + "line": 365, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt", "source": "Speech provider", "surface": "android", @@ -4779,7 +4779,7 @@ }, { "kind": "ui-named-argument", - "line": 391, + "line": 417, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt", "source": "Tip: stop listening to send the captured turn.", "surface": "android", @@ -4787,7 +4787,7 @@ }, { "kind": "ui-named-argument", - "line": 401, + "line": 427, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt", "source": "Cancel", "surface": "android", @@ -4795,7 +4795,7 @@ }, { "kind": "conditional-branch", - "line": 402, + "line": 428, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt", "source": "Send to Chat", "surface": "android", @@ -4803,7 +4803,7 @@ }, { "kind": "ui-named-argument", - "line": 442, + "line": 456, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt", "source": "Back to voice", "surface": "android", @@ -4811,7 +4811,7 @@ }, { "kind": "ui-named-argument", - "line": 444, + "line": 458, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt", "source": "Realtime Talk", "surface": "android", @@ -4819,7 +4819,7 @@ }, { "kind": "ui-named-argument", - "line": 461, + "line": 475, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt", "source": "Talk settings", "surface": "android", @@ -4827,7 +4827,7 @@ }, { "kind": "ui-named-argument", - "line": 476, + "line": 499, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt", "source": "Live transcript", "surface": "android", @@ -4835,7 +4835,7 @@ }, { "kind": "conditional-branch", - "line": 487, + "line": 510, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt", "source": "Mute", "surface": "android", @@ -4843,7 +4843,7 @@ }, { "kind": "conditional-branch", - "line": 487, + "line": 510, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt", "source": "Unmute", "surface": "android", @@ -4851,7 +4851,7 @@ }, { "kind": "ui-named-argument", - "line": 493, + "line": 516, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt", "source": "End", "surface": "android", @@ -4859,7 +4859,7 @@ }, { "kind": "ui-named-argument", - "line": 516, + "line": 539, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt", "source": "Listening for your next turn.", "surface": "android", @@ -4867,7 +4867,7 @@ }, { "kind": "ui-named-argument", - "line": 616, + "line": 624, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt", "source": "OpenClaw", "surface": "android", @@ -4875,7 +4875,7 @@ }, { "kind": "ui-named-argument", - "line": 621, + "line": 629, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt", "source": "Search voice", "surface": "android", @@ -4883,7 +4883,7 @@ }, { "kind": "ui-named-argument", - "line": 629, + "line": 637, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt", "source": "Voice", "surface": "android", @@ -4891,7 +4891,7 @@ }, { "kind": "conditional-branch", - "line": 640, + "line": 648, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt", "source": "Mute speaker", "surface": "android", @@ -4899,7 +4899,7 @@ }, { "kind": "conditional-branch", - "line": 640, + "line": 648, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt", "source": "Unmute speaker", "surface": "android", @@ -4907,7 +4907,7 @@ }, { "kind": "conditional-branch", - "line": 723, + "line": 727, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt", "source": "End Talk", "surface": "android", @@ -4915,7 +4915,7 @@ }, { "kind": "conditional-branch", - "line": 735, + "line": 739, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt", "source": "Stop Dictation", "surface": "android", @@ -4923,7 +4923,7 @@ }, { "kind": "ui-named-argument", - "line": 872, + "line": 876, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt", "source": "Voice setup", "surface": "android", @@ -4931,7 +4931,7 @@ }, { "kind": "conditional-branch", - "line": 1054, + "line": 1037, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt", "source": "You", "surface": "android", @@ -4939,7 +4939,7 @@ }, { "kind": "ui-named-argument", - "line": 1072, + "line": 1055, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt", "source": "Sending", "surface": "android", @@ -4947,7 +4947,7 @@ }, { "kind": "ui-named-argument", - "line": 1074, + "line": 1057, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt", "source": "OpenClaw is preparing a response.", "surface": "android", @@ -4955,7 +4955,7 @@ }, { "kind": "ui-named-argument", - "line": 1089, + "line": 1072, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt", "source": "Permission needed", "surface": "android", @@ -4963,7 +4963,7 @@ }, { "kind": "ui-named-argument", - "line": 1090, + "line": 1073, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt", "source": "Microphone access is needed.", "surface": "android", @@ -4971,7 +4971,7 @@ }, { "kind": "ui-named-argument", - "line": 1092, + "line": 1075, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt", "source": "OpenClaw only listens when you start Talk or Dictation.", "surface": "android", @@ -4979,7 +4979,7 @@ }, { "kind": "ui-named-argument", - "line": 1096, + "line": 1079, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt", "source": "Enable Microphone", "surface": "android", @@ -5067,7 +5067,7 @@ }, { "kind": "ui-call", - "line": 256, + "line": 258, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt", "source": "Type a message…", "surface": "android", @@ -5075,7 +5075,7 @@ }, { "kind": "ui-named-argument", - "line": 270, + "line": 272, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt", "source": "Gateway is offline. Text messages are queued and sent after reconnecting.", "surface": "android", @@ -5083,7 +5083,7 @@ }, { "kind": "ui-named-argument", - "line": 300, + "line": 302, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt", "source": "Select thinking level", "surface": "android", @@ -5091,7 +5091,7 @@ }, { "kind": "ui-named-argument", - "line": 325, + "line": 327, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt", "source": "Attach", "surface": "android", @@ -5099,7 +5099,7 @@ }, { "kind": "ui-named-argument", - "line": 340, + "line": 342, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt", "source": "Refresh", "surface": "android", @@ -5107,7 +5107,7 @@ }, { "kind": "ui-named-argument", - "line": 348, + "line": 350, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt", "source": "Abort", "surface": "android", @@ -5115,7 +5115,7 @@ }, { "kind": "ui-named-argument", - "line": 383, + "line": 385, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt", "source": "Send", "surface": "android", @@ -5123,7 +5123,7 @@ }, { "kind": "ui-named-argument", - "line": 409, + "line": 411, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt", "source": "No commands found", "surface": "android", @@ -5411,7 +5411,7 @@ }, { "kind": "ui-named-argument", - "line": 322, + "line": 323, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Chat needs attention", "surface": "android", @@ -5419,7 +5419,7 @@ }, { "kind": "ui-named-argument", - "line": 504, + "line": 506, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "All", "surface": "android", @@ -5427,7 +5427,7 @@ }, { "kind": "ui-named-argument", - "line": 562, + "line": 564, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "OpenClaw", "surface": "android", @@ -5435,7 +5435,7 @@ }, { "kind": "ui-named-argument", - "line": 583, + "line": 585, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "New chat", "surface": "android", @@ -5443,7 +5443,7 @@ }, { "kind": "ui-named-argument", - "line": 588, + "line": 590, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "More new chat options", "surface": "android", @@ -5451,7 +5451,7 @@ }, { "kind": "ui-named-argument", - "line": 603, + "line": 605, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Refresh chat", "surface": "android", @@ -5459,7 +5459,7 @@ }, { "kind": "ui-named-argument", - "line": 606, + "line": 608, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Chat", "surface": "android", @@ -5467,7 +5467,7 @@ }, { "kind": "ui-named-argument", - "line": 754, + "line": 756, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Loading session", "surface": "android", @@ -5475,7 +5475,7 @@ }, { "kind": "ui-named-argument", - "line": 781, + "line": 783, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Jump to latest", "surface": "android", @@ -5483,7 +5483,7 @@ }, { "kind": "conditional-branch", - "line": 807, + "line": 809, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Ready when you are", "surface": "android", @@ -5491,7 +5491,7 @@ }, { "kind": "ui-named-argument", - "line": 838, + "line": 840, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Fix connection", "surface": "android", @@ -5499,7 +5499,7 @@ }, { "kind": "ui-named-argument", - "line": 839, + "line": 841, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Copy diagnostics", "surface": "android", @@ -5507,7 +5507,7 @@ }, { "kind": "conditional-branch", - "line": 1023, + "line": 1025, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Preparing audio…", "surface": "android", @@ -5515,7 +5515,7 @@ }, { "kind": "conditional-branch", - "line": 1023, + "line": 1025, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Speaking…", "surface": "android", @@ -5523,7 +5523,7 @@ }, { "kind": "ui-named-argument", - "line": 1044, + "line": 1046, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Tools running", "surface": "android", @@ -5531,7 +5531,7 @@ }, { "kind": "ui-named-argument", - "line": 1046, + "line": 1048, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "OpenClaw is working", "surface": "android", @@ -5539,7 +5539,7 @@ }, { "kind": "ui-named-argument", - "line": 1049, + "line": 1051, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "+${toolCalls.size - 4} more", "surface": "android", @@ -5547,7 +5547,7 @@ }, { "kind": "ui-named-argument", - "line": 1059, + "line": 1061, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Thinking", "surface": "android", @@ -5555,7 +5555,7 @@ }, { "kind": "ui-named-argument", - "line": 1060, + "line": 1062, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "OpenClaw is preparing a response.", "surface": "android", @@ -5563,7 +5563,7 @@ }, { "kind": "ui-named-argument", - "line": 1222, + "line": 1226, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Stop", "surface": "android", @@ -5571,7 +5571,7 @@ }, { "kind": "ui-named-argument", - "line": 1288, + "line": 1292, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Default", "surface": "android", @@ -5579,7 +5579,7 @@ }, { "kind": "conditional-branch", - "line": 1354, + "line": 1358, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Pin model", "surface": "android", @@ -5587,7 +5587,7 @@ }, { "kind": "conditional-branch", - "line": 1354, + "line": 1358, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Unpin model", "surface": "android", @@ -5595,7 +5595,7 @@ }, { "kind": "ui-named-argument", - "line": 1371, + "line": 1375, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "No commands found", "surface": "android", @@ -5603,7 +5603,7 @@ }, { "kind": "ui-named-argument", - "line": 1433, + "line": 1437, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Gateway offline", "surface": "android", @@ -5611,7 +5611,7 @@ }, { "kind": "ui-named-argument", - "line": 1533, + "line": 1537, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Attach image", "surface": "android", @@ -5619,7 +5619,7 @@ }, { "kind": "ui-named-argument", - "line": 1568, + "line": 1572, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Message OpenClaw", "surface": "android", @@ -5627,7 +5627,7 @@ }, { "kind": "ui-named-argument", - "line": 1584, + "line": 1588, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Open voice", "surface": "android", @@ -5635,7 +5635,7 @@ }, { "kind": "ui-named-argument", - "line": 1633, + "line": 1637, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Remove attachment", "surface": "android", @@ -5643,7 +5643,7 @@ }, { "kind": "conditional-branch", - "line": 1654, + "line": 1658, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Main", "surface": "android", @@ -5651,7 +5651,7 @@ }, { "kind": "conditional-branch", - "line": 1662, + "line": 1666, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "$emoji $name", "surface": "android", @@ -5659,7 +5659,7 @@ }, { "kind": "ui-named-argument", - "line": 1721, + "line": 1725, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Send", "surface": "android", @@ -5667,7 +5667,7 @@ }, { "kind": "conditional-branch", - "line": 1761, + "line": 1765, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "$contextLabel · ${contextMeterThinkingLabel(thinkingLevel)}", "surface": "android", @@ -5675,7 +5675,7 @@ }, { "kind": "ui-named-argument", - "line": 109, + "line": 112, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/VoiceNoteComposer.kt", "source": "Preparing voice note…", "surface": "android", @@ -5683,7 +5683,7 @@ }, { "kind": "ui-named-argument", - "line": 129, + "line": 132, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/VoiceNoteComposer.kt", "source": "Record voice note", "surface": "android", @@ -5691,7 +5691,7 @@ }, { "kind": "ui-named-argument", - "line": 167, + "line": 174, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/VoiceNoteComposer.kt", "source": "Cancel voice note", "surface": "android", @@ -5699,7 +5699,7 @@ }, { "kind": "ui-named-argument", - "line": 178, + "line": 185, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/VoiceNoteComposer.kt", "source": "Finish voice note", "surface": "android", @@ -5707,7 +5707,7 @@ }, { "kind": "ui-named-argument", - "line": 205, + "line": 212, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/VoiceNoteComposer.kt", "source": "Voice note", "surface": "android", @@ -5923,7 +5923,7 @@ }, { "kind": "conditional-branch", - "line": 598, + "line": 612, "path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt", "source": "Listening", "surface": "android", @@ -5931,7 +5931,7 @@ }, { "kind": "conditional-branch", - "line": 598, + "line": 612, "path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt", "source": "Ready", "surface": "android", @@ -5939,7 +5939,7 @@ }, { "kind": "conditional-branch", - "line": 1007, + "line": 1021, "path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt", "source": "Off", "surface": "android", @@ -5947,7 +5947,7 @@ }, { "kind": "conditional-branch", - "line": 1008, + "line": 1022, "path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt", "source": "Talk failed: Realtime provider closed unexpectedly.", "surface": "android", @@ -5955,7 +5955,7 @@ }, { "kind": "conditional-branch", - "line": 1009, + "line": 1023, "path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt", "source": "Talk failed: Realtime provider closed: $reason", "surface": "android", @@ -5963,7 +5963,7 @@ }, { "kind": "conditional-branch", - "line": 2051, + "line": 2082, "path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt", "source": "Aborted", "surface": "android", @@ -5971,7 +5971,7 @@ }, { "kind": "conditional-branch", - "line": 2051, + "line": 2082, "path": "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt", "source": "Chat error", "surface": "android", @@ -10995,7 +10995,7 @@ }, { "kind": "ui-call", - "line": 67, + "line": 68, "path": "apps/ios/Sources/Design/TalkProTab.swift", "source": "Not Now", "surface": "apple", @@ -11003,7 +11003,7 @@ }, { "kind": "ui-modifier", - "line": 106, + "line": 107, "path": "apps/ios/Sources/Design/TalkProTab.swift", "source": "Talk", "surface": "apple", @@ -11011,7 +11011,7 @@ }, { "kind": "ui-call", - "line": 152, + "line": 157, "path": "apps/ios/Sources/Design/TalkProTab.swift", "source": "Conversation", "surface": "apple", @@ -11019,7 +11019,7 @@ }, { "kind": "ui-call", - "line": 153, + "line": 158, "path": "apps/ios/Sources/Design/TalkProTab.swift", "source": "Agent", "surface": "apple", @@ -11027,7 +11027,7 @@ }, { "kind": "ui-call", - "line": 154, + "line": 159, "path": "apps/ios/Sources/Design/TalkProTab.swift", "source": "Session", "surface": "apple", @@ -11035,7 +11035,7 @@ }, { "kind": "ui-call", - "line": 155, + "line": 160, "path": "apps/ios/Sources/Design/TalkProTab.swift", "source": "Runtime", "surface": "apple", @@ -11043,7 +11043,7 @@ }, { "kind": "ui-call", - "line": 160, + "line": 165, "path": "apps/ios/Sources/Design/TalkProTab.swift", "source": "Voice Mode", "surface": "apple", @@ -11051,7 +11051,7 @@ }, { "kind": "ui-call", - "line": 161, + "line": 166, "path": "apps/ios/Sources/Design/TalkProTab.swift", "source": "Configured", "surface": "apple", @@ -11059,7 +11059,7 @@ }, { "kind": "ui-call", - "line": 162, + "line": 167, "path": "apps/ios/Sources/Design/TalkProTab.swift", "source": "Active", "surface": "apple", @@ -11067,7 +11067,7 @@ }, { "kind": "ui-call", - "line": 163, + "line": 168, "path": "apps/ios/Sources/Design/TalkProTab.swift", "source": "Transport", "surface": "apple", @@ -11075,7 +11075,7 @@ }, { "kind": "ui-call", - "line": 165, + "line": 170, "path": "apps/ios/Sources/Design/TalkProTab.swift", "source": "Last issue", "surface": "apple", @@ -11083,7 +11083,7 @@ }, { "kind": "ui-call", - "line": 167, + "line": 172, "path": "apps/ios/Sources/Design/TalkProTab.swift", "source": "Permission", "surface": "apple", @@ -11091,7 +11091,7 @@ }, { "kind": "ui-call", - "line": 168, + "line": 173, "path": "apps/ios/Sources/Design/TalkProTab.swift", "source": "Speech language", "surface": "apple", @@ -11099,7 +11099,7 @@ }, { "kind": "ui-call", - "line": 173, + "line": 178, "path": "apps/ios/Sources/Design/TalkProTab.swift", "source": "Controls", "surface": "apple", @@ -11107,7 +11107,7 @@ }, { "kind": "ui-call", - "line": 175, + "line": 180, "path": "apps/ios/Sources/Design/TalkProTab.swift", "source": "Speakerphone", "surface": "apple", @@ -11115,7 +11115,7 @@ }, { "kind": "ui-call", - "line": 180, + "line": 185, "path": "apps/ios/Sources/Design/TalkProTab.swift", "source": "Background listening", "surface": "apple", @@ -11123,7 +11123,7 @@ }, { "kind": "ui-call", - "line": 186, + "line": 191, "path": "apps/ios/Sources/Design/TalkProTab.swift", "source": "Voice & Talk Settings", "surface": "apple", @@ -11131,7 +11131,7 @@ }, { "kind": "conditional-branch", - "line": 355, + "line": 368, "path": "apps/ios/Sources/Design/TalkProTab.swift", "source": "Gateway permission required", "surface": "apple", @@ -11139,7 +11139,7 @@ }, { "kind": "conditional-branch", - "line": 357, + "line": 370, "path": "apps/ios/Sources/Design/TalkProTab.swift", "source": "Requesting approval", "surface": "apple", @@ -11147,7 +11147,7 @@ }, { "kind": "conditional-branch", - "line": 359, + "line": 372, "path": "apps/ios/Sources/Design/TalkProTab.swift", "source": "Approval requested", "surface": "apple", @@ -11155,7 +11155,7 @@ }, { "kind": "conditional-branch", - "line": 361, + "line": 374, "path": "apps/ios/Sources/Design/TalkProTab.swift", "source": "Voice API key missing", "surface": "apple", @@ -11163,7 +11163,7 @@ }, { "kind": "conditional-branch", - "line": 363, + "line": 376, "path": "apps/ios/Sources/Design/TalkProTab.swift", "source": "Voice config failed", "surface": "apple", @@ -11171,7 +11171,7 @@ }, { "kind": "conditional-branch", - "line": 408, + "line": 421, "path": "apps/ios/Sources/Design/TalkProTab.swift", "source": "Start Talk", "surface": "apple", @@ -11179,7 +11179,7 @@ }, { "kind": "conditional-branch", - "line": 409, + "line": 422, "path": "apps/ios/Sources/Design/TalkProTab.swift", "source": "Stop Talk", "surface": "apple", @@ -11187,7 +11187,7 @@ }, { "kind": "conditional-branch", - "line": 410, + "line": 423, "path": "apps/ios/Sources/Design/TalkProTab.swift", "source": "Enable Talk", "surface": "apple", @@ -11195,7 +11195,7 @@ }, { "kind": "conditional-branch", - "line": 411, + "line": 424, "path": "apps/ios/Sources/Design/TalkProTab.swift", "source": "Open Gateway Settings", "surface": "apple", @@ -11203,7 +11203,7 @@ }, { "kind": "conditional-branch", - "line": 411, + "line": 424, "path": "apps/ios/Sources/Design/TalkProTab.swift", "source": "Open Voice Settings", "surface": "apple", @@ -11211,7 +11211,7 @@ }, { "kind": "conditional-branch", - "line": 412, + "line": 425, "path": "apps/ios/Sources/Design/TalkProTab.swift", "source": "Demo Mode Only", "surface": "apple", @@ -11219,7 +11219,7 @@ }, { "kind": "conditional-branch", - "line": 412, + "line": 425, "path": "apps/ios/Sources/Design/TalkProTab.swift", "source": "Waiting for Approval", "surface": "apple", @@ -13507,7 +13507,7 @@ }, { "kind": "conditional-branch", - "line": 285, + "line": 295, "path": "apps/ios/Sources/Voice/TalkModeManager.swift", "source": "Offline", "surface": "apple", @@ -13515,7 +13515,7 @@ }, { "kind": "conditional-branch", - "line": 285, + "line": 295, "path": "apps/ios/Sources/Voice/TalkModeManager.swift", "source": "Ready", "surface": "apple", @@ -13523,7 +13523,7 @@ }, { "kind": "conditional-branch", - "line": 959, + "line": 969, "path": "apps/ios/Sources/Voice/TalkModeManager.swift", "source": "Listening", "surface": "apple", @@ -13531,7 +13531,7 @@ }, { "kind": "conditional-branch", - "line": 959, + "line": 969, "path": "apps/ios/Sources/Voice/TalkModeManager.swift", "source": "Speech error: \\(msg)", "surface": "apple", @@ -13539,7 +13539,7 @@ }, { "kind": "conditional-branch", - "line": 1160, + "line": 1170, "path": "apps/ios/Sources/Voice/TalkModeManager.swift", "source": "Aborted", "surface": "apple", @@ -13547,7 +13547,7 @@ }, { "kind": "conditional-branch", - "line": 1160, + "line": 1170, "path": "apps/ios/Sources/Voice/TalkModeManager.swift", "source": "Chat error", "surface": "apple", @@ -13555,7 +13555,7 @@ }, { "kind": "conditional-branch", - "line": 3006, + "line": 3065, "path": "apps/ios/Sources/Voice/TalkModeManager.swift", "source": "Native", "surface": "apple", @@ -13563,7 +13563,7 @@ }, { "kind": "conditional-branch", - "line": 3006, + "line": 3065, "path": "apps/ios/Sources/Voice/TalkModeManager.swift", "source": "Native WebRTC", "surface": "apple", @@ -13667,7 +13667,7 @@ }, { "kind": "conditional-branch", - "line": 508, + "line": 554, "path": "apps/ios/Sources/Voice/TalkRealtimeWebRTCSession.swift", "source": "Asking OpenClaw", "surface": "apple", @@ -13675,7 +13675,7 @@ }, { "kind": "conditional-branch", - "line": 508, + "line": 554, "path": "apps/ios/Sources/Voice/TalkRealtimeWebRTCSession.swift", "source": "Updating OpenClaw", "surface": "apple", @@ -14075,7 +14075,7 @@ }, { "kind": "ui-named-argument", - "line": 1096, + "line": 1104, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Refresh", "surface": "apple", @@ -14083,7 +14083,7 @@ }, { "kind": "ui-call", - "line": 1191, + "line": 1199, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "No chat synced", "surface": "apple", @@ -14091,7 +14091,7 @@ }, { "kind": "ui-call", - "line": 1198, + "line": 1206, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Tap the message pill below to start from your watch.", "surface": "apple", @@ -14099,7 +14099,7 @@ }, { "kind": "ui-call", - "line": 1246, + "line": 1254, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Message OpenClaw", "surface": "apple", @@ -14107,7 +14107,7 @@ }, { "kind": "ui-named-argument", - "line": 1349, + "line": 1357, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Approvals", "surface": "apple", @@ -14115,7 +14115,7 @@ }, { "kind": "ui-named-argument", - "line": 1352, + "line": 1360, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Clear", "surface": "apple", @@ -14123,7 +14123,7 @@ }, { "kind": "ui-named-argument", - "line": 1353, + "line": 1361, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "No approvals waiting", "surface": "apple", @@ -14131,7 +14131,7 @@ }, { "kind": "ui-named-argument", - "line": 1413, + "line": 1421, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Approval", "surface": "apple", @@ -14139,7 +14139,7 @@ }, { "kind": "ui-named-argument", - "line": 1427, + "line": 1435, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Sending decision...", "surface": "apple", @@ -14147,7 +14147,7 @@ }, { "kind": "ui-named-argument", - "line": 1431, + "line": 1439, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Approve", "surface": "apple", @@ -14155,7 +14155,7 @@ }, { "kind": "ui-named-argument", - "line": 1440, + "line": 1448, "path": "apps/ios/WatchApp/Sources/WatchInboxView.swift", "source": "Deny", "surface": "apple", @@ -20811,7 +20811,7 @@ }, { "kind": "conditional-branch", - "line": 62, + "line": 69, "path": "apps/macos/Sources/OpenClaw/TalkModeController.swift", "source": "Bottle", "surface": "apple", @@ -20819,7 +20819,7 @@ }, { "kind": "conditional-branch", - "line": 62, + "line": 69, "path": "apps/macos/Sources/OpenClaw/TalkModeController.swift", "source": "Submarine", "surface": "apple", @@ -22282,16 +22282,16 @@ "id": "native.apple.e6770d7cc616021f" }, { - "kind": "ui-call", - "line": 59, + "kind": "ui-modifier", + "line": 63, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/VoiceNoteComposerViews.swift", "source": "Recording", "surface": "apple", - "id": "native.apple.c971dc7dc243d0d1" + "id": "native.apple.4a226d89671518e1" }, { "kind": "ui-modifier", - "line": 75, + "line": 78, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/VoiceNoteComposerViews.swift", "source": "Cancel voice note", "surface": "apple", @@ -22299,7 +22299,7 @@ }, { "kind": "ui-modifier", - "line": 84, + "line": 87, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/VoiceNoteComposerViews.swift", "source": "Finish voice note", "surface": "apple", @@ -22307,7 +22307,7 @@ }, { "kind": "ui-localized-call", - "line": 143, + "line": 154, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/VoiceNoteRecorder.swift", "source": "Push-to-talk is using the microphone.", "surface": "apple", @@ -22315,7 +22315,7 @@ }, { "kind": "ui-localized-call", - "line": 150, + "line": 161, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/VoiceNoteRecorder.swift", "source": "Microphone access is required. Enable it in Settings.", "surface": "apple", @@ -22323,7 +22323,7 @@ }, { "kind": "ui-localized-call", - "line": 163, + "line": 174, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/VoiceNoteRecorder.swift", "source": "Could not start recording: \\(error.localizedDescription)", "surface": "apple", @@ -22331,7 +22331,7 @@ }, { "kind": "ui-localized-call", - "line": 246, + "line": 264, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/VoiceNoteRecorder.swift", "source": "Recording was interrupted. Try again.", "surface": "apple", diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt index 1effd9fe342..f1438be8cfe 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt @@ -262,6 +262,9 @@ class MainViewModel( val talkModeEnabled: StateFlow = runtimeState(initial = false) { it.talkModeEnabled } val talkModeListening: StateFlow = runtimeState(initial = false) { it.talkModeListening } val talkModeSpeaking: StateFlow = runtimeState(initial = false) { it.talkModeSpeaking } + val talkInputLevel: StateFlow = runtimeState(initial = 0f) { it.talkInputLevel } + val talkOutputLevel: StateFlow = runtimeState(initial = null) { it.talkOutputLevel } + val talkSpeechActive: StateFlow = runtimeState(initial = false) { it.talkSpeechActive } val talkModeStatusText: StateFlow = runtimeState(initial = "Off") { it.talkModeStatusText } val talkModeConversation: StateFlow> = runtimeState(initial = emptyList()) { it.talkModeConversation } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index 8325964a3e5..4004d7d0c6e 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -1259,6 +1259,15 @@ class NodeRuntime private constructor( val talkModeSpeaking: StateFlow get() = talkMode.isSpeaking + val talkInputLevel: StateFlow + get() = talkMode.inputLevel + + val talkOutputLevel: StateFlow + get() = talkMode.outputLevel + + val talkSpeechActive: StateFlow + get() = talkMode.speechActive + val talkModeStatusText: StateFlow get() = talkMode.statusText diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/VoiceNoteRecorderController.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/VoiceNoteRecorderController.kt index 1d553e3e334..6e5cf404916 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/VoiceNoteRecorderController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/VoiceNoteRecorderController.kt @@ -1,5 +1,6 @@ package ai.openclaw.app.chat +import ai.openclaw.app.voice.smoothedAudioLevel import android.content.Context import android.media.MediaRecorder import android.os.SystemClock @@ -44,6 +45,9 @@ internal interface VoiceNoteRecordingEngine { fun stop(): Long fun cancel() + + /** Peak abs PCM amplitude (0..32767) since the last poll; 0 when not recording. */ + fun pollAmplitude(): Int } /** Owns voice-note recording state and temporary-file cleanup. */ @@ -64,6 +68,9 @@ internal class VoiceNoteRecorderController( private val _elapsedMs = MutableStateFlow(0L) val elapsedMs: StateFlow = _elapsedMs.asStateFlow() + private val _inputLevel = MutableStateFlow(0f) + val inputLevel: StateFlow = _inputLevel.asStateFlow() + private var outputFile: File? = null private var elapsedJob: Job? = null private var ownsMic = false @@ -143,6 +150,7 @@ internal class VoiceNoteRecorderController( // coroutine is composition-scoped and may be cancelled before it runs, // so cancel() must still be able to delete the handed-off recording. _elapsedMs.value = 0L + _inputLevel.value = 0f _state.value = VoiceNoteRecorderState.Preparing releaseMicLocked() VoiceNoteRecording(file = file, durationMs = durationMs) @@ -171,6 +179,7 @@ internal class VoiceNoteRecorderController( outputFile?.delete() outputFile = null _elapsedMs.value = 0L + _inputLevel.value = 0f _state.value = VoiceNoteRecorderState.Idle } } @@ -190,13 +199,18 @@ internal class VoiceNoteRecorderController( while (isActive && state.value is VoiceNoteRecorderState.Recording) { val elapsed = (elapsedRealtimeMillis() - startedAt).coerceIn(0L, VOICE_NOTE_MAX_DURATION_MS) _elapsedMs.value = elapsed + // MediaRecorder reports the peak since the last poll; at 10Hz that + // reads close to the mean-abs mic levels used by the other waveform + // surfaces, so peak/32767 needs no extra curve. + val rawLevel = (engine.pollAmplitude().coerceIn(0, 32_767)) / 32_767f + _inputLevel.value = smoothedAudioLevel(_inputLevel.value, rawLevel) // MediaRecorder's duration callback races its asynchronous auto-stop. // Own the cap here so every successful finish calls stop() exactly once. if (elapsed >= VOICE_NOTE_MAX_DURATION_MS) { finish() return@launch } - delay(250L) + delay(100L) } } } @@ -213,6 +227,7 @@ internal class VoiceNoteRecorderController( releaseMicLocked() outputFile = null _elapsedMs.value = 0L + _inputLevel.value = 0f _state.value = VoiceNoteRecorderState.Failure(message) } @@ -282,4 +297,8 @@ internal class AndroidVoiceNoteRecordingEngine( runCatching { active.stop() } active.release() } + + // maxAmplitude throws until sampling starts on some OEMs; a dead meter beats + // killing the recording. + override fun pollAmplitude(): Int = recorder?.let { active -> runCatching { active.maxAmplitude }.getOrDefault(0) } ?: 0 } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt index b45971c2714..82ba78e1b51 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt @@ -49,6 +49,8 @@ import ai.openclaw.app.ui.design.ClawTextBadge import ai.openclaw.app.ui.design.ClawTextField import ai.openclaw.app.ui.design.ClawTheme import ai.openclaw.app.ui.design.OpenClawMascot +import ai.openclaw.app.ui.design.TalkWaveform +import ai.openclaw.app.ui.design.TalkWaveformPhase import android.Manifest import android.content.ClipData import android.content.ClipboardManager @@ -628,16 +630,12 @@ private fun SettingsWaveformPanel( horizontalArrangement = Arrangement.spacedBy(5.dp), ) { Icon(imageVector = Icons.Default.PlayArrow, contentDescription = null, modifier = Modifier.size(24.dp), tint = ClawTheme.colors.text) - Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically) { - listOf(6, 12, 18, 11, 28, 34, 18, 10, 8, 24, 38, 31, 12, 8, 18, 30, 40, 22, 12, 8, 20, 29, 16, 8).forEachIndexed { index, height -> - Box( - modifier = - Modifier - .size(width = 2.dp, height = (if (active) height else 7 + index % 4 * 4).dp) - .background(if (active) ClawTheme.colors.text else ClawTheme.colors.textSubtle, RoundedCornerShape(999.dp)), - ) - } - } + // Thinking is the preview phase: no capture runs on this screen, so the + // synthetic swell demonstrates the animation without touching the mic. + TalkWaveform( + phase = if (active) TalkWaveformPhase.Thinking else TalkWaveformPhase.Idle, + modifier = Modifier.weight(1f).height(48.dp), + ) } } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt index 947f3adef68..190bd4067df 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt @@ -14,6 +14,9 @@ import ai.openclaw.app.ui.design.ClawStatus import ai.openclaw.app.ui.design.ClawStatusPill import ai.openclaw.app.ui.design.ClawTheme import ai.openclaw.app.ui.design.OpenClawMascot +import ai.openclaw.app.ui.design.TalkWaveform +import ai.openclaw.app.ui.design.TalkWaveformPalette +import ai.openclaw.app.ui.design.TalkWaveformPhase import ai.openclaw.app.voice.VoiceConversationEntry import ai.openclaw.app.voice.VoiceConversationRole import android.Manifest @@ -106,6 +109,10 @@ fun VoiceScreen( val talkModeStatusText by viewModel.talkModeStatusText.collectAsState() val talkModeConversation by viewModel.talkModeConversation.collectAsState() val talkSetupReadiness by viewModel.talkSetupReadiness.collectAsState() + val micInputLevel by viewModel.micInputLevel.collectAsState() + val talkInputLevel by viewModel.talkInputLevel.collectAsState() + val talkOutputLevel by viewModel.talkOutputLevel.collectAsState() + val talkSpeechActive by viewModel.talkSpeechActive.collectAsState() var pendingAction by remember { mutableStateOf(null) } var hasMicPermission by remember { mutableStateOf(context.hasRecordAudioPermission()) } @@ -167,6 +174,10 @@ fun VoiceScreen( entries = talkModeConversation, listening = talkModeListening, speaking = talkModeSpeaking, + statusText = talkModeStatusText, + inputLevel = talkInputLevel, + outputLevel = talkOutputLevel, + speechActive = talkSpeechActive, speakerEnabled = speakerEnabled, onToggleSpeaker = { viewModel.setSpeakerEnabled(!speakerEnabled) }, onEndTalk = { viewModel.setTalkModeEnabled(false) }, @@ -183,6 +194,7 @@ fun VoiceScreen( conversation = micConversation, listening = micEnabled, sending = micIsSending, + inputLevel = micInputLevel, statusText = activeStatus, gatewayStatus = gatewayStatus, onCancel = { viewModel.cancelMicCapture() }, @@ -209,11 +221,21 @@ fun VoiceScreen( VoiceHero( gatewayStatus = gatewayStatus, - voiceCaptureMode = voiceCaptureMode, micEnabled = micEnabled, talkModeEnabled = talkModeEnabled, talkModeListening = talkModeListening, talkModeSpeaking = talkModeSpeaking, + orbPhase = + voiceHeroWaveformPhase( + micEnabled = micEnabled, + micInputLevel = micInputLevel, + talkModeEnabled = talkModeEnabled, + talkModeListening = talkModeListening, + talkModeSpeaking = talkModeSpeaking, + talkInputLevel = talkInputLevel, + talkOutputLevel = talkOutputLevel, + talkSpeechActive = talkSpeechActive, + ), micLiveTranscript = micLiveTranscript, gatewayReady = gatewayReady, voiceAttentionStatus = voiceAttentionStatus, @@ -271,6 +293,7 @@ private fun DictationScreen( conversation: List, listening: Boolean, sending: Boolean, + inputLevel: Float, statusText: String, gatewayStatus: String, onCancel: () -> Unit, @@ -314,7 +337,10 @@ private fun DictationScreen( overflow = TextOverflow.Ellipsis, ) Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) { - DictationWaveform(active = listening || sending) + TalkWaveform( + phase = TalkWaveformPhase.Listening(level = inputLevel, speechActive = false), + modifier = Modifier.fillMaxWidth().height(56.dp), + ) Row(horizontalArrangement = Arrangement.spacedBy(7.dp), verticalAlignment = Alignment.CenterVertically) { Icon(imageVector = Icons.Default.Mic, contentDescription = null, modifier = Modifier.size(15.dp), tint = if (listening) ClawTheme.colors.success else ClawTheme.colors.textMuted) Text(text = displayStatusText, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted) @@ -404,27 +430,15 @@ private fun DictationScreen( } } -@Composable -private fun DictationWaveform(active: Boolean) { - Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { - List(48) { index -> - val height = if (active) 3 + ((index * 7) % 16) else 3 + (index % 3) * 2 - Box( - modifier = - Modifier - .size(width = 2.dp, height = height.dp) - .clip(RoundedCornerShape(999.dp)) - .background(if (active) ClawTheme.colors.text else ClawTheme.colors.textSubtle), - ) - } - } -} - @Composable private fun TalkSessionScreen( entries: List, listening: Boolean, speaking: Boolean, + statusText: String, + inputLevel: Float, + outputLevel: Float?, + speechActive: Boolean, speakerEnabled: Boolean, onToggleSpeaker: () -> Unit, onEndTalk: () -> Unit, @@ -467,9 +481,18 @@ private fun TalkSessionScreen( color = ClawTheme.colors.canvas, border = BorderStroke(1.dp, ClawTheme.colors.borderStrong), ) { - Box(contentAlignment = Alignment.Center) { - TalkWaveform(active = listening || speaking) - } + TalkWaveform( + phase = + talkSessionWaveformPhase( + speaking = speaking, + listening = listening, + statusText = statusText, + inputLevel = inputLevel, + speechActive = speechActive, + outputLevel = outputLevel, + ), + modifier = Modifier.fillMaxSize(), + ) } Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) { @@ -583,21 +606,6 @@ private fun TalkControl( } } -@Composable -private fun TalkWaveform(active: Boolean) { - Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) { - listOf(4, 12, 24, 34, 46, 28, 12, 38, 44, 24, 12, 30, 42, 18, 6).forEachIndexed { index, height -> - Box( - modifier = - Modifier - .size(width = 3.dp, height = (if (active) height else 6 + index % 4 * 5).dp) - .clip(RoundedCornerShape(999.dp)) - .background(if (active) ClawTheme.colors.text else ClawTheme.colors.textSubtle), - ) - } - } -} - @Composable private fun VoiceHeader( statusText: String, @@ -647,11 +655,11 @@ private fun VoiceHeader( @Composable private fun VoiceHero( gatewayStatus: String, - voiceCaptureMode: VoiceCaptureMode, micEnabled: Boolean, talkModeEnabled: Boolean, talkModeListening: Boolean, talkModeSpeaking: Boolean, + orbPhase: TalkWaveformPhase, micLiveTranscript: String?, gatewayReady: Boolean, voiceAttentionStatus: String?, @@ -664,11 +672,7 @@ private fun VoiceHero( val talkNeedsSetup = gatewayReady && talkSetupReadiness.realtimeTalk.requiresSetup val dictationNeedsSetup = gatewayReady && talkSetupReadiness.dictation.requiresSetup Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(9.dp)) { - VoiceOrb( - active = micEnabled || talkModeEnabled, - listening = talkModeListening || voiceCaptureMode == VoiceCaptureMode.ManualMic, - speaking = talkModeSpeaking, - ) + VoiceOrb(phase = orbPhase) Row( modifier = Modifier.fillMaxWidth(), @@ -958,51 +962,30 @@ private fun VoicePrimaryAction( } } +// White wave stack for the tinted orb background (standard palette reds would +// vanish against the blue), mirroring how the macOS orb passes its own colors. +private val voiceOrbPalette = + TalkWaveformPalette( + active = listOf(Color.White, Color.White.copy(alpha = 0.75f), Color.White.copy(alpha = 0.5f)), + inactive = listOf(Color.White.copy(alpha = 0.62f), Color.White.copy(alpha = 0.5f), Color.White.copy(alpha = 0.38f)), + ) + @Composable -private fun VoiceOrb( - active: Boolean, - listening: Boolean, - speaking: Boolean, -) { +private fun VoiceOrb(phase: TalkWaveformPhase) { Surface( modifier = Modifier.size(112.dp), shape = CircleShape, - color = if (active || listening || speaking) Color(0xFF1976D2) else Color(0xFF123B63), + color = if (phase != TalkWaveformPhase.Idle) Color(0xFF1976D2) else Color(0xFF123B63), contentColor = Color.White, tonalElevation = 3.dp, shadowElevation = 7.dp, ) { - Box(contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { - Icon( - imageVector = - when { - speaking -> Icons.Default.RecordVoiceOver - listening -> Icons.Default.GraphicEq - else -> Icons.Default.Mic - }, - contentDescription = null, - modifier = Modifier.size(32.dp), - tint = Color.White, - ) - Waveform(active = active) - } - } - } -} - -@Composable -private fun Waveform(active: Boolean) { - Row(horizontalArrangement = Arrangement.spacedBy(3.dp), verticalAlignment = Alignment.CenterVertically) { - listOf(6, 11, 17, 23, 14, 9, 20, 14, 7).forEachIndexed { index, height -> - Box( - modifier = - Modifier - .size(width = 2.dp, height = (if (active) height else 6 + index % 3 * 3).dp) - .clip(RoundedCornerShape(999.dp)) - .background(if (active) Color.White else Color.White.copy(alpha = 0.52f)), - ) - } + // The circular surface clips the wave, matching the macOS orb treatment. + TalkWaveform( + phase = phase, + modifier = Modifier.fillMaxSize().padding(horizontal = 8.dp), + palette = voiceOrbPalette, + ) } } @@ -1116,6 +1099,43 @@ private fun runVoiceAction( } } +// Status literals TalkModeManager publishes while a turn runs but no audio +// flows yet; they are the only signal that the agent is being awaited. +private val talkAwaitingAgentStatuses = setOf("Thinking…", "Connecting…", "Generating voice…") + +internal fun talkSessionWaveformPhase( + speaking: Boolean, + listening: Boolean, + statusText: String, + inputLevel: Float, + speechActive: Boolean, + outputLevel: Float?, +): TalkWaveformPhase = + when { + speaking -> TalkWaveformPhase.Speaking(outputLevel) + statusText.trim() in talkAwaitingAgentStatuses -> TalkWaveformPhase.Thinking + listening -> TalkWaveformPhase.Listening(level = inputLevel, speechActive = speechActive) + else -> TalkWaveformPhase.Idle + } + +internal fun voiceHeroWaveformPhase( + micEnabled: Boolean, + micInputLevel: Float, + talkModeEnabled: Boolean, + talkModeListening: Boolean, + talkModeSpeaking: Boolean, + talkInputLevel: Float, + talkOutputLevel: Float?, + talkSpeechActive: Boolean, +): TalkWaveformPhase = + when { + talkModeSpeaking -> TalkWaveformPhase.Speaking(talkOutputLevel) + talkModeListening -> TalkWaveformPhase.Listening(level = talkInputLevel, speechActive = talkSpeechActive) + micEnabled -> TalkWaveformPhase.Listening(level = micInputLevel, speechActive = false) + talkModeEnabled -> TalkWaveformPhase.Thinking + else -> TalkWaveformPhase.Idle + } + internal fun voiceStatusLabel( gatewayStatus: String, voiceCaptureMode: VoiceCaptureMode, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt index 3a410a0e391..5417543a4bb 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt @@ -146,6 +146,7 @@ internal fun ChatComposer( onRemoveAttachment: (id: String) -> Unit, voiceNoteState: VoiceNoteRecorderState, voiceNoteElapsedMs: Long, + voiceNoteLevel: Float, recordVoiceNoteEnabled: Boolean, onStartVoiceNote: () -> Unit, onCancelVoiceNote: () -> Unit, @@ -227,6 +228,7 @@ internal fun ChatComposer( if (recordingVoiceNote) { VoiceNoteRecordingControls( elapsedMs = voiceNoteElapsedMs, + level = voiceNoteLevel, onCancel = onCancelVoiceNote, onDone = onFinishVoiceNote, ) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt index 6fe24d2ae7a..512481e291d 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt @@ -208,6 +208,7 @@ fun ChatScreen( ) val voiceNoteState by voiceNoteRecorder.state.collectAsState() val voiceNoteElapsedMs by voiceNoteRecorder.elapsedMs.collectAsState() + val voiceNoteLevel by voiceNoteRecorder.inputLevel.collectAsState() val pickImages = rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris -> if (uris.isNullOrEmpty()) return@rememberLauncherForActivityResult @@ -368,6 +369,7 @@ fun ChatScreen( onRemoveAttachment = { id -> attachments.removeAll { it.id == id } }, voiceNoteState = voiceNoteState, voiceNoteElapsedMs = voiceNoteElapsedMs, + voiceNoteLevel = voiceNoteLevel, recordVoiceNoteEnabled = pendingRunCount == 0 && !micCaptureActive, onStartVoiceNote = { scope.launch { voiceNoteRecorder.start() } }, onCancelVoiceNote = voiceNoteRecorder::cancel, @@ -1109,6 +1111,7 @@ private fun ChatComposer( onRemoveAttachment: (String) -> Unit, voiceNoteState: VoiceNoteRecorderState, voiceNoteElapsedMs: Long, + voiceNoteLevel: Float, recordVoiceNoteEnabled: Boolean, onStartVoiceNote: () -> Unit, onCancelVoiceNote: () -> Unit, @@ -1168,6 +1171,7 @@ private fun ChatComposer( if (voiceNoteState is VoiceNoteRecorderState.Recording) { VoiceNoteRecordingControls( elapsedMs = voiceNoteElapsedMs, + level = voiceNoteLevel, onCancel = onCancelVoiceNote, onDone = onFinishVoiceNote, modifier = Modifier.weight(1f), diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/VoiceNoteComposer.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/VoiceNoteComposer.kt index 4036876ea2e..d156b929031 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/VoiceNoteComposer.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/VoiceNoteComposer.kt @@ -6,12 +6,15 @@ import ai.openclaw.app.chat.ChatMessageContent import ai.openclaw.app.chat.VoiceNoteRecorderController import ai.openclaw.app.chat.VoiceNoteRecorderState import ai.openclaw.app.ui.design.ClawTheme +import ai.openclaw.app.ui.design.TalkWaveform +import ai.openclaw.app.ui.design.TalkWaveformPhase import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -134,6 +137,7 @@ internal fun VoiceNoteRecordButton( @Composable internal fun VoiceNoteRecordingControls( elapsedMs: Long, + level: Float, onCancel: () -> Unit, onDone: () -> Unit, modifier: Modifier = Modifier, @@ -154,7 +158,10 @@ internal fun VoiceNoteRecordingControls( Text( text = formatVoiceNoteDuration(elapsedMs), style = ClawTheme.type.label.copy(fontWeight = FontWeight.SemiBold), - modifier = Modifier.weight(1f), + ) + TalkWaveform( + phase = TalkWaveformPhase.Listening(level = level, speechActive = false), + modifier = Modifier.weight(1f).height(30.dp), ) Surface( onClick = onCancel, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/design/TalkWaveform.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/design/TalkWaveform.kt new file mode 100644 index 00000000000..1b7b1b01f6b --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/design/TalkWaveform.kt @@ -0,0 +1,266 @@ +package ai.openclaw.app.ui.design + +import android.provider.Settings +import androidx.compose.animation.core.withInfiniteAnimationFrameNanos +import androidx.compose.foundation.Canvas +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableDoubleStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.pow +import kotlin.math.sin + +/** + * Universal OpenClaw talk animation: Compose port of the shared Siri-style + * waveform in `apps/shared/OpenClawKit/Sources/OpenClawChatUI/TalkWaveformView.swift`. + * Every constant mirrors the Swift original; change them there first. + */ +internal sealed interface TalkWaveformPhase { + /** Voice surface is off or unavailable: flat, static, dimmed. */ + data object Idle : TalkWaveformPhase + + /** + * Connecting or waiting on the agent. No audio exists in this state, so the + * wave breathes on a slow synthetic swell by design. + */ + data object Thinking : TalkWaveformPhase + + /** + * Capturing the user's voice. [level] is the live microphone level in 0..1; + * [speechActive] raises the floor once endpointing detects actual speech. + */ + data class Listening( + val level: Float, + val speechActive: Boolean, + ) : TalkWaveformPhase + + /** + * Agent speech playback. [level] is the live playback envelope in 0..1. + * `null` means the active voice path exposes no envelope (system TTS and + * talk.speak compressed playback have no metering); the wave then falls + * back to a synthetic pulse rather than freezing. + */ + data class Speaking( + val level: Float?, + ) : TalkWaveformPhase +} + +/** + * Wave colors, front to back. Surfaces embedding the wave on tinted backgrounds + * (for example the voice orb) pass their own colors. + */ +internal data class TalkWaveformPalette( + val active: List, + val inactive: List, +) { + companion object { + val standard = + TalkWaveformPalette( + active = + listOf( + Color(red = 198 / 255f, green = 62 / 255f, blue = 56 / 255f), + Color(red = 0.95f, green = 0.45f, blue = 0.30f), + Color(red = 0.45f, green = 0.08f, blue = 0.12f), + ), + inactive = + listOf( + Color(red = 0.62f, green = 0.62f, blue = 0.62f), + Color(red = 0.72f, green = 0.72f, blue = 0.72f), + Color(red = 0.82f, green = 0.82f, blue = 0.82f), + ), + ) + } +} + +@Composable +internal fun TalkWaveform( + phase: TalkWaveformPhase, + modifier: Modifier = Modifier, + palette: TalkWaveformPalette = TalkWaveformPalette.standard, +) { + val context = LocalContext.current + // Compose frame clocks ignore the system animator scale; honor the OS + // "remove animations" setting explicitly (same pattern as OpenClawMascot). + val animationsEnabled = + remember(context) { + Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) > 0f + } + val idle = phase == TalkWaveformPhase.Idle + val frozen = !animationsEnabled || idle + var timeSeconds by remember { mutableDoubleStateOf(0.0) } + var bornNanos by remember { mutableLongStateOf(Long.MIN_VALUE) } + LaunchedEffect(frozen) { + if (frozen) { + timeSeconds = 0.0 + return@LaunchedEffect + } + var lastFrameNanos = Long.MIN_VALUE + while (true) { + withInfiniteAnimationFrameNanos { frameNanos -> + if (bornNanos == Long.MIN_VALUE) bornNanos = frameNanos + // ~30fps like the iOS TimelineView(minimumInterval: 1/30): skip + // intermediate vsync callbacks instead of redrawing at display rate. + if (lastFrameNanos == Long.MIN_VALUE || frameNanos - lastFrameNanos >= 33_000_000L) { + lastFrameNanos = frameNanos + timeSeconds = (frameNanos - bornNanos) / 1_000_000_000.0 + } + } + } + } + + val power = TalkWaveformMath.power(phase, timeSeconds) + val colors = if (idle) palette.inactive else palette.active + // The applied Claw theme (not the system setting) decides dark rendering. + val dark = ClawTheme.colors.canvas.luminance() < 0.5f + val midlineColor = ClawTheme.colors.textMuted.copy(alpha = 0.30f) + Canvas( + modifier = + modifier.graphicsLayer { + alpha = if (idle) 0.6f else 1f + // Waves must blend with each other (screen on dark), not with whatever + // happens to be drawn behind the widget. + compositingStrategy = CompositingStrategy.Offscreen + }, + ) { + val midY = size.height / 2f + drawLine( + color = midlineColor, + start = Offset(0f, midY), + end = Offset(size.width, midY), + strokeWidth = 1.dp.toPx(), + ) + // Screen blend pops on dark; opacity overlap reads better on light. + val blendMode = if (dark) BlendMode.Screen else BlendMode.SrcOver + val opacity = if (dark) 0.9f else 0.55f + colors.forEachIndexed { index, color -> + drawPath( + path = wavePath(size = size, time = timeSeconds, seed = index * 7.31, power = power), + color = color.copy(alpha = color.alpha * opacity), + blendMode = blendMode, + ) + } + } +} + +/** One wave = max envelope of three drifting lobes, mirrored around the midline. */ +private fun wavePath( + size: Size, + time: Double, + seed: Double, + power: Double, +): Path { + val midX = size.width / 2.0 + val midY = size.height / 2.0 + val lobes = TalkWaveformMath.lobes(time, seed) + + val upper = ArrayList() + var x = -midX + while (x <= midX) { + val graphX = x / (midX / 9.0) + var y = 0.0 + for (lobe in lobes) { + val amplitude = lobe.amplitude * midY * power + y = max(y, TalkWaveformMath.attenuatedSine(x = graphX, amplitude = amplitude, k = lobe.k, t = lobe.t)) + } + upper.add(Offset((midX + x).toFloat(), (midY - y).toFloat())) + x += 2.0 + } + + val path = Path() + path.moveTo(0f, midY.toFloat()) + for (point in upper) { + path.lineTo(point.x, point.y) + } + for (index in upper.indices.reversed()) { + val point = upper[index] + path.lineTo(point.x, (2.0 * midY).toFloat() - point.y) + } + path.close() + return path +} + +/** + * Pure waveform math, split from the composable for unit testing. Canonical + * reference for every constant is TalkWaveformMath in TalkWaveformView.swift. + */ +internal object TalkWaveformMath { + data class Lobe( + val amplitude: Double, + val k: Double, + val t: Double, + ) + + /** Per-phase drive for the wave amplitude in 0..1. */ + fun power( + phase: TalkWaveformPhase, + time: Double, + ): Double = + when (phase) { + TalkWaveformPhase.Idle -> 0.05 + TalkWaveformPhase.Thinking -> 0.16 + 0.10 * (0.5 + 0.5 * sin(time * 1.6)) + is TalkWaveformPhase.Listening -> { + val clamped = phase.level.toDouble().coerceIn(0.0, 1.0) + // Detected speech lifts the floor so the wave visibly commits to the + // user even when the mic level dips between words. + if (phase.speechActive) 0.55 + 0.45 * clamped else 0.30 + 0.65 * clamped + } + is TalkWaveformPhase.Speaking -> { + val level = phase.level + if (level == null) { + // Synthetic pulse for voice paths with no playback metering. + 0.70 * (0.55 + 0.45 * abs(sin(time * 5.0))) + } else { + 0.25 + 0.75 * level.toDouble().coerceIn(0.0, 1.0) + } + } + } + + /** + * Lobe parameters oscillate smoothly so peaks sweep back and forth across + * the line instead of scrolling off-screen. + */ + fun lobes( + time: Double, + seed: Double, + ): List = + (0 until 3).map { index -> + val f = index.toDouble() + val ampFrequency = 0.9 + 0.23 * f + val ampPhase = time * ampFrequency + seed * 2.4 + f * 2.1 + val amplitude = 0.30 + 0.70 * (0.5 + 0.5 * sin(ampPhase)) + val k = 0.62 + 0.11 * f + val driftFrequency = 0.45 + 0.17 * f + val driftPhase = time * driftFrequency + seed + f * 1.9 + Lobe(amplitude = amplitude, k = k, t = 2.8 * sin(driftPhase)) + } + + /** |A·sin(kx − t)| shaped by the bell envelope g = (K/(K+(kx−t′)²))^K, K = 4. */ + fun attenuatedSine( + x: Double, + amplitude: Double, + k: Double, + t: Double, + ): Double { + val sine = amplitude * sin(k * x - t) + val tPrime = t - PI / 2 + val envelope = (4.0 / (4.0 + (k * x - tPrime).pow(2))).pow(4.0) + return abs(sine * envelope) + } +} diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/AndroidAudioInputSession.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/AndroidAudioInputSession.kt index bf85a76aeae..02701bd9964 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/voice/AndroidAudioInputSession.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/AndroidAudioInputSession.kt @@ -203,6 +203,37 @@ private class BluetoothCommunicationRoute { private val bluetoothCommunicationRoute = BluetoothCommunicationRoute() +/** + * Mean-abs level of little-endian PCM16 audio in 0..1. Single implementation + * shared by mic capture (dictation, realtime talk) and playback metering so + * every waveform surface reads the same scale. + */ +internal fun pcm16MeanAbsLevel( + frame: ByteArray, + length: Int, +): Float { + var total = 0L + var count = 0 + var index = 0 + val limit = length - (length % 2) + while (index < limit) { + val sample = + (frame[index].toInt() and 0xff) or + (frame[index + 1].toInt() shl 8) + total += kotlin.math.abs(sample.toShort().toInt()) + count += 1 + index += 2 + } + if (count == 0) return 0f + return ((total / count).toFloat() / Short.MAX_VALUE).coerceIn(0f, 1f) +} + +/** iOS-parity level smoothing (new = old*0.8 + raw*0.2) for waveform meters. */ +internal fun smoothedAudioLevel( + previous: Float, + raw: Float, +): Float = previous * 0.8f + raw * 0.2f + /** Converts AudioRecord's negative return codes into capture-session failures. */ internal fun checkAudioRecordReadResult(result: Int): Int { if (result >= 0) return result diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/MicCaptureManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/MicCaptureManager.kt index 3b663fb0527..36606e787c9 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/voice/MicCaptureManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/MicCaptureManager.kt @@ -702,7 +702,7 @@ internal class MicCaptureManager( while (coroutineContext.isActive && _micEnabled.value && transcriptionSession == session) { val read = audioInput.read(buffer, 0, buffer.size) if (read <= 0) continue - _inputLevel.value = pcm16Level(buffer, read) + _inputLevel.value = pcm16MeanAbsLevel(buffer, read) audioFrames.trySend(buffer.copyOf(read)) } } catch (err: Throwable) { @@ -785,26 +785,6 @@ internal class MicCaptureManager( else -> "Listening" } - private fun pcm16Level( - frame: ByteArray, - length: Int, - ): Float { - var total = 0L - var count = 0 - var index = 0 - val limit = length - (length % 2) - while (index < limit) { - val sample = - (frame[index].toInt() and 0xff) or - (frame[index + 1].toInt() shl 8) - total += kotlin.math.abs(sample.toShort().toInt()) - count += 1 - index += 2 - } - if (count == 0) return 0f - return ((total / count).toFloat() / Short.MAX_VALUE).coerceIn(0f, 1f) - } - private fun pcm16ToPcmu(pcm16: ByteArray): ByteArray { val output = ByteArray(pcm16.size / 2) var inputIndex = 0 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 4d595d5809c..140e565f3af 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 @@ -181,6 +181,20 @@ class TalkModeManager internal constructor( private val _isSpeaking = MutableStateFlow(false) val isSpeaking: StateFlow = _isSpeaking + private val _inputLevel = MutableStateFlow(0f) + val inputLevel: StateFlow = _inputLevel + + // Null while no metered PCM playback is active. System TTS and talk.speak + // compressed playback expose no envelope; the waveform then shows the + // synthetic Speaking(null) pulse instead of a frozen line. + private val _outputLevel = MutableStateFlow(null) + val outputLevel: StateFlow = _outputLevel + + // True while the realtime provider streams a non-final user transcript, the + // closest Android has to iOS endpointing's "speech detected" signal. + private val _speechActive = MutableStateFlow(false) + val speechActive: StateFlow = _speechActive + private val _statusText = MutableStateFlow("Off") val statusText: StateFlow = _statusText @@ -1058,6 +1072,7 @@ class TalkModeManager internal constructor( while (coroutineContext.isActive && _isEnabled.value && realtimeSessionId == sessionId) { val read = audioInput.read(buffer, 0, buffer.size) if (read <= 0) continue + _inputLevel.value = smoothedAudioLevel(_inputLevel.value, pcm16MeanAbsLevel(buffer, read)) if (!shouldAppendRealtimeCapturedFrame(read)) continue audioFrames.trySend(buffer.copyOf(read)) } @@ -1068,6 +1083,7 @@ class TalkModeManager internal constructor( } finally { audioFrames.close() audioInput?.close() + _inputLevel.value = 0f } } } @@ -1124,6 +1140,11 @@ class TalkModeManager internal constructor( "transcript" -> { val role = obj["role"].asStringOrNull() val isFinal = obj["final"].asBooleanOrNull() == true + // A streaming (non-final) user transcript is the provider's speech + // signal; it raises the waveform floor like iOS endpointing does. + if (role == "user") { + _speechActive.value = !isFinal + } val text = realtimeTranscriptText(obj["text"].asStringOrNull(), isFinal) var assistantText: String? = null if (text != null) { @@ -1272,6 +1293,10 @@ class TalkModeManager internal constructor( if (track.playState != AudioTrack.PLAYSTATE_PLAYING) { track.play() } + // Blocking MODE_STREAM writes are playback-paced once the track buffer + // fills, so per-write metering tracks what the speaker actually plays. + _outputLevel.value = + smoothedAudioLevel(_outputLevel.value ?: 0f, pcm16MeanAbsLevel(bytes, writtenBytes)) _isSpeaking.value = true _statusText.value = "Speaking…" val durationMs = ((writtenBytes / 2.0) / realtimeSampleRateHz * 1000.0).toLong() @@ -1290,7 +1315,10 @@ class TalkModeManager internal constructor( val idle = synchronized(realtimePlaybackLock) { val playbackIdle = SystemClock.elapsedRealtime() >= realtimePlaybackEndsAtMs - if (playbackIdle) _isSpeaking.value = false + if (playbackIdle) { + _isSpeaking.value = false + _outputLevel.value = null + } playbackIdle } if (idle && _isEnabled.value && realtimeSessionId != null) { @@ -1322,6 +1350,7 @@ class TalkModeManager internal constructor( realtimeAudioTrack = null } _isSpeaking.value = false + _outputLevel.value = null if (_isEnabled.value) { _statusText.value = "Listening" } @@ -1362,6 +1391,8 @@ class TalkModeManager internal constructor( realtimeUserEntryAwaitingFinal = false realtimeUserEntryAwaitingFinalStartedAtMs = null realtimeAssistantEntryId = null + _speechActive.value = false + _inputLevel.value = 0f stopRealtimePlayback() if (preserveStatus) { _statusText.value = status diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/VoiceNoteRecorderControllerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/VoiceNoteRecorderControllerTest.kt index 16c3273502e..a6b0e7ac799 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/chat/VoiceNoteRecorderControllerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/VoiceNoteRecorderControllerTest.kt @@ -21,6 +21,7 @@ class VoiceNoteRecorderControllerTest { var stopCount = 0 var cancelCount = 0 var outputFile: File? = null + var amplitude = 0 override fun start(outputFile: File) { startCount += 1 @@ -36,6 +37,8 @@ class VoiceNoteRecorderControllerTest { override fun cancel() { cancelCount += 1 } + + override fun pollAmplitude(): Int = amplitude } @Test @@ -103,6 +106,27 @@ class VoiceNoteRecorderControllerTest { directory.deleteRecursively() } + @Test + fun recordingPublishesSmoothedLevelAndCancelClearsIt() = + runTest { + val directory = Files.createTempDirectory("voice-note-test").toFile() + val engine = FakeEngine() + val controller = controller(directory, engine) + + controller.start() + engine.amplitude = 32_767 + advanceTimeBy(50L) + runCurrent() + assertEquals(0.2f, controller.inputLevel.value, 1e-4f) + advanceTimeBy(100L) + runCurrent() + assertEquals(0.36f, controller.inputLevel.value, 1e-4f) + + controller.cancel() + assertEquals(0f, controller.inputLevel.value, 0f) + directory.deleteRecursively() + } + @Test fun cancelDeletesTemporaryFileAndReturnsIdle() = runTest { diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/VoiceScreenLogicTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/VoiceScreenLogicTest.kt index e8f3917ddad..72c21f78d4f 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/VoiceScreenLogicTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/VoiceScreenLogicTest.kt @@ -1,6 +1,7 @@ package ai.openclaw.app.ui import ai.openclaw.app.VoiceCaptureMode +import ai.openclaw.app.ui.design.TalkWaveformPhase import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test @@ -72,4 +73,68 @@ class VoiceScreenLogicTest { voiceRuntimeAttentionStatus("Transcription unavailable: UNAVAILABLE: Error: No realtime transcription provider registered"), ) } + + @Test + fun talkSessionWaveformPhaseFollowsTalkState() { + assertEquals( + TalkWaveformPhase.Speaking(0.4f), + talkSessionWaveformPhase(speaking = true, listening = true, statusText = "Speaking…", inputLevel = 0.2f, speechActive = true, outputLevel = 0.4f), + ) + // Thinking statuses win over the still-running capture loop. + assertEquals( + TalkWaveformPhase.Thinking, + talkSessionWaveformPhase(speaking = false, listening = true, statusText = "Thinking…", inputLevel = 0.2f, speechActive = false, outputLevel = null), + ) + assertEquals( + TalkWaveformPhase.Listening(level = 0.2f, speechActive = true), + talkSessionWaveformPhase(speaking = false, listening = true, statusText = "Listening", inputLevel = 0.2f, speechActive = true, outputLevel = null), + ) + assertEquals( + TalkWaveformPhase.Idle, + talkSessionWaveformPhase(speaking = false, listening = false, statusText = "Off", inputLevel = 0f, speechActive = false, outputLevel = null), + ) + } + + @Test + fun voiceHeroWaveformPhasePrefersTalkOverDictation() { + assertEquals( + TalkWaveformPhase.Speaking(null), + voiceHeroWaveformPhase( + micEnabled = true, + micInputLevel = 0.5f, + talkModeEnabled = true, + talkModeListening = true, + talkModeSpeaking = true, + talkInputLevel = 0.1f, + talkOutputLevel = null, + talkSpeechActive = false, + ), + ) + assertEquals( + TalkWaveformPhase.Listening(level = 0.5f, speechActive = false), + voiceHeroWaveformPhase( + micEnabled = true, + micInputLevel = 0.5f, + talkModeEnabled = false, + talkModeListening = false, + talkModeSpeaking = false, + talkInputLevel = 0f, + talkOutputLevel = null, + talkSpeechActive = false, + ), + ) + assertEquals( + TalkWaveformPhase.Thinking, + voiceHeroWaveformPhase( + micEnabled = false, + micInputLevel = 0f, + talkModeEnabled = true, + talkModeListening = false, + talkModeSpeaking = false, + talkInputLevel = 0f, + talkOutputLevel = null, + talkSpeechActive = false, + ), + ) + } } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/design/TalkWaveformMathTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/design/TalkWaveformMathTest.kt new file mode 100644 index 00000000000..daac28a8a34 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/design/TalkWaveformMathTest.kt @@ -0,0 +1,81 @@ +package ai.openclaw.app.ui.design + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.math.PI + +/** Guards the exact constants ported from TalkWaveformView.swift. */ +class TalkWaveformMathTest { + @Test + fun idlePowerIsStaticRegardlessOfTime() { + assertEquals(0.05, TalkWaveformMath.power(TalkWaveformPhase.Idle, 0.0), 1e-9) + assertEquals(0.05, TalkWaveformMath.power(TalkWaveformPhase.Idle, 42.5), 1e-9) + } + + @Test + fun thinkingBreathesInsideItsBand() { + assertEquals(0.21, TalkWaveformMath.power(TalkWaveformPhase.Thinking, 0.0), 1e-9) + var time = 0.0 + while (time < 10.0) { + val power = TalkWaveformMath.power(TalkWaveformPhase.Thinking, time) + assertTrue(power in 0.16..0.26) + time += 0.1 + } + } + + @Test + fun listeningClampsLevelAndSpeechRaisesFloor() { + assertEquals(0.30, TalkWaveformMath.power(TalkWaveformPhase.Listening(level = -0.5f, speechActive = false), 0.0), 1e-9) + assertEquals(0.95, TalkWaveformMath.power(TalkWaveformPhase.Listening(level = 1.5f, speechActive = false), 0.0), 1e-9) + assertEquals(0.56, TalkWaveformMath.power(TalkWaveformPhase.Listening(level = 0.4f, speechActive = false), 0.0), 1e-6) + assertEquals(0.73, TalkWaveformMath.power(TalkWaveformPhase.Listening(level = 0.4f, speechActive = true), 0.0), 1e-6) + assertEquals(1.0, TalkWaveformMath.power(TalkWaveformPhase.Listening(level = 2f, speechActive = true), 0.0), 1e-9) + } + + @Test + fun speakingClampsMeteredLevel() { + assertEquals(0.25, TalkWaveformMath.power(TalkWaveformPhase.Speaking(level = -1f), 0.0), 1e-9) + assertEquals(1.0, TalkWaveformMath.power(TalkWaveformPhase.Speaking(level = 2f), 0.0), 1e-9) + assertEquals(0.70, TalkWaveformMath.power(TalkWaveformPhase.Speaking(level = 0.6f), 0.0), 1e-6) + } + + @Test + fun speakingWithoutEnvelopePulsesSynthetically() { + val trough = TalkWaveformMath.power(TalkWaveformPhase.Speaking(level = null), 0.0) + val peak = TalkWaveformMath.power(TalkWaveformPhase.Speaking(level = null), PI / 10.0) + assertEquals(0.70 * 0.55, trough, 1e-9) + assertEquals(0.70, peak, 1e-9) + var time = 0.0 + while (time < 5.0) { + val power = TalkWaveformMath.power(TalkWaveformPhase.Speaking(level = null), time) + assertTrue(power in trough..peak) + time += 0.05 + } + } + + @Test + fun lobesAreDeterministicWithFixedShapeConstants() { + val lobes = TalkWaveformMath.lobes(time = 1.25, seed = 7.31) + assertEquals(lobes, TalkWaveformMath.lobes(time = 1.25, seed = 7.31)) + assertEquals(3, lobes.size) + assertEquals(0.62, lobes[0].k, 1e-9) + assertEquals(0.73, lobes[1].k, 1e-9) + assertEquals(0.84, lobes[2].k, 1e-9) + for (lobe in lobes) { + assertTrue(lobe.amplitude in 0.30..1.0) + assertTrue(lobe.t in -2.8..2.8) + } + } + + @Test + fun attenuatedSinePeaksAtFullAmplitudeAndStaysNonNegative() { + // At kx − t = −π/2 the bell envelope is exactly 1 and |sin| is 1. + assertEquals(3.0, TalkWaveformMath.attenuatedSine(x = -PI / 2, amplitude = 3.0, k = 1.0, t = 0.0), 1e-9) + var x = -9.0 + while (x <= 9.0) { + assertTrue(TalkWaveformMath.attenuatedSine(x = x, amplitude = 1.0, k = 0.73, t = 1.4) >= 0.0) + x += 0.25 + } + } +} diff --git a/apps/android/app/src/test/java/ai/openclaw/app/voice/AudioLevelsTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/voice/AudioLevelsTest.kt new file mode 100644 index 00000000000..29cda44b0c9 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/voice/AudioLevelsTest.kt @@ -0,0 +1,58 @@ +package ai.openclaw.app.voice + +import org.junit.Assert.assertEquals +import org.junit.Test + +class AudioLevelsTest { + @Test + fun silenceMetersToZero() { + val silence = ByteArray(640) + assertEquals(0f, pcm16MeanAbsLevel(silence, silence.size), 0f) + } + + @Test + fun fullScaleMetersToOne() { + val frame = pcm16Frame(samples = 160, sample = Short.MAX_VALUE) + assertEquals(1f, pcm16MeanAbsLevel(frame, frame.size), 1e-6f) + } + + @Test + fun negativeFullScaleClampsToOne() { + // abs(-32768) exceeds Short.MAX_VALUE by one; the level must stay in 0..1. + val frame = pcm16Frame(samples = 160, sample = Short.MIN_VALUE) + assertEquals(1f, pcm16MeanAbsLevel(frame, frame.size), 0f) + } + + @Test + fun midScaleMetersToHalf() { + val frame = pcm16Frame(samples = 160, sample = (Short.MAX_VALUE / 2).toShort()) + assertEquals(0.5f, pcm16MeanAbsLevel(frame, frame.size), 1e-3f) + } + + @Test + fun trailingOddByteAndEmptyLengthAreIgnored() { + val frame = pcm16Frame(samples = 2, sample = Short.MAX_VALUE) + byteArrayOf(0x7F) + assertEquals(1f, pcm16MeanAbsLevel(frame, frame.size), 1e-6f) + assertEquals(0f, pcm16MeanAbsLevel(frame, 0), 0f) + assertEquals(0f, pcm16MeanAbsLevel(frame, 1), 0f) + } + + @Test + fun smoothingMatchesIosWeighting() { + assertEquals(0.2f, smoothedAudioLevel(previous = 0f, raw = 1f), 1e-6f) + assertEquals(0.36f, smoothedAudioLevel(previous = 0.2f, raw = 1f), 1e-6f) + assertEquals(0.8f, smoothedAudioLevel(previous = 1f, raw = 0f), 1e-6f) + } + + private fun pcm16Frame( + samples: Int, + sample: Short, + ): ByteArray { + val frame = ByteArray(samples * 2) + for (index in 0 until samples) { + frame[index * 2] = (sample.toInt() and 0xff).toByte() + frame[index * 2 + 1] = ((sample.toInt() shr 8) and 0xff).toByte() + } + return frame + } +} diff --git a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeManagerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeManagerTest.kt index 7f1ac6f1231..9909572b9a6 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeManagerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeManagerTest.kt @@ -359,6 +359,19 @@ class TalkModeManagerTest { ) } + @Test + fun realtimeUserTranscriptsDriveSpeechActive() { + val manager = createManager() + + setPrivateField(manager, "realtimeSessionId", "relay-1") + + assertFalse(manager.speechActive.value) + manager.handleGatewayEvent("talk.event", realtimeTranscriptPayload(role = "user", text = "hello")) + assertTrue(manager.speechActive.value) + manager.handleGatewayEvent("talk.event", realtimeTranscriptPayload(role = "user", text = "hello world", final = true)) + assertFalse(manager.speechActive.value) + } + @Test fun realtimeTranscriptDeltasAccumulateVoiceConversation() { val manager = createManager() diff --git a/apps/ios/Sources/Design/TalkProTab.swift b/apps/ios/Sources/Design/TalkProTab.swift index 65dff0d5bbe..eada14a91e6 100644 --- a/apps/ios/Sources/Design/TalkProTab.swift +++ b/apps/ios/Sources/Design/TalkProTab.swift @@ -1,3 +1,4 @@ +import OpenClawChatUI import SwiftUI struct TalkProTab: View { @@ -116,7 +117,11 @@ struct TalkProTab: View { private var heroSection: some View { Section { VStack(spacing: 16) { - TalkSiriWaveView(mode: self.state.waveformMode(micLevel: self.appModel.talkMode.micLevel)) + TalkWaveformView( + phase: self.state.waveformPhase( + micLevel: self.appModel.talkMode.micLevel, + playbackLevel: self.appModel.talkMode.playbackLevel), + palette: .openClawBrand) .frame(height: 130) .accessibilityHidden(true) @@ -325,12 +330,20 @@ enum TalkProPrimaryAction: Equatable { case waiting } -enum TalkProWaveformMode: Equatable { - case level(Double) - case inputSpeech - case speaking - case indeterminate - case still +extension TalkWaveformPalette { + /// iOS app branding for the shared wave: adaptive accent front lobe plus + /// system grays so the idle wave tracks light/dark appearance. + static let openClawBrand = TalkWaveformPalette( + active: [ + OpenClawBrand.accent, + Color(red: 0.95, green: 0.45, blue: 0.30), + Color(red: 0.45, green: 0.08, blue: 0.12), + ], + inactive: [ + Color(uiColor: .systemGray2), + Color(uiColor: .systemGray3), + Color(uiColor: .systemGray4), + ]) } struct TalkProState: Equatable { @@ -432,24 +445,23 @@ struct TalkProState: Equatable { } } - func waveformMode(micLevel: Double) -> TalkProWaveformMode { - if self.isDemoMode { return .still } - if !self.gatewayConnected { return .still } + func waveformPhase(micLevel: Double, playbackLevel: Double?) -> TalkWaveformPhase { + if self.isDemoMode { return .idle } + if !self.gatewayConnected { return .idle } switch self.permissionState { case .requestingUpgrade, .upgradeRequested: - return .indeterminate + return .thinking case .missingScope, .requestFailed, .apiKeyMissing, .loadFailed: - return .still + return .idle default: break } - if !self.isConfigLoaded { return .still } - if self.isSpeaking { return .speaking } - if self.isListening, self.isUserSpeechDetected { return .inputSpeech } - if self.isListening { return .level(micLevel) } + if !self.isConfigLoaded { return .idle } + if self.isSpeaking { return .speaking(level: playbackLevel) } + if self.isListening { return .listening(level: micLevel, speechActive: self.isUserSpeechDetected) } if self.normalizedStatus.contains("connecting") || self.normalizedStatus.contains("thinking") { - return .indeterminate + return .thinking } - return self.isEnabled ? .indeterminate : .still + return self.isEnabled ? .thinking : .idle } } diff --git a/apps/ios/Sources/Design/TalkSiriWave.swift b/apps/ios/Sources/Design/TalkSiriWave.swift deleted file mode 100644 index aa8d45a8f04..00000000000 --- a/apps/ios/Sources/Design/TalkSiriWave.swift +++ /dev/null @@ -1,128 +0,0 @@ -import SwiftUI - -/// iOS 9-style Siri waveform for the Talk screen, driven by the runtime mode. -/// Math adapted from noahchalifour/swiftui-siri-waveform-view (MIT), as packaged -/// by alfianlosari/SiriWaveView; redrawn with Canvas + TimelineView so lobes flow -/// continuously instead of re-randomizing per power change. -/// -/// State rendering: off = flat static grey; connecting/thinking = slow low -/// breathing; listening = amplitude follows mic level; speech detected = full -/// power; agent speaking = strong TTS-style pulse. -struct TalkSiriWaveView: View { - var mode: TalkProWaveformMode - - @Environment(\.colorScheme) private var colorScheme - @Environment(\.accessibilityReduceMotion) private var reduceMotion - - private static let born = Date() - - private static let monoColors: [Color] = [ - OpenClawBrand.accent, - Color(red: 0.95, green: 0.45, blue: 0.30), - Color(red: 0.45, green: 0.08, blue: 0.12), - ] - private static let inactiveColors: [Color] = [ - Color(uiColor: .systemGray2), - Color(uiColor: .systemGray3), - Color(uiColor: .systemGray4), - ] - - var body: some View { - let frozen = self.reduceMotion || self.mode == .still - TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: frozen)) { timeline in - let time = frozen ? 0 : timeline.date.timeIntervalSince(Self.born) - let power = Self.power(for: self.mode, time: time) - Canvas { context, size in - let midY = size.height / 2 - var line = Path() - line.move(to: CGPoint(x: 0, y: midY)) - line.addLine(to: CGPoint(x: size.width, y: midY)) - context.stroke(line, with: .color(.secondary.opacity(0.30)), lineWidth: 1) - - // Screen blend pops on dark; opacity overlap reads better on light. - context.blendMode = self.colorScheme == .dark ? .screen : .normal - let opacity = self.colorScheme == .dark ? 0.9 : 0.55 - for (index, color) in self.colors.enumerated() { - let path = Self.wavePath( - in: size, - time: time, - seed: Double(index) * 7.31, - power: power) - context.fill(path, with: .color(color.opacity(opacity))) - } - } - } - .opacity(self.mode == .still ? 0.6 : 1.0) - } - - private var colors: [Color] { - self.mode == .still ? Self.inactiveColors : Self.monoColors - } - - /// Per-state drive: thinking breathes low and slow, speaking pulses like - /// TTS playback, listening follows the live mic level. - private static func power(for mode: TalkProWaveformMode, time: Double) -> Double { - switch mode { - case .still: - 0.05 - case .indeterminate: - 0.16 + 0.10 * (0.5 + 0.5 * sin(time * 1.6)) - case let .level(level): - 0.30 + 0.65 * min(max(level, 0), 1) - case .inputSpeech: - 0.95 - case .speaking: - 0.70 * (0.55 + 0.45 * abs(sin(time * 5.0))) - } - } - - /// One wave = max envelope of three drifting lobes, mirrored around the midline. - private static func wavePath(in size: CGSize, time: Double, seed: Double, power: Double) -> Path { - let midX = Double(size.width) / 2 - let midY = Double(size.height) / 2 - - // Lobe parameters oscillate smoothly so peaks sweep back and forth - // across the line instead of scrolling off-screen. - let lobes: [(A: Double, k: Double, t: Double)] = (0..<3).map { index in - let f = Double(index) - let ampFrequency = 0.9 + 0.23 * f - let ampPhase = time * ampFrequency + seed * 2.4 + f * 2.1 - let amp = 0.30 + 0.70 * (0.5 + 0.5 * sin(ampPhase)) - let k = 0.62 + 0.11 * f - let driftFrequency = 0.45 + 0.17 * f - let driftPhase = time * driftFrequency + seed + f * 1.9 - let t = 2.8 * sin(driftPhase) - return (A: amp, k: k, t: t) - } - - var upper: [CGPoint] = [] - var x = -midX - while x <= midX { - let graphX = x / (midX / 9.0) - var y: Double = 0 - for lobe in lobes { - let amplitude = lobe.A * midY * power - y = max(y, Self.attenuatedSine(x: graphX, A: amplitude, k: lobe.k, t: lobe.t)) - } - upper.append(CGPoint(x: midX + x, y: midY - y)) - x += 2 - } - - var path = Path() - path.move(to: CGPoint(x: 0, y: midY)) - path.addLines(upper) - for point in upper.reversed() { - path.addLine(to: CGPoint(x: point.x, y: 2 * midY - point.y)) - } - path.closeSubpath() - return path - } - - /// |A·sin(kx − t)| shaped by the bell envelope g = (K/(K+(kx−t′)²))^K, K = 4. - private static func attenuatedSine(x: Double, A: Double, k: Double, t: Double) -> Double { - let sine = A * sin(k * x - t) - let tPrime = t - .pi / 2 - let envelope = pow(4.0 / (4.0 + pow(k * x - tPrime, 2)), 4.0) - return abs(sine * envelope) - } -} diff --git a/apps/ios/Sources/Voice/RealtimeTalkRelaySession.swift b/apps/ios/Sources/Voice/RealtimeTalkRelaySession.swift index 21f990e13c7..ab91c87aa01 100644 --- a/apps/ios/Sources/Voice/RealtimeTalkRelaySession.swift +++ b/apps/ios/Sources/Voice/RealtimeTalkRelaySession.swift @@ -125,6 +125,11 @@ final class RealtimeTalkRelaySession { private let onStatus: (String) -> Void private let onIssue: (TalkRuntimeIssue) -> Void private let onSpeakingChanged: (Bool) -> Void + private let onInputLevel: (Double) -> Void + private let onOutputLevel: (Double?) -> Void + /// Playback-time-aligned envelope of the assistant PCM the relay schedules; + /// drives the speaking waveform with real audio instead of a synthetic pulse. + private var outputEnvelope: PCMPlaybackEnvelope? private let audioEngine = AVAudioEngine() private var relaySessionId: String? @@ -165,7 +170,9 @@ final class RealtimeTalkRelaySession { pcmPlayer: PCMStreamingAudioPlaying, onStatus: @escaping (String) -> Void, onIssue: @escaping (TalkRuntimeIssue) -> Void = { _ in }, - onSpeakingChanged: @escaping (Bool) -> Void) + onSpeakingChanged: @escaping (Bool) -> Void, + onInputLevel: @escaping (Double) -> Void = { _ in }, + onOutputLevel: @escaping (Double?) -> Void = { _ in }) { self.gateway = gateway self.options = options @@ -173,6 +180,8 @@ final class RealtimeTalkRelaySession { self.onStatus = onStatus self.onIssue = onIssue self.onSpeakingChanged = onSpeakingChanged + self.onInputLevel = onInputLevel + self.onOutputLevel = onOutputLevel } func start() async throws { @@ -360,6 +369,7 @@ final class RealtimeTalkRelaySession { return } self.ensureOutputPlaybackStarted() + self.outputEnvelope?.append(data) self.outputContinuation?.yield(data) case "audioDone": self.finishOutputPlaybackStream() @@ -729,6 +739,7 @@ final class RealtimeTalkRelaySession { private func recordMicrophoneFrame(byteCount: Int, rms: Float, timestampMs: Double) { guard !self.isClosed else { return } + self.onInputLevel(TalkAudioLevel.normalized(rms: Double(rms))) self.micLogFrameCount += 1 self.micLogByteCount += byteCount self.micLogMaxRms = max(self.micLogMaxRms, rms) @@ -767,6 +778,11 @@ final class RealtimeTalkRelaySession { guard self.outputContinuation == nil, self.outputTask == nil else { return } self.outputSessionId += 1 let sessionId = self.outputSessionId + let envelope = self.outputEnvelope ?? PCMPlaybackEnvelope { [weak self] level in + self?.onOutputLevel(level) + } + envelope.begin(sampleRate: self.outputSampleRateHz) + self.outputEnvelope = envelope let stream = AsyncThrowingStream { continuation in self.outputContinuation = continuation } @@ -810,6 +826,7 @@ final class RealtimeTalkRelaySession { for chunk in chunks { self.markOutputAudioStarted(byteCount: chunk.count, nowMs: ProcessInfo.processInfo.systemUptime * 1000) self.onSpeakingChanged(true) + self.outputEnvelope?.append(chunk) self.outputContinuation?.yield(chunk) } if shouldFinish { @@ -846,6 +863,7 @@ final class RealtimeTalkRelaySession { self.isOutputPlaying = false self.outputStartedAtMs = nil self.outputPlaybackExpectedEndMs = 0 + self.outputEnvelope?.cancel() self.onSpeakingChanged(false) } @@ -863,6 +881,7 @@ final class RealtimeTalkRelaySession { self.isOutputPlaying = false self.outputStartedAtMs = nil self.outputPlaybackExpectedEndMs = 0 + self.outputEnvelope?.cancel() self.onSpeakingChanged(false) } diff --git a/apps/ios/Sources/Voice/TalkGatewaySpeechClient.swift b/apps/ios/Sources/Voice/TalkGatewaySpeechClient.swift index bfcbf8c8c65..57dc92b4898 100644 --- a/apps/ios/Sources/Voice/TalkGatewaySpeechClient.swift +++ b/apps/ios/Sources/Voice/TalkGatewaySpeechClient.swift @@ -119,6 +119,12 @@ final class TalkGatewaySpeechClient: TalkGatewaySpeechSynthesizing { protocol TalkBufferedAudioPlaying { func play(data: Data) async -> StreamingPlaybackResult func stop() -> Double? + func setLevelHandler(_ handler: (@MainActor (Double?) -> Void)?) +} + +extension TalkBufferedAudioPlaying { + /// Level metering is a UI nicety; test doubles and future players may skip it. + func setLevelHandler(_: (@MainActor (Double?) -> Void)?) {} } @MainActor @@ -163,6 +169,12 @@ final class TalkBufferedAudioPlayer: NSObject, TalkBufferedAudioPlaying, @precon private let logger = Logger(subsystem: "ai.openclaw", category: "talk.tts") private var player: AVAudioPlayer? private var playback: Playback? + private var levelHandler: (@MainActor (Double?) -> Void)? + private var levelMeter: AudioPlayerLevelMeter? + + func setLevelHandler(_ handler: (@MainActor (Double?) -> Void)?) { + self.levelHandler = handler + } func play(data: Data) async -> StreamingPlaybackResult { self.stopInternal() @@ -176,6 +188,11 @@ final class TalkBufferedAudioPlayer: NSObject, TalkBufferedAudioPlaying, @precon self.player = player player.delegate = self player.prepareToPlay() + if let levelHandler { + let meter = AudioPlayerLevelMeter(onLevel: levelHandler) + meter.attach(player) + self.levelMeter = meter + } self.armWatchdog(playback: playback) if !player.play() { self.logger.error("talk buffered audio player refused to play") @@ -236,6 +253,8 @@ final class TalkBufferedAudioPlayer: NSObject, TalkBufferedAudioPlaying, @precon guard self.playback === playback else { return } self.playback = nil + self.levelMeter?.detach() + self.levelMeter = nil self.player?.stop() self.player = nil } diff --git a/apps/ios/Sources/Voice/TalkModeManager.swift b/apps/ios/Sources/Voice/TalkModeManager.swift index 7eb025d679a..887701febda 100644 --- a/apps/ios/Sources/Voice/TalkModeManager.swift +++ b/apps/ios/Sources/Voice/TalkModeManager.swift @@ -46,6 +46,10 @@ final class TalkModeManager: NSObject { var statusText: String = "Off" /// 0..1-ish (not calibrated). Intended for UI feedback only. var micLevel: Double = 0 + /// Live agent playback envelope in 0...1 while speaking. nil means the active + /// voice path exposes no real level (system voice, compressed streaming); the + /// waveform then falls back to a synthetic pulse. + var playbackLevel: Double? var gatewayTalkConfigLoaded: Bool = false var gatewayTalkApiKeyConfigured: Bool = false var gatewayTalkDefaultModelId: String? @@ -161,6 +165,12 @@ final class TalkModeManager: NSObject { var mp3Player: StreamingAudioPlaying = StreamingAudioPlayer.shared var bufferedPlayer: TalkBufferedAudioPlaying = TalkBufferedAudioPlayer.shared + /// Meters PCM speech bytes on their way into the streaming player so the + /// speaking waveform tracks the audible envelope, not network arrival. + @ObservationIgnored private lazy var pcmPlaybackEnvelope = PCMPlaybackEnvelope { [weak self] level in + self?.playbackLevel = level + } + private var gateway: GatewayNodeSession? private var gatewayConnected = false private var talkConfigLoadedAt: Date? @@ -1347,6 +1357,14 @@ final class TalkModeManager: NSObject { if speaking { self.isListening = false } + }, + onInputLevel: { [weak self] level in + guard let self, self.isListening else { return } + // Same smoothing as the SFSpeech tap so route switches keep the wave feel. + self.micLevel = (self.micLevel * 0.80) + (level * 0.20) + }, + onOutputLevel: { [weak self] level in + self?.playbackLevel = level }) self.realtimeRelaySession = relaySession do { @@ -1687,6 +1705,7 @@ final class TalkModeManager: NSObject { if self.speechGeneration == speechGeneration { self.stopRecognition() self.isSpeaking = false + self.playbackLevel = nil self.restoreConfiguredVoiceModeDescriptor() } } @@ -1773,7 +1792,10 @@ final class TalkModeManager: NSObject { let streamFailure = StreamFailureBox() let stream = Self.monitorStreamFailures(rawStream, failureBox: streamFailure) self.lastPlaybackWasPCM = true - var playback = await pcmPlayer.play(stream: stream, sampleRate: sampleRate) + var playback = await pcmPlayer.play( + stream: self.meteredPCMStream(stream, sampleRate: sampleRate), + sampleRate: sampleRate) + self.pcmPlaybackEnvelope.cancel() if !playback.finished, playback.interruptedAt == nil { let mp3Format = ElevenLabsTTSClient.validatedOutputFormat("mp3_44100_128") self.logger.warning("pcm playback failed; retrying mp3") @@ -1869,9 +1891,15 @@ final class TalkModeManager: NSObject { case let .pcm(sampleRate): self.lastPlaybackWasPCM = true let stream = Self.makeBufferedAudioStream(chunks: [audio.data]) - result = await self.pcmPlayer.play(stream: stream, sampleRate: sampleRate) + result = await self.pcmPlayer.play( + stream: self.meteredPCMStream(stream, sampleRate: sampleRate), + sampleRate: sampleRate) + self.pcmPlaybackEnvelope.cancel() case .buffered: self.lastPlaybackWasPCM = false + self.bufferedPlayer.setLevelHandler { [weak self] level in + self?.playbackLevel = level + } result = await self.bufferedPlayer.play(data: audio.data) case let .unsupportedRaw(codec): throw NSError(domain: "TalkGatewaySpeech", code: 2, userInfo: [ @@ -1970,7 +1998,9 @@ final class TalkModeManager: NSObject { self.stopRecognition() TalkSystemSpeechSynthesizer.shared.stop() self.cancelIncrementalSpeech() + self.pcmPlaybackEnvelope.cancel() self.isSpeaking = false + self.playbackLevel = nil restoreConfiguredVoiceModeDescriptor() } @@ -2422,6 +2452,32 @@ final class TalkModeManager: NSObject { } } + /// Passes PCM chunks through to the player while feeding the playback + /// envelope so `playbackLevel` follows the audible speech. + private func meteredPCMStream( + _ stream: AsyncThrowingStream, + sampleRate: Double) -> AsyncThrowingStream + { + let envelope = self.pcmPlaybackEnvelope + envelope.begin(sampleRate: sampleRate) + return AsyncThrowingStream { continuation in + let task = Task { @MainActor in + do { + for try await chunk in stream { + envelope.append(chunk) + continuation.yield(chunk) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in + task.cancel() + } + } + } + private func speakIncrementalSegment( _ text: String, context preferredContext: IncrementalSpeechContext? = nil, @@ -2465,7 +2521,10 @@ final class TalkModeManager: NSObject { let streamFailure = StreamFailureBox() let stream = Self.monitorStreamFailures(rawStream, failureBox: streamFailure) self.lastPlaybackWasPCM = true - var playback = await pcmPlayer.play(stream: stream, sampleRate: sampleRate) + var playback = await pcmPlayer.play( + stream: self.meteredPCMStream(stream, sampleRate: sampleRate), + sampleRate: sampleRate) + self.pcmPlaybackEnvelope.cancel() if !playback.finished, playback.interruptedAt == nil { self.logger.warning("pcm playback failed; retrying mp3") if Self.isPCMFormatRejectedByAPI(streamFailure.value) { @@ -3275,6 +3334,9 @@ extension TalkModeManager: TalkRealtimeWebRTCSessionDelegate { self.isSpeaking = false self.isUserSpeechDetected = false } + if !self.isSpeaking { + self.playbackLevel = nil + } } func realtimeSession(_ session: TalkRealtimeWebRTCSession, didDetectInputSpeech active: Bool) { @@ -3285,6 +3347,17 @@ extension TalkModeManager: TalkRealtimeWebRTCSessionDelegate { } } + func realtimeSession(_ session: TalkRealtimeWebRTCSession, didUpdateAudioLevels input: Double?, output: Double?) { + guard session === self.realtimeSession else { return } + if self.isListening, let input { + // Same smoothing as the SFSpeech tap so route switches keep the wave feel. + self.micLevel = (self.micLevel * 0.80) + (input * 0.20) + } + if self.isSpeaking, let output { + self.playbackLevel = output + } + } + func realtimeSession(_ session: TalkRealtimeWebRTCSession, didReceiveUserTranscript text: String) { guard session === self.realtimeSession else { return } let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/apps/ios/Sources/Voice/TalkRealtimeWebRTCSession.swift b/apps/ios/Sources/Voice/TalkRealtimeWebRTCSession.swift index 5964c8dca9f..9bc5df4e22a 100644 --- a/apps/ios/Sources/Voice/TalkRealtimeWebRTCSession.swift +++ b/apps/ios/Sources/Voice/TalkRealtimeWebRTCSession.swift @@ -10,6 +10,9 @@ import OSLog protocol TalkRealtimeWebRTCSessionDelegate: AnyObject { func realtimeSession(_ session: TalkRealtimeWebRTCSession, didChangeStatus status: String) func realtimeSession(_ session: TalkRealtimeWebRTCSession, didDetectInputSpeech active: Bool) + /// Live mic/playback levels (normalized 0...1) polled from WebRTC stats; nil + /// when the report carries no audio level for that direction. + func realtimeSession(_ session: TalkRealtimeWebRTCSession, didUpdateAudioLevels input: Double?, output: Double?) func realtimeSession(_ session: TalkRealtimeWebRTCSession, didReceiveUserTranscript text: String) func realtimeSession(_ session: TalkRealtimeWebRTCSession, didReceiveAssistantTranscript text: String) func realtimeSessionDidFinish(_ session: TalkRealtimeWebRTCSession) @@ -51,6 +54,7 @@ final class TalkRealtimeWebRTCSession: NSObject { private var assistantAudioActive = false private var assistantAudioFinishTask: Task? private var ownsAudioSessionActivation = false + private var audioLevelPollTask: Task? private struct ToolBuffer { var name: String @@ -161,11 +165,53 @@ final class TalkRealtimeWebRTCSession: NSObject { self.trace("remote description set") try self.checkNotStopped() self.delegate?.realtimeSession(self, didChangeStatus: "Listening") + self.startAudioLevelPolling() + } + + /// WebRTC owns capture and playback in this transport, so the app never sees + /// PCM; peer-connection stats are the only real level source for the waveform. + private func startAudioLevelPolling() { + self.audioLevelPollTask?.cancel() + self.audioLevelPollTask = Task { [weak self] in + while !Task.isCancelled { + guard let self, !self.stopped, let peer = self.peerConnection else { return } + let levels = await Self.audioLevels(peer: peer) + guard !Task.isCancelled, !self.stopped else { return } + self.delegate?.realtimeSession( + self, + didUpdateAudioLevels: levels.input.map { TalkAudioLevel.normalized(rms: $0) }, + output: levels.output.map { TalkAudioLevel.normalized(rms: $0) }) + try? await Task.sleep(nanoseconds: 100_000_000) + } + } + } + + private nonisolated static func audioLevels( + peer: RTCPeerConnection) async -> (input: Double?, output: Double?) + { + await withCheckedContinuation { continuation in + peer.statistics { report in + var input: Double? + var output: Double? + for stat in report.statistics.values { + guard (stat.values["kind"] as? String) == "audio", + let level = stat.values["audioLevel"] as? NSNumber + else { continue } + // Per the WebRTC stats spec audioLevel is linear 0...1: + // media-source is the local mic, inbound-rtp the remote voice. + if stat.type == "media-source" { input = level.doubleValue } + if stat.type == "inbound-rtp" { output = level.doubleValue } + } + continuation.resume(returning: (input, output)) + } + } } func stop() { let shouldNotify = !self.stopped self.stopped = true + self.audioLevelPollTask?.cancel() + self.audioLevelPollTask = nil self.cancelActiveToolCalls() self.toolBuffers.removeAll() self.dataChannel?.close() diff --git a/apps/ios/Tests/TalkProStateTests.swift b/apps/ios/Tests/TalkProStateTests.swift index 6391a81b805..50e8783a903 100644 --- a/apps/ios/Tests/TalkProStateTests.swift +++ b/apps/ios/Tests/TalkProStateTests.swift @@ -1,3 +1,4 @@ +import OpenClawChatUI import Testing @testable import OpenClaw @@ -17,7 +18,7 @@ struct TalkProStateTests { #expect(state.title == "Voice config unavailable") #expect(state.primaryAction == .start) #expect(state.primaryButtonTitle == "Start Talk") - #expect(state.waveformMode(micLevel: 0.8) == .still) + #expect(state.waveformPhase(micLevel: 0.8, playbackLevel: nil) == .idle) } @Test func `enabled talk without loaded config can be stopped`() { @@ -35,7 +36,7 @@ struct TalkProStateTests { #expect(state.title == "Voice config unavailable") #expect(state.primaryAction == .stop) #expect(state.primaryButtonTitle == "Stop Talk") - #expect(state.waveformMode(micLevel: 0.8) == .still) + #expect(state.waveformPhase(micLevel: 0.8, playbackLevel: nil) == .idle) } @Test func `enabled talk with loaded config can be stopped`() { @@ -87,6 +88,41 @@ struct TalkProStateTests { #expect(state.primaryAction == .waiting) #expect(state.primaryButtonTitle == "Demo Mode Only") #expect(state.primaryButtonIcon == "lock.fill") - #expect(state.waveformMode(micLevel: 0.8) == .still) + #expect(state.waveformPhase(micLevel: 0.8, playbackLevel: nil) == .idle) + } + + @Test func `listening drives the wave with the real mic level`() { + let state = Self.readyState(isListening: true) + #expect(state.waveformPhase(micLevel: 0.4, playbackLevel: nil) + == .listening(level: 0.4, speechActive: false)) + } + + @Test func `detected speech keeps the real mic level and marks speech active`() { + let state = Self.readyState(isListening: true, isUserSpeechDetected: true) + #expect(state.waveformPhase(micLevel: 0.7, playbackLevel: nil) + == .listening(level: 0.7, speechActive: true)) + } + + @Test func `speaking forwards the playback envelope when available`() { + let state = Self.readyState(isSpeaking: true) + #expect(state.waveformPhase(micLevel: 0, playbackLevel: 0.55) == .speaking(level: 0.55)) + #expect(state.waveformPhase(micLevel: 0, playbackLevel: nil) == .speaking(level: nil)) + } + + private static func readyState( + isListening: Bool = false, + isSpeaking: Bool = false, + isUserSpeechDetected: Bool = false) -> TalkProState + { + TalkProState( + gatewayConnected: true, + isDemoMode: false, + isEnabled: true, + statusText: "Ready", + isConfigLoaded: true, + isListening: isListening, + isSpeaking: isSpeaking, + isUserSpeechDetected: isUserSpeechDetected, + permissionState: .ready) } } diff --git a/apps/ios/WatchApp/Sources/WatchInboxView.swift b/apps/ios/WatchApp/Sources/WatchInboxView.swift index fe0a5e95b41..ab761a115cb 100644 --- a/apps/ios/WatchApp/Sources/WatchInboxView.swift +++ b/apps/ios/WatchApp/Sources/WatchInboxView.swift @@ -1090,7 +1090,15 @@ private struct WatchChatTimelineView: View { } if let voiceStatusText = self.voiceStatusText { - WatchTinyStatus(text: voiceStatusText) + VStack(alignment: .leading, spacing: 3) { + // Watch TTS runs through AVSpeechSynthesizer, which has no + // metering API, so speaking uses the wave's synthetic pulse. + TalkWaveformView( + phase: self.speechPlayback.isSpeaking ? .speaking(level: nil) : .thinking) + .frame(height: 24) + .accessibilityHidden(true) + WatchTinyStatus(text: voiceStatusText) + } } WatchSecondaryButton(title: "Refresh") { diff --git a/apps/ios/project.yml b/apps/ios/project.yml index ee4870417ea..6fbdd03be2b 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -313,6 +313,9 @@ targets: - Info.plist - path: Sources/Services/WatchSessionActivationGate.swift group: Sources/Services + # Universal talk waveform, compiled directly since the watch links no packages. + - path: ../shared/OpenClawKit/Sources/OpenClawChatUI/TalkWaveformView.swift + group: Shared - path: Sources/Fonts buildPhase: resources - path: Resources/Localizable.xcstrings diff --git a/apps/macos/Sources/OpenClaw/TalkAudioPlayer.swift b/apps/macos/Sources/OpenClaw/TalkAudioPlayer.swift index 76795908814..e04e048cda9 100644 --- a/apps/macos/Sources/OpenClaw/TalkAudioPlayer.swift +++ b/apps/macos/Sources/OpenClaw/TalkAudioPlayer.swift @@ -1,5 +1,6 @@ import AVFoundation import Foundation +import OpenClawKit import OSLog @MainActor @@ -9,6 +10,12 @@ final class TalkAudioPlayer: NSObject, @preconcurrency AVAudioPlayerDelegate { private let logger = Logger(subsystem: "ai.openclaw", category: "talk.tts") private var player: AVAudioPlayer? private var playback: Playback? + private var levelHandler: (@MainActor (Double?) -> Void)? + private var levelMeter: AudioPlayerLevelMeter? + + func setLevelHandler(_ handler: (@MainActor (Double?) -> Void)?) { + self.levelHandler = handler + } private final class Playback: @unchecked Sendable { private let lock = NSLock() @@ -63,6 +70,11 @@ final class TalkAudioPlayer: NSObject, @preconcurrency AVAudioPlayerDelegate { player.delegate = self player.prepareToPlay() + if let levelHandler { + let meter = AudioPlayerLevelMeter(onLevel: levelHandler) + meter.attach(player) + self.levelMeter = meter + } self.armWatchdog(playback: playback) @@ -101,6 +113,8 @@ final class TalkAudioPlayer: NSObject, @preconcurrency AVAudioPlayerDelegate { guard self.playback === playback else { return } self.playback = nil + self.levelMeter?.detach() + self.levelMeter = nil self.player?.stop() self.player = nil } diff --git a/apps/macos/Sources/OpenClaw/TalkModeController.swift b/apps/macos/Sources/OpenClaw/TalkModeController.swift index 0b53e4015bb..24bfc467e07 100644 --- a/apps/macos/Sources/OpenClaw/TalkModeController.swift +++ b/apps/macos/Sources/OpenClaw/TalkModeController.swift @@ -1,5 +1,6 @@ import AppKit import Observation +import OpenClawKit @MainActor @Observable @@ -11,6 +12,12 @@ final class TalkModeController { private(set) var phase: TalkModePhase = .idle private(set) var isPaused: Bool = false + /// Meters streamed PCM speech so the orb waveform follows the audible + /// envelope instead of a synthetic pulse. + @ObservationIgnored private lazy var playbackEnvelope = PCMPlaybackEnvelope { [weak self] level in + self?.updateSpeakingLevel(level) + } + func setEnabled(_ enabled: Bool) async { self.logger.info("talk enabled=\(enabled)") if enabled { @@ -72,6 +79,43 @@ final class TalkModeController { TalkOverlayController.shared.updateLevel(level) } + /// Playback level published while agent speech plays; nil (path without + /// metering, or playback ended) settles the wave back to its floor. + func updateSpeakingLevel(_ level: Double?) { + guard self.phase == .speaking else { return } + TalkOverlayController.shared.updateLevel(level ?? 0) + } + + /// Passes streamed PCM speech through to the player while feeding the + /// playback envelope; call `endSpeechMetering` once playback returns. + func meteredSpeechStream( + _ stream: AsyncThrowingStream, + sampleRate: Double) -> AsyncThrowingStream + { + let envelope = self.playbackEnvelope + envelope.begin(sampleRate: sampleRate) + return AsyncThrowingStream { continuation in + let task = Task { @MainActor in + do { + for try await chunk in stream { + envelope.append(chunk) + continuation.yield(chunk) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in + task.cancel() + } + } + } + + func endSpeechMetering() { + self.playbackEnvelope.cancel() + } + func setPaused(_ paused: Bool) { guard self.isPaused != paused else { return } self.logger.info("talk paused=\(paused)") diff --git a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift index fc7e552fc11..d841433625c 100644 --- a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift +++ b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift @@ -1079,9 +1079,13 @@ extension TalkModeRuntime { stream: AsyncThrowingStream, sampleRate: Double) async -> StreamingPlaybackResult { - await PCMStreamingAudioPlayer.shared.play(stream: stream, sampleRate: sampleRate) + let metered = TalkModeController.shared.meteredSpeechStream(stream, sampleRate: sampleRate) + let result = await PCMStreamingAudioPlayer.shared.play(stream: metered, sampleRate: sampleRate) + TalkModeController.shared.endSpeechMetering() + return result } + /// MP3 streaming has no metering hook; the wave falls back to its floor. @MainActor private func playMP3(stream: AsyncThrowingStream) async -> StreamingPlaybackResult { await StreamingAudioPlayer.shared.play(stream: stream) @@ -1099,7 +1103,10 @@ extension TalkModeRuntime { @MainActor private func playTalkAudio(data: Data) async -> TalkPlaybackResult { - await TalkAudioPlayer.shared.play(data: data) + TalkAudioPlayer.shared.setLevelHandler { level in + TalkModeController.shared.updateSpeakingLevel(level) + } + return await TalkAudioPlayer.shared.play(data: data) } @MainActor diff --git a/apps/macos/Sources/OpenClaw/TalkOverlayView.swift b/apps/macos/Sources/OpenClaw/TalkOverlayView.swift index 25d3b78b75d..66a5d622d2d 100644 --- a/apps/macos/Sources/OpenClaw/TalkOverlayView.swift +++ b/apps/macos/Sources/OpenClaw/TalkOverlayView.swift @@ -1,4 +1,5 @@ import AppKit +import OpenClawChatUI import SwiftUI struct TalkOverlayView: View { @@ -134,32 +135,39 @@ private struct TalkOrbView: View { let isPaused: Bool var body: some View { - if self.isPaused { + ZStack { Circle() .fill(self.orbGradient) - .overlay(Circle().stroke(Color.white.opacity(0.35), lineWidth: 1)) - .shadow(color: Color.black.opacity(0.18), radius: 10, x: 0, y: 5) - } else { - TimelineView(.animation) { context in - let t = context.date.timeIntervalSinceReferenceDate - let listenScale = self.phase == .listening ? (1 + CGFloat(self.level) * 0.12) : 1 - let pulse = self.phase == .speaking ? (1 + 0.06 * sin(t * 6)) : 1 + .overlay(Circle().stroke(Color.white.opacity(self.isPaused ? 0.35 : 0.45), lineWidth: 1)) + .shadow(color: Color.black.opacity(self.isPaused ? 0.18 : 0.22), radius: 10, x: 0, y: 5) + .scaleEffect(self.orbScale) - ZStack { - Circle() - .fill(self.orbGradient) - .overlay(Circle().stroke(Color.white.opacity(0.45), lineWidth: 1)) - .shadow(color: Color.black.opacity(0.22), radius: 10, x: 0, y: 5) - .scaleEffect(pulse * listenScale) - - TalkWaveRings(phase: self.phase, level: self.level, time: t, accent: self.accent) - - if self.phase == .thinking { - TalkOrbitArcs(time: t) - } - } + if !self.isPaused { + // The universal talk waveform (shared with iOS/watchOS/Android) + // rendered inside the orb; the level is real mic/playback audio. + // 0.82 x 0.56 keeps the wave rect's corners inside the orb circle. + TalkWaveformView(phase: self.wavePhase, palette: .talkOrb) + .frame( + width: TalkOverlayController.orbSize * 0.82, + height: TalkOverlayController.orbSize * 0.56) + .accessibilityHidden(true) } } + .animation(.easeOut(duration: 0.12), value: self.orbScale) + } + + private var wavePhase: TalkWaveformPhase { + switch self.phase { + case .idle: .idle + case .thinking: .thinking + case .listening: .listening(level: self.level, speechActive: false) + case .speaking: .speaking(level: self.level) + } + } + + private var orbScale: CGFloat { + guard !self.isPaused, self.phase == .listening || self.phase == .speaking else { return 1 } + return 1 + CGFloat(self.level) * 0.08 } private var orbGradient: RadialGradient { @@ -171,44 +179,17 @@ private struct TalkOrbView: View { } } -private struct TalkWaveRings: View { - let phase: TalkModePhase - let level: Double - let time: TimeInterval - let accent: Color - - var body: some View { - ZStack { - ForEach(0..<3, id: \.self) { idx in - let speed = self.phase == .speaking ? 1.4 : self.phase == .listening ? 0.9 : 0.6 - let progress = (time * speed + Double(idx) * 0.28).truncatingRemainder(dividingBy: 1) - let amplitude = self.phase == .speaking ? 0.95 : self.phase == .listening ? 0.5 + self - .level * 0.7 : 0.35 - let scale = 0.75 + progress * amplitude + (self.phase == .listening ? self.level * 0.15 : 0) - let alpha = self.phase == .speaking ? 0.72 : self.phase == .listening ? 0.58 + self.level * 0.28 : 0.4 - Circle() - .stroke(self.accent.opacity(alpha - progress * 0.3), lineWidth: 1.6) - .scaleEffect(scale) - .opacity(alpha - progress * 0.6) - } - } - } -} - -private struct TalkOrbitArcs: View { - let time: TimeInterval - - var body: some View { - ZStack { - Circle() - .trim(from: 0.08, to: 0.26) - .stroke(Color.white.opacity(0.88), style: StrokeStyle(lineWidth: 1.6, lineCap: .round)) - .rotationEffect(.degrees(self.time * 42)) - Circle() - .trim(from: 0.62, to: 0.86) - .stroke(Color.white.opacity(0.7), style: StrokeStyle(lineWidth: 1.4, lineCap: .round)) - .rotationEffect(.degrees(-self.time * 35)) - } - .scaleEffect(1.08) - } +extension TalkWaveformPalette { + /// High-contrast wave tones for the tinted orb background. + fileprivate static let talkOrb = TalkWaveformPalette( + active: [ + Color.white, + Color(white: 0.9), + Color(white: 0.72), + ], + inactive: [ + Color.white.opacity(0.7), + Color.white.opacity(0.55), + Color.white.opacity(0.4), + ]) } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/TalkWaveformView.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/TalkWaveformView.swift new file mode 100644 index 00000000000..225767066ca --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/TalkWaveformView.swift @@ -0,0 +1,172 @@ +import SwiftUI + +/// Universal OpenClaw talk animation: an iOS 9-style Siri waveform shared by the +/// iOS, watchOS, and macOS apps; the Android app ports the same math in Compose +/// (`TalkWaveform.kt`). Math adapted from noahchalifour/swiftui-siri-waveform-view +/// (MIT), as packaged by alfianlosari/SiriWaveView; redrawn with Canvas + +/// TimelineView so lobes flow continuously instead of re-randomizing per power +/// change. +/// +/// This file is also compiled directly into the watch target, which links no +/// packages (see `apps/ios/project.yml`). Keep it dependency-free SwiftUI. +public enum TalkWaveformPhase: Equatable, Sendable { + /// Voice surface is off or unavailable: flat, static, dimmed. + case idle + /// Connecting or waiting on the agent. No audio exists in this state, so the + /// wave breathes on a slow synthetic swell by design. + case thinking + /// Capturing the user's voice. `level` is the live microphone level in 0...1; + /// `speechActive` raises the floor once endpointing detects actual speech. + case listening(level: Double, speechActive: Bool) + /// Agent speech playback. `level` is the live playback envelope in 0...1. + /// `nil` means the active voice path exposes no envelope (AVSpeechSynthesizer + /// and compressed streaming playback have no metering API); the wave then + /// falls back to a synthetic pulse rather than freezing. + case speaking(level: Double?) +} + +/// Wave colors, front to back. Surfaces embedding the wave on tinted backgrounds +/// (for example the macOS orb) pass their own colors. +public struct TalkWaveformPalette: Equatable, Sendable { + public var active: [Color] + public var inactive: [Color] + + public init(active: [Color], inactive: [Color]) { + self.active = active + self.inactive = inactive + } + + public static let standard = TalkWaveformPalette( + active: [ + Color(red: 198 / 255.0, green: 62 / 255.0, blue: 56 / 255.0), + Color(red: 0.95, green: 0.45, blue: 0.30), + Color(red: 0.45, green: 0.08, blue: 0.12), + ], + inactive: [ + Color(white: 0.62), + Color(white: 0.72), + Color(white: 0.82), + ]) +} + +public struct TalkWaveformView: View { + public var phase: TalkWaveformPhase + public var palette: TalkWaveformPalette + + @Environment(\.colorScheme) private var colorScheme + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private static let born = Date() + + public init(phase: TalkWaveformPhase, palette: TalkWaveformPalette = .standard) { + self.phase = phase + self.palette = palette + } + + public var body: some View { + let frozen = self.reduceMotion || self.phase == .idle + TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: frozen)) { timeline in + let time = frozen ? 0 : timeline.date.timeIntervalSince(Self.born) + let power = TalkWaveformMath.power(for: self.phase, time: time) + Canvas { context, size in + let midY = size.height / 2 + var line = Path() + line.move(to: CGPoint(x: 0, y: midY)) + line.addLine(to: CGPoint(x: size.width, y: midY)) + context.stroke(line, with: .color(.secondary.opacity(0.30)), lineWidth: 1) + + // Screen blend pops on dark; opacity overlap reads better on light. + context.blendMode = self.colorScheme == .dark ? .screen : .normal + let opacity = self.colorScheme == .dark ? 0.9 : 0.55 + for (index, color) in self.colors.enumerated() { + let path = TalkWaveformMath.wavePath( + in: size, + time: time, + seed: Double(index) * 7.31, + power: power) + context.fill(path, with: .color(color.opacity(opacity))) + } + } + } + .opacity(self.phase == .idle ? 0.6 : 1.0) + } + + private var colors: [Color] { + self.phase == .idle ? self.palette.inactive : self.palette.active + } +} + +/// Pure waveform math, split from the view for unit testing and so the Android +/// port has one canonical reference for every constant. +public enum TalkWaveformMath { + /// Per-phase drive for the wave amplitude in 0...1. + public static func power(for phase: TalkWaveformPhase, time: Double) -> Double { + switch phase { + case .idle: + return 0.05 + case .thinking: + return 0.16 + 0.10 * (0.5 + 0.5 * sin(time * 1.6)) + case let .listening(level, speechActive): + let clamped = min(max(level, 0), 1) + // Detected speech lifts the floor so the wave visibly commits to the + // user even when the mic level dips between words. + return speechActive ? 0.55 + 0.45 * clamped : 0.30 + 0.65 * clamped + case let .speaking(level): + guard let level else { + // Synthetic pulse for voice paths with no playback metering. + return 0.70 * (0.55 + 0.45 * abs(sin(time * 5.0))) + } + return 0.25 + 0.75 * min(max(level, 0), 1) + } + } + + /// One wave = max envelope of three drifting lobes, mirrored around the midline. + public static func wavePath(in size: CGSize, time: Double, seed: Double, power: Double) -> Path { + let midX = Double(size.width) / 2 + let midY = Double(size.height) / 2 + + // Lobe parameters oscillate smoothly so peaks sweep back and forth + // across the line instead of scrolling off-screen. + let lobes: [(A: Double, k: Double, t: Double)] = (0..<3).map { index in + let f = Double(index) + let ampFrequency = 0.9 + 0.23 * f + let ampPhase = time * ampFrequency + seed * 2.4 + f * 2.1 + let amp = 0.30 + 0.70 * (0.5 + 0.5 * sin(ampPhase)) + let k = 0.62 + 0.11 * f + let driftFrequency = 0.45 + 0.17 * f + let driftPhase = time * driftFrequency + seed + f * 1.9 + let t = 2.8 * sin(driftPhase) + return (A: amp, k: k, t: t) + } + + var upper: [CGPoint] = [] + var x = -midX + while x <= midX { + let graphX = x / (midX / 9.0) + var y: Double = 0 + for lobe in lobes { + let amplitude = lobe.A * midY * power + y = max(y, Self.attenuatedSine(x: graphX, A: amplitude, k: lobe.k, t: lobe.t)) + } + upper.append(CGPoint(x: midX + x, y: midY - y)) + x += 2 + } + + var path = Path() + path.move(to: CGPoint(x: 0, y: midY)) + path.addLines(upper) + for point in upper.reversed() { + path.addLine(to: CGPoint(x: point.x, y: 2 * midY - point.y)) + } + path.closeSubpath() + return path + } + + /// |A·sin(kx − t)| shaped by the bell envelope g = (K/(K+(kx−t′)²))^K, K = 4. + private static func attenuatedSine(x: Double, A: Double, k: Double, t: Double) -> Double { + let sine = A * sin(k * x - t) + let tPrime = t - .pi / 2 + let envelope = pow(4.0 / (4.0 + pow(k * x - tPrime, 2)), 4.0) + return abs(sine * envelope) + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/VoiceNoteComposerViews.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/VoiceNoteComposerViews.swift index b4a55b579aa..4297cdafda2 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/VoiceNoteComposerViews.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/VoiceNoteComposerViews.swift @@ -56,8 +56,11 @@ struct OpenClawVoiceNoteRecordingRow: View { .fill(OpenClawChatTheme.danger) .frame(width: 9, height: 9) - Text("Recording") - .font(OpenClawChatTypography.footnoteSemiBold) + // Live capture wave replaces a static "Recording" label; the level is + // real recorder metering, so silence reads flat and speech moves. + TalkWaveformView(phase: .listening(level: self.recorder.level, speechActive: false)) + .frame(maxWidth: .infinity, minHeight: 26, maxHeight: 26) + .accessibilityLabel("Recording") Text(openClawVoiceNoteDurationLabel(self.recorder.elapsedSeconds)) .font(OpenClawChatTypography.mono(size: 13, relativeTo: .footnote)) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/VoiceNoteRecorder.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/VoiceNoteRecorder.swift index 30d8833c40f..42fea9feea0 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/VoiceNoteRecorder.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/VoiceNoteRecorder.swift @@ -1,6 +1,7 @@ import AVFAudio import Foundation import Observation +import OpenClawKit private let voiceNoteMaximumDurationSeconds: TimeInterval = 180 @@ -21,6 +22,14 @@ public protocol VoiceNoteAudioCapture: AnyObject { /// Reports capture loss after a recording has started. func setFailureHandler(_ handler: @escaping @MainActor () -> Void) + + /// Latest capture level in 0...1 for the live waveform, when supported. + func currentLevel() -> Double? +} + +extension VoiceNoteAudioCapture { + /// Metering is a UI nicety; test captures may skip it. + public func currentLevel() -> Double? { nil } } /// A completed voice-note recording ready to stage as a chat attachment. @@ -51,6 +60,8 @@ public final class OpenClawVoiceNoteRecorder { public private(set) var state: State = .idle public private(set) var elapsedSeconds: TimeInterval = 0 + /// Live capture level in 0...1 while recording; drives the recording waveform. + public private(set) var level: Double = 0 @ObservationIgnored public var onRecordingActiveChanged: (@MainActor (Bool) -> Void)? @@ -179,6 +190,7 @@ public final class OpenClawVoiceNoteRecorder { let duration = max(0, self.capture.stop()) let recording = OpenClawVoiceNoteRecording(fileURL: fileURL, durationSeconds: duration) self.elapsedSeconds = duration + self.level = 0 self.state = .finished(recording: recording) self.onRecordingActiveChanged?(false) return recording @@ -205,6 +217,7 @@ public final class OpenClawVoiceNoteRecorder { } let wasRecording = self.isRecording self.elapsedSeconds = 0 + self.level = 0 self.state = .idle if wasRecording { self.onRecordingActiveChanged?(false) @@ -220,6 +233,10 @@ public final class OpenClawVoiceNoteRecorder { guard !Task.isCancelled else { return } guard case let .recording(startedAt, _) = self.state else { return } self.elapsedSeconds = max(0, self.now().timeIntervalSince(startedAt)) + if let captureLevel = self.capture.currentLevel() { + // Light smoothing keeps the 10 Hz meter poll from stepping visibly. + self.level = (self.level * 0.55) + (captureLevel * 0.45) + } if self.elapsedSeconds >= self.durationLimit { self.finish() return @@ -233,6 +250,7 @@ public final class OpenClawVoiceNoteRecorder { self.timerTask = nil let wasRecording = self.isRecording self.elapsedSeconds = 0 + self.level = 0 self.state = .failed(message: message) if wasRecording { self.onRecordingActiveChanged?(false) @@ -317,6 +335,7 @@ public final class OpenClawVoiceNoteAudioCapture: NSObject, VoiceNoteAudioCaptur ] let recorder = try AVAudioRecorder(url: url, settings: settings) recorder.delegate = self + recorder.isMeteringEnabled = true guard recorder.record() else { throw NSError( domain: "OpenClawVoiceNoteAudioCapture", @@ -345,6 +364,12 @@ public final class OpenClawVoiceNoteAudioCapture: NSObject, VoiceNoteAudioCaptur self.deactivateAudioSession() } + public func currentLevel() -> Double? { + guard let recorder, recorder.isRecording else { return nil } + recorder.updateMeters() + return TalkAudioLevel.normalized(decibels: Double(recorder.averagePower(forChannel: 0))) + } + public nonisolated func audioRecorderEncodeErrorDidOccur(_ recorder: AVAudioRecorder, error: (any Error)?) { Task { @MainActor [weak self] in self?.captureDidFail() diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkPlaybackLevelMeters.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkPlaybackLevelMeters.swift new file mode 100644 index 00000000000..aba4b0cf450 --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkPlaybackLevelMeters.swift @@ -0,0 +1,169 @@ +import AVFoundation +import Foundation + +/// Normalizes an RMS value to the shared 0...1 UI level scale used by every +/// talk animation (mic, playback, recording) so the wave reads identically +/// across surfaces: dB full scale mapped over a 50 dB window. +public enum TalkAudioLevel { + public static func normalized(rms: Double) -> Double { + self.normalized(decibels: 20 * log10(max(rms, 1e-7))) + } + + public static func normalized(decibels: Double) -> Double { + max(0, min(1, (decibels + 50) / 50)) + } + + /// RMS of little-endian PCM16 mono bytes; 0 for empty or odd-length data. + public static func pcm16RMS(_ data: Data) -> Double { + let sampleCount = data.count / 2 + guard sampleCount > 0 else { return 0 } + var sum: Double = 0 + data.withUnsafeBytes { raw in + let samples = raw.bindMemory(to: Int16.self) + for index in 0.. Void + private var segments: [Segment] = [] + private var bytesPerSecond: Double = 0 + private var startedAt: ContinuousClock.Instant? + private var scheduleEnd: TimeInterval = 0 + private var publishTask: Task? + + public init(onLevel: @escaping @MainActor (Double?) -> Void) { + self.onLevel = onLevel + } + + /// Starts a new envelope; the playback clock is anchored to the first chunk. + public func begin(sampleRate: Double) { + self.cancel() + self.bytesPerSecond = max(1, sampleRate * Double(MemoryLayout.size)) + } + + public func append(_ chunk: Data) { + guard self.bytesPerSecond > 1, !chunk.isEmpty else { return } + let now = ContinuousClock.now + if self.startedAt == nil { + self.startedAt = now + self.startPublishing() + } + guard let startedAt = self.startedAt else { return } + let elapsed = Self.seconds(startedAt.duration(to: now)) + // Chunks queue behind whatever is already scheduled; a stalled stream + // resumes at "now" instead of leaving a phantom backlog gap. + var start = max(elapsed, self.scheduleEnd) + // Meter in ~50 ms windows: a whole clip can arrive as one chunk, and a + // single RMS for it would render a flat line instead of an envelope. + let windowBytes = max(2, Int(self.bytesPerSecond * 0.05) & ~1) + var offset = chunk.startIndex + while offset < chunk.endIndex { + let end = min(offset + windowBytes, chunk.endIndex) + let window = chunk[offset.. Bool { + guard let startedAt = self.startedAt else { return false } + let elapsed = Self.seconds(startedAt.duration(to: .now)) + if elapsed > self.scheduleEnd + 0.5 { + return false + } + self.segments.removeAll { $0.end < elapsed } + let level = self.segments.first { elapsed >= $0.start && elapsed < $0.end }?.level ?? 0 + self.onLevel(level) + return true + } + + private static func seconds(_ duration: Duration) -> TimeInterval { + let parts = duration.components + return Double(parts.seconds) + Double(parts.attoseconds) * 1e-18 + } +} + +/// Publishes the live output level of an `AVAudioPlayer` (buffered TTS clips) +/// via its built-in metering at ~30 Hz. Detach clears the level to nil so the +/// consumer can distinguish "silent" from "not playing". +@MainActor +public final class AudioPlayerLevelMeter { + private let onLevel: @MainActor (Double?) -> Void + private var pollTask: Task? + private weak var player: AVAudioPlayer? + + public init(onLevel: @escaping @MainActor (Double?) -> Void) { + self.onLevel = onLevel + } + + public func attach(_ player: AVAudioPlayer) { + self.detach() + player.isMeteringEnabled = true + self.player = player + self.pollTask = Task { [weak self] in + while !Task.isCancelled { + guard let self, let player = self.player else { return } + player.updateMeters() + self.onLevel(TalkAudioLevel.normalized(decibels: Double(player.averagePower(forChannel: 0)))) + try? await Task.sleep(nanoseconds: 33_000_000) + } + } + } + + public func detach() { + self.pollTask?.cancel() + self.pollTask = nil + self.player = nil + self.onLevel(nil) + } +} diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkWaveformTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkWaveformTests.swift new file mode 100644 index 00000000000..f3e6d7aa281 --- /dev/null +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkWaveformTests.swift @@ -0,0 +1,90 @@ +import Foundation +import OpenClawChatUI +import OpenClawKit +import Testing + +struct TalkWaveformMathTests { + @Test + func `idle is A flat floor`() { + #expect(TalkWaveformMath.power(for: .idle, time: 0) == 0.05) + #expect(TalkWaveformMath.power(for: .idle, time: 12.7) == 0.05) + } + + @Test + func `thinking breathes inside its band`() { + for time in stride(from: 0.0, through: 10.0, by: 0.25) { + let power = TalkWaveformMath.power(for: .thinking, time: time) + #expect(power >= 0.16) + #expect(power <= 0.26 + 1e-9) + } + } + + @Test + func `listening follows the mic level and clamps`() { + #expect(TalkWaveformMath.power(for: .listening(level: 0, speechActive: false), time: 1) == 0.30) + #expect(TalkWaveformMath.power(for: .listening(level: 1, speechActive: false), time: 1) == 0.95) + #expect(TalkWaveformMath.power(for: .listening(level: 2.5, speechActive: false), time: 1) == 0.95) + #expect(TalkWaveformMath.power(for: .listening(level: -1, speechActive: false), time: 1) == 0.30) + } + + @Test + func `detected speech raises the floor but stays level driven`() { + let quiet = TalkWaveformMath.power(for: .listening(level: 0, speechActive: true), time: 1) + let loud = TalkWaveformMath.power(for: .listening(level: 1, speechActive: true), time: 1) + #expect(quiet == 0.55) + #expect(loud == 1.0) + } + + @Test + func `speaking follows the playback envelope`() { + #expect(TalkWaveformMath.power(for: .speaking(level: 0), time: 1) == 0.25) + #expect(TalkWaveformMath.power(for: .speaking(level: 1), time: 1) == 1.0) + #expect(TalkWaveformMath.power(for: .speaking(level: 3), time: 1) == 1.0) + } + + @Test + func `speaking without an envelope pulses synthetically`() { + var seen: Set = [] + for time in stride(from: 0.0, through: 3.0, by: 0.05) { + let power = TalkWaveformMath.power(for: .speaking(level: nil), time: time) + #expect(power >= 0.70 * 0.55 - 1e-9) + #expect(power <= 0.70 + 1e-9) + seen.insert(Int(power * 1000)) + } + // The fallback must move over time, not freeze. + #expect(seen.count > 5) + } +} + +struct TalkAudioLevelTests { + @Test + func `full scale RMS normalizes to one`() { + #expect(TalkAudioLevel.normalized(rms: 1.0) == 1.0) + } + + @Test + func `silence normalizes to zero`() { + #expect(TalkAudioLevel.normalized(rms: 0) == 0) + #expect(TalkAudioLevel.normalized(rms: 1e-9) == 0) + } + + @Test + func `pcm 16 RMS measures real samples`() { + let silence = Data(repeating: 0, count: 512) + #expect(TalkAudioLevel.pcm16RMS(silence) == 0) + + var fullScale = Data() + for _ in 0..<256 { + withUnsafeBytes(of: Int16.max.littleEndian) { fullScale.append(contentsOf: $0) } + } + let rms = TalkAudioLevel.pcm16RMS(fullScale) + #expect(abs(rms - 1.0) < 0.001) + #expect(TalkAudioLevel.normalized(rms: rms) > 0.99) + } + + @Test + func `pcm 16 RMS ignores empty and odd data`() { + #expect(TalkAudioLevel.pcm16RMS(Data()) == 0) + #expect(TalkAudioLevel.pcm16RMS(Data([0x7F])) == 0) + } +} diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/VoiceNoteRecorderTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/VoiceNoteRecorderTests.swift index 6a0d3f65a4e..12518bbbd1d 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/VoiceNoteRecorderTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/VoiceNoteRecorderTests.swift @@ -37,6 +37,12 @@ private final class FakeVoiceNoteAudioCapture: VoiceNoteAudioCapture { self.failureHandler = handler } + var meterLevel: Double? + + func currentLevel() -> Double? { + self.meterLevel + } + func failCapture() { self.failureHandler?() } @@ -217,6 +223,27 @@ final class VoiceNoteRecorderTests: XCTestCase { XCTAssertEqual(openClawVoiceNoteDurationLabel(-1), "0:00") } + @MainActor + func testRecordingPublishesCaptureLevelsAndResetsOnFinish() async throws { + let capture = FakeVoiceNoteAudioCapture() + capture.meterLevel = 0.8 + let recorder = OpenClawVoiceNoteRecorder( + capture: capture, + timerIntervalNanoseconds: 2_000_000) + + let started = await recorder.start() + XCTAssertTrue(started) + XCTAssertEqual(recorder.level, 0) + + for _ in 0..<200 where recorder.level == 0 { + try await Task.sleep(nanoseconds: 3_000_000) + } + XCTAssertGreaterThan(recorder.level, 0) + + _ = try XCTUnwrap(recorder.finish()) + XCTAssertEqual(recorder.level, 0) + } + @MainActor func testRecordButtonRequiresAttachmentInput() { let recorder = OpenClawVoiceNoteRecorder(capture: FakeVoiceNoteAudioCapture()) diff --git a/docs/nodes/talk.md b/docs/nodes/talk.md index 0bc5c392b3a..ea963c9ffc5 100644 --- a/docs/nodes/talk.md +++ b/docs/nodes/talk.md @@ -110,7 +110,7 @@ Supported keys: `voice` / `voice_id` / `voiceId`, `model` / `model_id` / `modelI - Menu bar toggle: **Talk** - Config tab: **Talk Mode** group (voice id + interrupt toggle) -- Overlay: Listening (cloud pulses with mic level) → Thinking (sinking animation) → Speaking (radiating rings). Click the cloud to stop speaking, click X to exit Talk mode. +- Overlay: the orb renders the universal talk waveform (shared with iOS, watchOS, and Android). Listening follows the live mic level, Speaking follows the actual TTS playback envelope, Thinking breathes softly. Click the orb to pause/resume, double-click to stop speaking, click X to exit Talk mode. ## Android UI