feat(ios): record and send voice notes from the chat composer (#100946)

* feat(ios): record and send voice notes from the chat composer

Adds tap-to-record voice notes to the shared chat composer: m4a/AAC mono
capture with a 3-minute cap, lazy mic permission, cancel/finish controls,
audio attachment chips with duration, and a voice-note transcript row.
Voice-wake suppression becomes reason-scoped so recording and Talk cannot
clobber each other. Attachments stay online-only per the outbox contract.

Related: #100709

* fix(ios): harden voice note attachment staging

* chore(ios): keep voice note changelog release-owned

* fix(ios): gate voice notes with attachment availability

* chore(ios): refresh native localization inventory
This commit is contained in:
Peter Steinberger 2026-07-07 00:14:05 +01:00 committed by GitHub
parent bb44c2311d
commit b978dcc63b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1224 additions and 103 deletions

View file

@ -8475,7 +8475,7 @@
},
{
"kind": "ui-call",
"line": 162,
"line": 163,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "The session attaches once the gateway is ready.",
"surface": "apple",
@ -8483,7 +8483,7 @@
},
{
"kind": "ui-modifier",
"line": 261,
"line": 268,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "Opens Settings / Gateway",
"surface": "apple",
@ -8491,7 +8491,7 @@
},
{
"kind": "ui-call",
"line": 286,
"line": 293,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "New Chat",
"surface": "apple",
@ -8499,7 +8499,7 @@
},
{
"kind": "ui-call",
"line": 299,
"line": 306,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "New Chat in Worktree",
"surface": "apple",
@ -8507,7 +8507,7 @@
},
{
"kind": "ui-call",
"line": 314,
"line": 321,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "Export Transcript",
"surface": "apple",
@ -8515,7 +8515,7 @@
},
{
"kind": "ui-modifier",
"line": 324,
"line": 331,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "Chat actions",
"surface": "apple",
@ -8523,7 +8523,7 @@
},
{
"kind": "conditional-branch",
"line": 377,
"line": 384,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "Connected",
"surface": "apple",
@ -8531,7 +8531,7 @@
},
{
"kind": "conditional-branch",
"line": 377,
"line": 384,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "Unavailable",
"surface": "apple",
@ -8539,7 +8539,7 @@
},
{
"kind": "ui-localized-call",
"line": 389,
"line": 396,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "Message \\(self.agentDisplayName)...",
"surface": "apple",
@ -8547,7 +8547,7 @@
},
{
"kind": "ui-localized-call",
"line": 392,
"line": 399,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "Message \\(self.agentDisplayName); sends when connected",
"surface": "apple",
@ -8555,7 +8555,7 @@
},
{
"kind": "ui-localized-call",
"line": 394,
"line": 401,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "Connect to a gateway",
"surface": "apple",
@ -8563,7 +8563,7 @@
},
{
"kind": "ui-localized-call",
"line": 462,
"line": 469,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "Check OpenClaw status",
"surface": "apple",
@ -8571,7 +8571,7 @@
},
{
"kind": "ui-localized-call",
"line": 463,
"line": 470,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "Summarize the current OpenClaw status and tell me what needs attention.",
"surface": "apple",
@ -8579,7 +8579,7 @@
},
{
"kind": "ui-localized-call",
"line": 466,
"line": 473,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "What can I control here?",
"surface": "apple",
@ -8587,7 +8587,7 @@
},
{
"kind": "ui-localized-call",
"line": 467,
"line": 474,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "Show me which phone controls and device capabilities are available right now.",
"surface": "apple",
@ -8595,7 +8595,7 @@
},
{
"kind": "ui-localized-call",
"line": 470,
"line": 477,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "Help me start voice chat",
"surface": "apple",
@ -8603,7 +8603,7 @@
},
{
"kind": "ui-localized-call",
"line": 471,
"line": 478,
"path": "apps/ios/Sources/Design/ChatProTab.swift",
"source": "Help me start a realtime voice session from this phone.",
"surface": "apple",
@ -12731,7 +12731,7 @@
},
{
"kind": "conditional-branch",
"line": 2855,
"line": 2878,
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
"source": "Action required",
"surface": "apple",
@ -12739,7 +12739,7 @@
},
{
"kind": "conditional-branch",
"line": 2855,
"line": 2878,
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
"source": "Approval needed",
"surface": "apple",
@ -12747,7 +12747,7 @@
},
{
"kind": "conditional-branch",
"line": 3417,
"line": 3440,
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
"source": "Connecting…",
"surface": "apple",
@ -12755,7 +12755,7 @@
},
{
"kind": "conditional-branch",
"line": 3417,
"line": 3440,
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
"source": "Reconnecting…",
"surface": "apple",
@ -12763,7 +12763,7 @@
},
{
"kind": "conditional-branch",
"line": 3421,
"line": 3444,
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
"source": "Connecting...",
"surface": "apple",
@ -12771,7 +12771,7 @@
},
{
"kind": "conditional-branch",
"line": 3421,
"line": 3444,
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
"source": "Reconnecting...",
"surface": "apple",
@ -12779,7 +12779,7 @@
},
{
"kind": "conditional-branch",
"line": 3713,
"line": 3736,
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
"source": "Connected",
"surface": "apple",
@ -12787,7 +12787,7 @@
},
{
"kind": "conditional-branch",
"line": 3713,
"line": 3736,
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
"source": "Offline",
"surface": "apple",
@ -14307,7 +14307,7 @@
},
{
"kind": "conditional-branch",
"line": 260,
"line": 280,
"path": "apps/ios/Sources/Voice/VoiceWakeManager.swift",
"source": "Off",
"surface": "apple",
@ -14315,7 +14315,7 @@
},
{
"kind": "conditional-branch",
"line": 260,
"line": 280,
"path": "apps/ios/Sources/Voice/VoiceWakeManager.swift",
"source": "Paused",
"surface": "apple",
@ -21811,7 +21811,7 @@
},
{
"kind": "ui-modifier",
"line": 214,
"line": 240,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Context \\(percentage)% used",
"surface": "apple",
@ -21819,7 +21819,7 @@
},
{
"kind": "ui-call",
"line": 218,
"line": 244,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Thinking",
"surface": "apple",
@ -21827,7 +21827,7 @@
},
{
"kind": "ui-call",
"line": 239,
"line": 265,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Model",
"surface": "apple",
@ -21835,7 +21835,7 @@
},
{
"kind": "ui-call",
"line": 256,
"line": 282,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Pinned",
"surface": "apple",
@ -21843,7 +21843,7 @@
},
{
"kind": "ui-call",
"line": 264,
"line": 290,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Recent",
"surface": "apple",
@ -21851,7 +21851,7 @@
},
{
"kind": "ui-call",
"line": 272,
"line": 298,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Models",
"surface": "apple",
@ -21859,7 +21859,7 @@
},
{
"kind": "conditional-branch",
"line": 301,
"line": 327,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Pin model",
"surface": "apple",
@ -21867,7 +21867,7 @@
},
{
"kind": "conditional-branch",
"line": 301,
"line": 327,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Unpin model",
"surface": "apple",
@ -21875,7 +21875,7 @@
},
{
"kind": "ui-call",
"line": 305,
"line": 331,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Session",
"surface": "apple",
@ -21883,7 +21883,7 @@
},
{
"kind": "ui-modifier",
"line": 369,
"line": 395,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Add Image",
"surface": "apple",
@ -21891,15 +21891,23 @@
},
{
"kind": "ui-modifier",
"line": 370,
"line": 396,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Attachments",
"surface": "apple",
"id": "native.apple.1963dfa3285cb715"
},
{
"kind": "ui-call",
"line": 431,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Voice note",
"surface": "apple",
"id": "native.apple.f8dda832ed76441d"
},
{
"kind": "conditional-branch",
"line": 540,
"line": 589,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Talk",
"surface": "apple",
@ -21907,7 +21915,7 @@
},
{
"kind": "conditional-branch",
"line": 586,
"line": 635,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Start realtime chat",
"surface": "apple",
@ -21915,7 +21923,7 @@
},
{
"kind": "conditional-branch",
"line": 586,
"line": 635,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Stop realtime chat",
"surface": "apple",
@ -21923,7 +21931,7 @@
},
{
"kind": "conditional-branch",
"line": 619,
"line": 668,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Start",
"surface": "apple",
@ -21931,7 +21939,7 @@
},
{
"kind": "conditional-branch",
"line": 619,
"line": 668,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Stop",
"surface": "apple",
@ -21939,7 +21947,7 @@
},
{
"kind": "ui-call",
"line": 729,
"line": 778,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Loading commands",
"surface": "apple",
@ -21947,7 +21955,7 @@
},
{
"kind": "ui-call",
"line": 738,
"line": 787,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Commands unavailable",
"surface": "apple",
@ -21955,7 +21963,7 @@
},
{
"kind": "ui-call",
"line": 747,
"line": 796,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Retry",
"surface": "apple",
@ -21963,7 +21971,7 @@
},
{
"kind": "ui-call",
"line": 756,
"line": 805,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "No matching commands",
"surface": "apple",
@ -21971,7 +21979,7 @@
},
{
"kind": "ui-modifier",
"line": 888,
"line": 937,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Stop response",
"surface": "apple",
@ -21979,7 +21987,7 @@
},
{
"kind": "ui-modifier",
"line": 913,
"line": 962,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Send message",
"surface": "apple",
@ -21987,7 +21995,7 @@
},
{
"kind": "ui-modifier",
"line": 928,
"line": 977,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Refresh",
"surface": "apple",
@ -21995,7 +22003,7 @@
},
{
"kind": "conditional-branch",
"line": 1038,
"line": 1108,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Connecting...",
"surface": "apple",
@ -22003,7 +22011,7 @@
},
{
"kind": "conditional-branch",
"line": 1038,
"line": 1108,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Gateway connected",
"surface": "apple",
@ -22019,7 +22027,7 @@
},
{
"kind": "conditional-branch",
"line": 557,
"line": 569,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Show full output",
"surface": "apple",
@ -22027,7 +22035,7 @@
},
{
"kind": "conditional-branch",
"line": 557,
"line": 569,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Show less",
"surface": "apple",
@ -22035,7 +22043,7 @@
},
{
"kind": "ui-call",
"line": 616,
"line": 628,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Writing",
"surface": "apple",
@ -22043,7 +22051,7 @@
},
{
"kind": "ui-call",
"line": 642,
"line": 654,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Preparing audio…",
"surface": "apple",
@ -22051,7 +22059,7 @@
},
{
"kind": "ui-call",
"line": 645,
"line": 657,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Speaking…",
"surface": "apple",
@ -22059,7 +22067,7 @@
},
{
"kind": "ui-call",
"line": 653,
"line": 665,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Preparing audio, tap to cancel",
"surface": "apple",
@ -22067,7 +22075,7 @@
},
{
"kind": "ui-call",
"line": 654,
"line": 666,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Speaking, tap to stop",
"surface": "apple",
@ -22075,7 +22083,7 @@
},
{
"kind": "conditional-branch",
"line": 677,
"line": 689,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Queued",
"surface": "apple",
@ -22083,7 +22091,7 @@
},
{
"kind": "conditional-branch",
"line": 679,
"line": 691,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Sending…",
"surface": "apple",
@ -22091,7 +22099,7 @@
},
{
"kind": "conditional-branch",
"line": 681,
"line": 693,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Confirming…",
"surface": "apple",
@ -22099,7 +22107,7 @@
},
{
"kind": "conditional-branch",
"line": 683,
"line": 695,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Delivery unknown",
"surface": "apple",
@ -22107,7 +22115,7 @@
},
{
"kind": "conditional-branch",
"line": 685,
"line": 697,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Not sent",
"surface": "apple",
@ -22115,7 +22123,7 @@
},
{
"kind": "conditional-branch",
"line": 707,
"line": 719,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Queued, sends when reconnected",
"surface": "apple",
@ -22123,7 +22131,7 @@
},
{
"kind": "conditional-branch",
"line": 709,
"line": 721,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Sending",
"surface": "apple",
@ -22131,7 +22139,7 @@
},
{
"kind": "conditional-branch",
"line": 711,
"line": 723,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Sent, waiting for chat history confirmation",
"surface": "apple",
@ -22139,7 +22147,7 @@
},
{
"kind": "conditional-branch",
"line": 713,
"line": 725,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Delivery unconfirmed, touch and hold to retry or delete",
"surface": "apple",
@ -22147,7 +22155,7 @@
},
{
"kind": "conditional-branch",
"line": 715,
"line": 727,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Not sent, touch and hold to retry or delete",
"surface": "apple",
@ -22155,7 +22163,7 @@
},
{
"kind": "ui-call",
"line": 810,
"line": 822,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "Running tools…",
"surface": "apple",
@ -22163,7 +22171,7 @@
},
{
"kind": "ui-call",
"line": 818,
"line": 830,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift",
"source": "\\(display.emoji) \\(display.label)",
"surface": "apple",
@ -22179,7 +22187,7 @@
},
{
"kind": "ui-call",
"line": 430,
"line": 434,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Retry Send",
"surface": "apple",
@ -22187,7 +22195,7 @@
},
{
"kind": "ui-call",
"line": 444,
"line": 448,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Delete",
"surface": "apple",
@ -22195,7 +22203,7 @@
},
{
"kind": "ui-call",
"line": 475,
"line": 479,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Stop Listening",
"surface": "apple",
@ -22203,7 +22211,7 @@
},
{
"kind": "ui-call",
"line": 478,
"line": 482,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Listen",
"surface": "apple",
@ -22211,7 +22219,7 @@
},
{
"kind": "ui-call",
"line": 551,
"line": 555,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Jump to latest",
"surface": "apple",
@ -22219,7 +22227,7 @@
},
{
"kind": "ui-modifier",
"line": 562,
"line": 566,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Jump to latest reply",
"surface": "apple",
@ -22227,7 +22235,7 @@
},
{
"kind": "ui-named-argument",
"line": 582,
"line": 586,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Refresh",
"surface": "apple",
@ -22235,7 +22243,7 @@
},
{
"kind": "ui-call",
"line": 1007,
"line": 1011,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Loading chat",
"surface": "apple",
@ -22243,15 +22251,31 @@
},
{
"kind": "ui-modifier",
"line": 1087,
"line": 1091,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"source": "Dismiss",
"surface": "apple",
"id": "native.apple.5c481fea4eb3dc17"
},
{
"kind": "ui-localized-call",
"line": 22,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+Attachments.swift",
"source": "Could not attach voice note: \\(error.localizedDescription)",
"surface": "apple",
"id": "native.apple.82befa424fe7bace"
},
{
"kind": "ui-localized-call",
"line": 27,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+Attachments.swift",
"source": "Voice note exceeds the 5 MB attachment limit",
"surface": "apple",
"id": "native.apple.ec5daba7e38184dc"
},
{
"kind": "conditional-branch",
"line": 62,
"line": 91,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+Attachments.swift",
"source": "\\(baseName).jpg",
"surface": "apple",
@ -22259,7 +22283,7 @@
},
{
"kind": "conditional-branch",
"line": 1360,
"line": 1361,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift",
"source": "delivery unconfirmed",
"surface": "apple",
@ -22267,12 +22291,68 @@
},
{
"kind": "conditional-branch",
"line": 1360,
"line": 1361,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift",
"source": "queued after route change",
"surface": "apple",
"id": "native.apple.722f1f90b97e8e45"
},
{
"kind": "ui-modifier",
"line": 39,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/VoiceNoteComposerViews.swift",
"source": "Record Voice Note",
"surface": "apple",
"id": "native.apple.71e0219d1501de06"
},
{
"kind": "ui-modifier",
"line": 40,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/VoiceNoteComposerViews.swift",
"source": "Record voice note",
"surface": "apple",
"id": "native.apple.e6770d7cc616021f"
},
{
"kind": "ui-call",
"line": 59,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/VoiceNoteComposerViews.swift",
"source": "Recording",
"surface": "apple",
"id": "native.apple.c971dc7dc243d0d1"
},
{
"kind": "ui-modifier",
"line": 75,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/VoiceNoteComposerViews.swift",
"source": "Cancel voice note",
"surface": "apple",
"id": "native.apple.0fb64b1b7ea0aa5e"
},
{
"kind": "ui-modifier",
"line": 84,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/VoiceNoteComposerViews.swift",
"source": "Finish voice note",
"surface": "apple",
"id": "native.apple.b7a9de9b3c8e4bad"
},
{
"kind": "ui-localized-call",
"line": 108,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/VoiceNoteRecorder.swift",
"source": "Microphone access is required. Enable it in Settings.",
"surface": "apple",
"id": "native.apple.1fc7530ccdfc937e"
},
{
"kind": "ui-localized-call",
"line": 121,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/VoiceNoteRecorder.swift",
"source": "Could not start recording: \\(error.localizedDescription)",
"surface": "apple",
"id": "native.apple.c963f6dfaa0610b5"
},
{
"kind": "conditional-branch",
"line": 235,

View file

@ -150,6 +150,7 @@ struct ChatProTab: View {
emptyAssistantIntro: String(localized: "What would you like to work on?"),
emptyAssistantPrompts: Self.emptyAssistantPrompts,
talkControl: self.talkControl,
voiceNoteControl: self.voiceNoteControl,
speech: self.speech)
// iMessage-style grey bubbles for agent replies in the clean chrome.
.environment(\.openClawAssistantBubblesInCleanChrome, true)
@ -245,6 +246,12 @@ struct ChatProTab: View {
})
}
private var voiceNoteControl: OpenClawChatVoiceNoteControl {
OpenClawChatVoiceNoteControl(
recorder: self.appModel.voiceNoteRecorder,
isTalkActive: self.appModel.talkMode.isEnabled || self.appModel.talkMode.isPushToTalkActive)
}
private var activeAgentID: String {
self.normalized(self.appModel.chatAgentId)
?? "main"

View file

@ -255,6 +255,7 @@ final class NodeAppModel {
private var gatewayHealthMonitorDisabled = false
private let notificationCenter: NotificationCentering
let voiceWake = VoiceWakeManager()
let voiceNoteRecorder: OpenClawVoiceNoteRecorder
let talkMode: TalkModeManager
private let locationService: any LocationServicing
private let deviceStatusService: any DeviceStatusServicing
@ -500,7 +501,8 @@ final class NodeAppModel {
remindersService: any RemindersServicing = RemindersService(),
motionService: any MotionServicing = MotionService(),
watchMessagingService: any WatchMessagingServicing = WatchMessagingService(),
talkMode: TalkModeManager = TalkModeManager())
talkMode: TalkModeManager = TalkModeManager(),
voiceNoteRecorder: OpenClawVoiceNoteRecorder = OpenClawVoiceNoteRecorder())
{
self.screen = screen
self.camera = camera
@ -515,6 +517,7 @@ final class NodeAppModel {
self.motionService = motionService
self.watchMessagingService = watchMessagingService
self.talkMode = talkMode
self.voiceNoteRecorder = voiceNoteRecorder
self.apnsDeviceTokenHex = UserDefaults.standard.string(forKey: Self.apnsDeviceTokenUserDefaultsKey)
restorePersistedWatchExecApprovalBridgeState()
GatewayDiagnostics.bootstrap()
@ -575,6 +578,9 @@ final class NodeAppModel {
// Best-effort only.
}
}
self.voiceNoteRecorder.onRecordingActiveChanged = { [weak self] isActive in
self?.voiceWake.setSuppressedByVoiceNote(isActive)
}
let enabled = UserDefaults.standard.bool(forKey: "voiceWake.enabled")
self.voiceWake.setEnabled(enabled)
@ -881,6 +887,9 @@ final class NodeAppModel {
}
UserDefaults.standard.set(enabled, forKey: "talk.enabled")
if enabled {
if self.voiceNoteRecorder.isRecording || self.voiceNoteRecorder.isRequestingPermission {
self.voiceNoteRecorder.cancel()
}
// Voice wake holds the microphone continuously; talk mode needs exclusive access for STT.
// When talk is enabled from the UI, prioritize talk and pause voice wake.
self.voiceWake.setSuppressedByTalk(true)
@ -1973,6 +1982,20 @@ final class NodeAppModel {
}
private func handleTalkInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
if req.command == OpenClawTalkCommand.pttOnce.rawValue ||
req.command == OpenClawTalkCommand.pttStart.rawValue
{
// Composer recording and PTT use separate AVAudio stacks. Refuse the
// second owner before it can reconfigure the shared audio session.
guard !self.voiceNoteRecorder.isRecording,
!self.voiceNoteRecorder.isRequestingPermission
else {
throw NSError(domain: "TalkMode", code: 8, userInfo: [
NSLocalizedDescriptionKey: "Voice note recording is active",
])
}
}
if req.command == OpenClawTalkCommand.pttOnce.rawValue {
self.acquirePttVoiceWakeLease()
defer { self.releasePttVoiceWakeLease() }

View file

@ -49,6 +49,11 @@ private enum VoiceWakeAudioError: LocalizedError {
}
}
private enum VoiceWakeSuppressionReason: Hashable {
case talk
case voiceNote
}
extension AVAudioPCMBuffer {
fileprivate func deepCopy() -> AVAudioPCMBuffer? {
let format = self.format
@ -111,7 +116,7 @@ final class VoiceWakeManager: NSObject {
private var lastDispatched: String?
private var onCommand: (@Sendable (String) async -> Void)?
private var userDefaultsObserver: NSObjectProtocol?
private var suppressedByTalk: Bool = false
private var suppressionReasons: Set<VoiceWakeSuppressionReason> = []
private let externalAudioResumeDelayNs: UInt64
private let recognitionErrorRestartDelayNs: UInt64
@ -167,8 +172,23 @@ final class VoiceWakeManager: NSObject {
}
func setSuppressedByTalk(_ suppressed: Bool) {
self.suppressedByTalk = suppressed
self.setSuppressed(suppressed, reason: .talk)
}
func setSuppressedByVoiceNote(_ suppressed: Bool) {
self.setSuppressed(suppressed, reason: .voiceNote)
}
private func setSuppressed(_ suppressed: Bool, reason: VoiceWakeSuppressionReason) {
if suppressed {
self.suppressionReasons.insert(reason)
} else {
self.suppressionReasons.remove(reason)
}
// Each microphone owner clears only its own reason, so Talk ending
// cannot restart Voice Wake over an active voice-note recording.
if !self.suppressionReasons.isEmpty {
self.cancelScheduledStart()
if self.isListening {
self.isListening = false
@ -214,7 +234,7 @@ final class VoiceWakeManager: NSObject {
self.isStarting = true
defer { self.isStarting = false }
guard !self.suppressedByTalk else {
guard self.suppressionReasons.isEmpty else {
self.isListening = false
self.statusText = "Paused"
return
@ -255,7 +275,7 @@ final class VoiceWakeManager: NSObject {
return
}
guard self.isEnabled, !self.suppressedByTalk, !self.isSuspendedForExternalAudio else {
guard self.isEnabled, self.suppressionReasons.isEmpty, !self.isSuspendedForExternalAudio else {
self.isListening = false
self.statusText = self.isEnabled ? "Paused" : "Off"
return
@ -303,7 +323,7 @@ final class VoiceWakeManager: NSObject {
}
private func startRecognition() throws {
guard self.isEnabled, !self.suppressedByTalk, !self.isSuspendedForExternalAudio else { return }
guard self.isEnabled, self.suppressionReasons.isEmpty, !self.isSuspendedForExternalAudio else { return }
self.recognitionTask?.cancel()
self.recognitionTask = nil

View file

@ -79,6 +79,21 @@ private func makeProjectedWatchChatRawMessage(
return try JSONDecoder().decode(AnyCodable.self, from: data)
}
@MainActor
private final class VoiceNoteCaptureStub: VoiceNoteAudioCapture {
func requestPermission() async -> Bool {
true
}
func start(url _: URL) throws {}
func stop() -> TimeInterval {
1
}
func cancel() {}
}
@MainActor
private func waitForMainActorWork(_ condition: () -> Bool) async {
for _ in 0..<100 {
@ -724,6 +739,23 @@ private actor WatchSnapshotSendGate {
appModel.voiceWake.stop()
}
@Test @MainActor func `PTT start is refused while a voice note owns the microphone`() async {
let voiceNoteRecorder = OpenClawVoiceNoteRecorder(capture: VoiceNoteCaptureStub())
let talkMode = TalkModeManager(allowSimulatorCapture: true)
let appModel = NodeAppModel(talkMode: talkMode, voiceNoteRecorder: voiceNoteRecorder)
#expect(await voiceNoteRecorder.start())
defer { voiceNoteRecorder.cancel() }
let request = BridgeInvokeRequest(
id: "ptt-start-during-voice-note",
command: OpenClawTalkCommand.pttStart.rawValue)
let response = await appModel._test_handleInvoke(request)
#expect(response.ok == false)
#expect(response.error?.message.contains("Voice note recording is active") == true)
#expect(talkMode.isPushToTalkActive == false)
}
@Test @MainActor func `overlapping PTT owners keep voice wake suspended until final release`() {
let appModel = NodeAppModel(talkMode: TalkModeManager(allowSimulatorCapture: true))
appModel.voiceWake.isEnabled = true

View file

@ -69,4 +69,23 @@ struct VoiceWakeManagerSuppressionTests {
#expect(manager.statusText == "Voice Wake isnt supported on Simulator")
#expect(manager.isListening == false)
}
@Test
@MainActor func `clearing Talk suppression does not clobber voice note suppression`() async {
let manager = VoiceWakeManager._test_withoutRestartDelays()
manager.isEnabled = true
manager.statusText = "Listening"
manager.setSuppressedByVoiceNote(true)
manager.setSuppressedByTalk(true)
manager.setSuppressedByTalk(false)
await manager._test_waitForScheduledStart()
#expect(manager.statusText == "Paused")
#expect(manager.isListening == false)
manager.setSuppressedByVoiceNote(false)
await manager._test_waitForScheduledStart()
#expect(manager.statusText == "Voice Wake isnt supported on Simulator")
}
}

View file

@ -87,6 +87,8 @@ struct OpenClawChatComposer: View {
let isAttachmentInputEnabled: Bool
let messagePlaceholder: String?
let talkControl: OpenClawChatTalkControl?
let voiceNoteControl: OpenClawChatVoiceNoteControl?
@State private var stagingVoiceNoteURL: URL?
#if !os(macOS)
@State private var pickerItems: [PhotosPickerItem] = []
@ -101,7 +103,7 @@ struct OpenClawChatComposer: View {
var body: some View {
VStack(alignment: .leading, spacing: 4) {
if self.showsToolbar {
if self.showsToolbar, self.voiceNoteControl?.recorder.isRecording != true {
self.composerToolbar
}
@ -109,7 +111,12 @@ struct OpenClawChatComposer: View {
self.attachmentsStrip
}
self.editor
if let voiceNoteControl, voiceNoteControl.recorder.isRecording {
OpenClawVoiceNoteRecordingRow(recorder: voiceNoteControl.recorder)
.padding(self.editorPadding)
} else {
self.editor
}
}
.padding(self.composerPadding)
.background {
@ -163,6 +170,18 @@ struct OpenClawChatComposer: View {
self.viewModel.loadSlashCommandsIfNeeded()
}
#endif
.onChange(of: self.voiceNoteControl?.recorder.completedRecording) { _, recording in
guard recording != nil else { return }
self.stageCompletedVoiceNoteIfNeeded()
}
.onChange(of: self.voiceNoteControl?.recorder.errorMessage) { _, message in
if let message {
self.viewModel.errorText = message
}
}
.onAppear {
self.stageCompletedVoiceNoteIfNeeded()
}
}
private var composerToolbar: some View {
@ -193,6 +212,13 @@ struct OpenClawChatComposer: View {
if self.style == .standard {
self.refreshButton
self.attachmentPicker
if let voiceNoteControl, !voiceNoteControl.isTalkActive {
OpenClawVoiceNoteButton(
control: voiceNoteControl,
compact: false,
isComposerEnabled: self.isComposerEnabled,
isAttachmentInputEnabled: self.isAttachmentInputEnabled)
}
}
}
.padding(.horizontal, 10)
@ -400,13 +426,27 @@ struct OpenClawChatComposer: View {
.scaledToFill()
.frame(width: 22, height: 22)
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
} else if att.mimeType.hasPrefix("audio/") {
Image(systemName: "waveform")
Text("Voice note")
.font(OpenClawChatTypography.caption)
if let durationSeconds = att.durationSeconds {
Text(openClawVoiceNoteDurationLabel(durationSeconds))
.font(OpenClawChatTypography.caption)
.foregroundStyle(.secondary)
}
} else {
Image(systemName: "photo")
Text(att.fileName)
.font(OpenClawChatTypography.caption)
.lineLimit(1)
}
Text(att.fileName)
.font(OpenClawChatTypography.caption)
.lineLimit(1)
if att.preview != nil {
Text(att.fileName)
.font(OpenClawChatTypography.caption)
.lineLimit(1)
}
Button {
self.viewModel.removeAttachment(att.id)
@ -497,6 +537,15 @@ struct OpenClawChatComposer: View {
self.attachmentPicker
.frame(width: self.cleanIconControlSize, height: self.cleanEditorMinHeight)
if let voiceNoteControl, !voiceNoteControl.isTalkActive {
OpenClawVoiceNoteButton(
control: voiceNoteControl,
compact: true,
isComposerEnabled: self.isComposerEnabled,
isAttachmentInputEnabled: self.isAttachmentInputEnabled)
.frame(width: self.cleanIconControlSize, height: self.cleanEditorMinHeight)
}
self.editorOverlay
.padding(.vertical, self.cleanEditorTextPadding)
.padding(.horizontal, 14)
@ -1030,10 +1079,31 @@ struct OpenClawChatComposer: View {
private var canSendMessage: Bool {
self.isComposerEnabled
&& self.voiceNoteControl?.recorder.completedRecording == nil
&& self.viewModel.canSend
&& (self.isAttachmentInputEnabled || self.viewModel.attachments.isEmpty)
}
private func stageCompletedVoiceNoteIfNeeded() {
guard let recorder = self.voiceNoteControl?.recorder,
let recording = recorder.completedRecording,
self.stagingVoiceNoteURL != recording.fileURL
else { return }
self.stagingVoiceNoteURL = recording.fileURL
Task {
await self.viewModel.addVoiceNoteAttachment(
fileURL: recording.fileURL,
durationSeconds: recording.durationSeconds)
if recorder.completedRecording == recording {
recorder.clearCompletedRecording()
}
if self.stagingVoiceNoteURL == recording.fileURL {
self.stagingVoiceNoteURL = nil
}
}
}
private var connectionStatusText: String {
self.connectionOK ? "Gateway connected" : "Connecting..."
}

View file

@ -478,17 +478,29 @@ private struct AttachmentRow: View {
var body: some View {
HStack(spacing: 8) {
Image(systemName: "paperclip")
Text(self.att.fileName ?? "Attachment")
Image(systemName: self.isAudio ? "waveform" : "paperclip")
Text(self.isAudio ? "Voice note" : (self.att.fileName ?? "Attachment"))
.font(OpenClawChatTypography.footnote)
.lineLimit(1)
.foregroundStyle(self.isUser ? OpenClawChatTheme.userText : OpenClawChatTheme.assistantText)
if self.isAudio, let durationSeconds = self.att.durationSeconds {
Text(openClawVoiceNoteDurationLabel(durationSeconds))
.font(OpenClawChatTypography.footnote)
.foregroundStyle(
self.isUser
? OpenClawChatTheme.userText.opacity(0.72)
: OpenClawChatTheme.assistantText.opacity(0.72))
}
Spacer()
}
.padding(10)
.background(self.isUser ? Color.white.opacity(0.2) : Color.black.opacity(0.04))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
private var isAudio: Bool {
self.att.mimeType?.hasPrefix("audio/") == true
}
}
private struct ToolCallCard: View {

View file

@ -116,6 +116,7 @@ public struct OpenClawChatMessageContent: Codable, Hashable, Sendable {
public let thinkingSignature: String?
public let mimeType: String?
public let fileName: String?
public let durationSeconds: Double?
public let content: AnyCodable?
// Tool-call fields (when `type == "toolCall"` or similar)
@ -130,6 +131,7 @@ public struct OpenClawChatMessageContent: Codable, Hashable, Sendable {
thinkingSignature: String? = nil,
mimeType: String?,
fileName: String?,
durationSeconds: Double? = nil,
content: AnyCodable?,
id: String? = nil,
name: String? = nil,
@ -141,6 +143,7 @@ public struct OpenClawChatMessageContent: Codable, Hashable, Sendable {
self.thinkingSignature = thinkingSignature
self.mimeType = mimeType
self.fileName = fileName
self.durationSeconds = durationSeconds
self.content = content
self.id = id
self.name = name
@ -154,6 +157,7 @@ public struct OpenClawChatMessageContent: Codable, Hashable, Sendable {
case thinkingSignature
case mimeType
case fileName
case durationSeconds
case content
case id
case name
@ -168,6 +172,7 @@ public struct OpenClawChatMessageContent: Codable, Hashable, Sendable {
self.thinkingSignature = try container.decodeIfPresent(String.self, forKey: .thinkingSignature)
self.mimeType = try container.decodeIfPresent(String.self, forKey: .mimeType)
self.fileName = try container.decodeIfPresent(String.self, forKey: .fileName)
self.durationSeconds = try container.decodeIfPresent(Double.self, forKey: .durationSeconds)
self.id = try container.decodeIfPresent(String.self, forKey: .id)
self.name = try container.decodeIfPresent(String.self, forKey: .name)
self.arguments = try container.decodeIfPresent(AnyCodable.self, forKey: .arguments)
@ -496,6 +501,7 @@ public struct OpenClawPendingAttachment: Identifiable {
public let mimeType: String
public let type: String
public let preview: OpenClawPlatformImage?
public let durationSeconds: Double?
public init(
url: URL?,
@ -503,7 +509,8 @@ public struct OpenClawPendingAttachment: Identifiable {
fileName: String,
mimeType: String,
type: String = "file",
preview: OpenClawPlatformImage?)
preview: OpenClawPlatformImage?,
durationSeconds: Double? = nil)
{
self.url = url
self.data = data
@ -511,6 +518,7 @@ public struct OpenClawPendingAttachment: Identifiable {
self.mimeType = mimeType
self.type = type
self.preview = preview
self.durationSeconds = durationSeconds
}
}

View file

@ -1085,6 +1085,7 @@ public actor OpenClawChatSQLiteTranscriptCache: OpenClawChatTranscriptCache,
thinkingSignature: nil,
mimeType: item.mimeType,
fileName: item.fileName,
durationSeconds: item.durationSeconds,
content: nil,
id: item.id,
name: item.name,

View file

@ -98,6 +98,7 @@ public struct OpenClawChatView: View {
private let emptyAssistantIntro: String?
private let emptyAssistantPrompts: [StarterPrompt]
private let talkControl: OpenClawChatTalkControl?
private let voiceNoteControl: OpenClawChatVoiceNoteControl?
private let speech: OpenClawChatSpeechController?
private enum ScrollFollowTarget: Equatable {
@ -150,6 +151,7 @@ public struct OpenClawChatView: View {
emptyAssistantIntro: String? = nil,
emptyAssistantPrompts: [StarterPrompt] = [],
talkControl: OpenClawChatTalkControl? = nil,
voiceNoteControl: OpenClawChatVoiceNoteControl? = nil,
speech: OpenClawChatSpeechController? = nil)
{
_viewModel = State(initialValue: viewModel)
@ -170,6 +172,7 @@ public struct OpenClawChatView: View {
self.emptyAssistantIntro = emptyAssistantIntro
self.emptyAssistantPrompts = emptyAssistantPrompts
self.talkControl = talkControl
self.voiceNoteControl = voiceNoteControl
self.speech = speech
}
@ -231,7 +234,8 @@ public struct OpenClawChatView: View {
isComposerEnabled: self.isComposerEnabled,
isAttachmentInputEnabled: self.isAttachmentInputEnabled,
messagePlaceholder: self.messagePlaceholder,
talkControl: self.talkControl)
talkControl: self.talkControl,
voiceNoteControl: self.voiceNoteControl)
}
private var messageList: some View {

View file

@ -9,6 +9,35 @@ import UIKit
#endif
extension OpenClawChatViewModel {
/// Stages a recorded m4a voice note and removes its temporary file.
func addVoiceNoteAttachment(fileURL: URL, durationSeconds: Double) async {
defer { try? FileManager.default.removeItem(at: fileURL) }
let data: Data
do {
data = try await Task.detached(priority: .userInitiated) {
try Data(contentsOf: fileURL)
}.value
} catch {
self.errorText = String(localized: "Could not attach voice note: \(error.localizedDescription)")
return
}
guard data.count <= Self.maxAttachmentBytes else {
self.errorText = String(localized: "Voice note exceeds the 5 MB attachment limit")
return
}
self.attachments.append(
OpenClawPendingAttachment(
url: nil,
data: data,
fileName: fileURL.lastPathComponent,
mimeType: "audio/mp4",
preview: nil,
durationSeconds: max(0, durationSeconds)))
}
func loadAttachments(urls: [URL]) async {
for url in urls {
do {

View file

@ -25,6 +25,7 @@ extension OpenClawChatViewModel {
thinkingSignature: content.thinkingSignature,
mimeType: content.mimeType,
fileName: content.fileName,
durationSeconds: content.durationSeconds,
content: content.content,
id: content.id,
name: content.name,
@ -133,7 +134,9 @@ extension OpenClawChatViewModel {
OpenClawChatMessage(
id: existing.id,
role: incoming.role,
content: incoming.content,
content: self.preservingLocalAudioDurations(
in: incoming.content,
from: existing.content),
timestamp: incoming.timestamp ?? existing.timestamp,
idempotencyKey: incoming.idempotencyKey,
toolCallId: incoming.toolCallId,
@ -143,6 +146,38 @@ extension OpenClawChatViewModel {
errorMessage: incoming.errorMessage)
}
private static func preservingLocalAudioDurations(
in incoming: [OpenClawChatMessageContent],
from existing: [OpenClawChatMessageContent]) -> [OpenClawChatMessageContent]
{
let localDurations = existing
.filter { $0.mimeType?.hasPrefix("audio/") == true }
.map(\.durationSeconds)
var audioIndex = 0
return incoming.map { content in
guard content.mimeType?.hasPrefix("audio/") == true else { return content }
defer { audioIndex += 1 }
guard content.durationSeconds == nil,
localDurations.indices.contains(audioIndex),
let localDuration = localDurations[audioIndex]
else {
return content
}
return OpenClawChatMessageContent(
type: content.type,
text: content.text,
thinking: content.thinking,
thinkingSignature: content.thinkingSignature,
mimeType: content.mimeType,
fileName: content.fileName,
durationSeconds: localDuration,
content: content.content,
id: content.id,
name: content.name,
arguments: content.arguments)
}
}
func currentRunMessageScope() -> RunMessageScope {
RunMessageScope(
session: self.currentSessionSnapshot(),

View file

@ -1232,7 +1232,7 @@ public final class OpenClawChatViewModel {
fileName: att.fileName,
content: att.data.base64EncodedString())
}
for att in encodedAttachments {
for (attachment, att) in zip(draftAttachments, encodedAttachments) {
userContent.append(
OpenClawChatMessageContent(
type: att.type,
@ -1241,6 +1241,7 @@ public final class OpenClawChatViewModel {
thinkingSignature: nil,
mimeType: att.mimeType,
fileName: att.fileName,
durationSeconds: attachment.durationSeconds,
content: AnyCodable(att.content),
id: nil,
name: nil,

View file

@ -0,0 +1,107 @@
import SwiftUI
/// Host-provided voice-note recording state for the shared chat composer.
public struct OpenClawChatVoiceNoteControl {
public var recorder: OpenClawVoiceNoteRecorder
public var isTalkActive: Bool
public init(recorder: OpenClawVoiceNoteRecorder, isTalkActive: Bool) {
self.recorder = recorder
self.isTalkActive = isTalkActive
}
}
struct OpenClawVoiceNoteButton: View {
let control: OpenClawChatVoiceNoteControl
let compact: Bool
let isComposerEnabled: Bool
let isAttachmentInputEnabled: Bool
var isRecordingEnabled: Bool {
self.isComposerEnabled
&& self.isAttachmentInputEnabled
&& !self.control.isTalkActive
&& !self.control.recorder.isRequestingPermission
}
var body: some View {
Button {
Task { await self.control.recorder.start() }
} label: {
if self.control.recorder.isRequestingPermission {
ProgressView()
.controlSize(.mini)
} else {
Image(systemName: "mic")
.font(OpenClawChatTypography.display(size: 15, weight: .semibold, relativeTo: .subheadline))
}
}
.help("Record Voice Note")
.accessibilityLabel("Record voice note")
.accessibilityIdentifier("chat-voice-note-record")
.modifier(VoiceNoteButtonChrome(compact: self.compact))
.controlSize(.small)
.foregroundStyle(.secondary)
.contentShape(Rectangle())
.disabled(!self.isRecordingEnabled)
}
}
struct OpenClawVoiceNoteRecordingRow: View {
let recorder: OpenClawVoiceNoteRecorder
var body: some View {
HStack(spacing: 10) {
Circle()
.fill(OpenClawChatTheme.danger)
.frame(width: 9, height: 9)
Text("Recording")
.font(OpenClawChatTypography.footnoteSemiBold)
Text(openClawVoiceNoteDurationLabel(self.recorder.elapsedSeconds))
.font(OpenClawChatTypography.mono(size: 13, relativeTo: .footnote))
.foregroundStyle(.secondary)
Spacer(minLength: 8)
Button {
self.recorder.cancel()
} label: {
Image(systemName: "xmark")
}
.buttonStyle(.bordered)
.controlSize(.small)
.accessibilityLabel("Cancel voice note")
Button {
self.recorder.finish()
} label: {
Image(systemName: "checkmark")
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.accessibilityLabel("Finish voice note")
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(OpenClawChatTheme.composerField)
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(OpenClawChatTheme.composerBorder)))
}
}
private struct VoiceNoteButtonChrome: ViewModifier {
let compact: Bool
func body(content: Content) -> some View {
if self.compact {
content.buttonStyle(.plain)
} else {
content.buttonStyle(.bordered)
}
}
}

View file

@ -0,0 +1,305 @@
import AVFAudio
import Foundation
import Observation
private let voiceNoteMaximumDurationSeconds: TimeInterval = 180
/// Audio capture operations driven by the voice-note recorder state machine.
@MainActor
public protocol VoiceNoteAudioCapture: AnyObject {
/// Requests microphone access when recording is first used.
func requestPermission() async -> Bool
/// Starts writing an audio recording to the supplied URL.
func start(url: URL) throws
/// Stops capture and returns the recorded duration in seconds.
func stop() -> TimeInterval
/// Stops and discards the active capture.
func cancel()
}
/// A completed voice-note recording ready to stage as a chat attachment.
public struct OpenClawVoiceNoteRecording: Equatable, Sendable {
public let fileURL: URL
public let durationSeconds: TimeInterval
public init(fileURL: URL, durationSeconds: TimeInterval) {
self.fileURL = fileURL
self.durationSeconds = durationSeconds
}
}
/// Main-actor voice-note recorder with explicit permission and capture states.
@MainActor
@Observable
public final class OpenClawVoiceNoteRecorder {
public enum State: Equatable {
case idle
case requestingPermission
case recording(startedAt: Date, fileURL: URL)
case finished(recording: OpenClawVoiceNoteRecording)
case failed(message: String)
}
public static let maximumDurationSeconds = voiceNoteMaximumDurationSeconds
public private(set) var state: State = .idle
public private(set) var elapsedSeconds: TimeInterval = 0
@ObservationIgnored public var onRecordingActiveChanged: (@MainActor (Bool) -> Void)?
@ObservationIgnored private let capture: any VoiceNoteAudioCapture
@ObservationIgnored private let durationLimit: TimeInterval
@ObservationIgnored private let timerIntervalNanoseconds: UInt64
@ObservationIgnored private let now: () -> Date
@ObservationIgnored private var timerTask: Task<Void, Never>?
/// Creates a recorder backed by the system audio recorder.
public convenience init() {
self.init(capture: OpenClawVoiceNoteAudioCapture())
}
/// Creates a recorder with an injectable audio capture seam.
public init(
capture: any VoiceNoteAudioCapture,
durationLimit: TimeInterval = OpenClawVoiceNoteRecorder.maximumDurationSeconds,
timerIntervalNanoseconds: UInt64 = 100_000_000,
now: @escaping () -> Date = Date.init)
{
self.capture = capture
self.durationLimit = durationLimit
self.timerIntervalNanoseconds = timerIntervalNanoseconds
self.now = now
}
deinit {
self.timerTask?.cancel()
}
public var isRecording: Bool {
if case .recording = self.state { return true }
return false
}
public var isRequestingPermission: Bool {
self.state == .requestingPermission
}
public var errorMessage: String? {
guard case let .failed(message) = self.state else { return nil }
return message
}
public var completedRecording: OpenClawVoiceNoteRecording? {
guard case let .finished(recording) = self.state else { return nil }
return recording
}
/// Requests permission if needed and starts a new recording.
@discardableResult
public func start() async -> Bool {
guard self.state == .idle || self.errorMessage != nil else { return false }
self.elapsedSeconds = 0
self.state = .requestingPermission
guard await self.capture.requestPermission() else {
self.fail(message: String(localized: "Microphone access is required. Enable it in Settings."))
return false
}
guard self.state == .requestingPermission else { return false }
let fileURL = self.makeTemporaryFileURL()
self.onRecordingActiveChanged?(true)
do {
try self.capture.start(url: fileURL)
} catch {
try? FileManager.default.removeItem(at: fileURL)
self.capture.cancel()
self.onRecordingActiveChanged?(false)
self.fail(message: String(localized: "Could not start recording: \(error.localizedDescription)"))
return false
}
self.state = .recording(startedAt: self.now(), fileURL: fileURL)
self.startTimer()
return true
}
/// Finishes the active recording and publishes its attachment handoff.
@discardableResult
public func finish() -> OpenClawVoiceNoteRecording? {
guard case let .recording(_, fileURL) = self.state else { return nil }
self.timerTask?.cancel()
self.timerTask = nil
let duration = max(0, self.capture.stop())
let recording = OpenClawVoiceNoteRecording(fileURL: fileURL, durationSeconds: duration)
self.elapsedSeconds = duration
self.state = .finished(recording: recording)
self.onRecordingActiveChanged?(false)
return recording
}
/// Cancels permission or capture and removes any temporary audio file.
public func cancel() {
let fileURL: URL? = switch self.state {
case let .recording(_, fileURL):
fileURL
case let .finished(recording):
recording.fileURL
default:
nil
}
self.timerTask?.cancel()
self.timerTask = nil
self.capture.cancel()
if let fileURL {
try? FileManager.default.removeItem(at: fileURL)
}
let wasRecording = self.isRecording
self.elapsedSeconds = 0
self.state = .idle
if wasRecording {
self.onRecordingActiveChanged?(false)
}
}
/// Clears a completed handoff after the composer has staged it.
public func clearCompletedRecording() {
guard case .finished = self.state else { return }
self.state = .idle
self.elapsedSeconds = 0
}
private func startTimer() {
self.timerTask?.cancel()
self.timerTask = Task { [weak self] in
guard let self else { return }
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: self.timerIntervalNanoseconds)
guard !Task.isCancelled else { return }
guard case let .recording(startedAt, _) = self.state else { return }
self.elapsedSeconds = max(0, self.now().timeIntervalSince(startedAt))
if self.elapsedSeconds >= self.durationLimit {
self.finish()
return
}
}
}
}
private func fail(message: String) {
self.timerTask?.cancel()
self.timerTask = nil
let wasRecording = self.isRecording
self.elapsedSeconds = 0
self.state = .failed(message: message)
if wasRecording {
self.onRecordingActiveChanged?(false)
}
}
private func makeTemporaryFileURL() -> URL {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyyMMdd-HHmmss"
let timestamp = formatter.string(from: self.now())
return FileManager.default.temporaryDirectory
.appendingPathComponent("voice-note-\(timestamp).m4a")
}
}
/// AVAudioRecorder-backed AAC voice-note capture.
@MainActor
public final class OpenClawVoiceNoteAudioCapture: VoiceNoteAudioCapture {
private var recorder: AVAudioRecorder?
private var ownsAudioSession = false
public init() {}
public func requestPermission() async -> Bool {
#if os(iOS)
switch AVAudioApplication.shared.recordPermission {
case .granted:
return true
case .denied:
return false
case .undetermined:
return await withCheckedContinuation { continuation in
AVAudioApplication.requestRecordPermission { granted in
continuation.resume(returning: granted)
}
}
@unknown default:
return false
}
#else
return false
#endif
}
public func start(url: URL) throws {
do {
#if os(iOS)
let session = AVAudioSession.sharedInstance()
try session.setCategory(.record, mode: .measurement, options: [.allowBluetoothHFP])
try session.setActive(true)
self.ownsAudioSession = true
#endif
let settings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 24000,
AVNumberOfChannelsKey: 1,
AVEncoderBitRateKey: 32000,
AVEncoderAudioQualityKey: AVAudioQuality.medium.rawValue,
]
let recorder = try AVAudioRecorder(url: url, settings: settings)
guard recorder.record() else {
throw NSError(
domain: "OpenClawVoiceNoteAudioCapture",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Audio recorder refused to start"])
}
self.recorder = recorder
} catch {
self.deactivateAudioSession()
throw error
}
}
public func stop() -> TimeInterval {
guard let recorder = self.recorder else { return 0 }
let duration = recorder.currentTime
recorder.stop()
self.recorder = nil
self.deactivateAudioSession()
return duration
}
public func cancel() {
self.recorder?.stop()
self.recorder = nil
self.deactivateAudioSession()
}
private func deactivateAudioSession() {
#if os(iOS)
guard self.ownsAudioSession else { return }
self.ownsAudioSession = false
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
#endif
}
}
func openClawVoiceNoteDurationLabel(_ durationSeconds: TimeInterval) -> String {
guard durationSeconds.isFinite else { return "0:00" }
let boundedDuration = min(
max(0, durationSeconds),
voiceNoteMaximumDurationSeconds)
let totalSeconds = Int(boundedDuration)
return String(format: "%d:%02d", totalSeconds / 60, totalSeconds % 60)
}

View file

@ -6,7 +6,55 @@ import UniformTypeIdentifiers
import XCTest
@testable import OpenClawChatUI
private actor AttachmentSendCapture {
private(set) var attachments: [OpenClawChatAttachmentPayload] = []
func store(_ attachments: [OpenClawChatAttachmentPayload]) {
self.attachments = attachments
}
func count() -> Int {
self.attachments.count
}
func first() -> OpenClawChatAttachmentPayload? {
self.attachments.first
}
}
private actor AttachmentHealthGate {
private var entered = false
private var released = false
private var continuation: CheckedContinuation<Void, Never>?
func wait() async {
self.entered = true
guard !self.released else { return }
await withCheckedContinuation { continuation in
self.continuation = continuation
}
}
func hasEntered() -> Bool {
self.entered
}
func release() {
self.released = true
self.continuation?.resume()
self.continuation = nil
}
}
private struct AttachmentProcessingTransport: OpenClawChatTransport {
let capture: AttachmentSendCapture?
let healthGate: AttachmentHealthGate?
init(capture: AttachmentSendCapture? = nil, healthGate: AttachmentHealthGate? = nil) {
self.capture = capture
self.healthGate = healthGate
}
func requestHistory(sessionKey _: String) async throws -> OpenClawChatHistoryPayload {
throw NSError(domain: "ChatViewModelAttachmentTests", code: 1)
}
@ -15,13 +63,15 @@ private struct AttachmentProcessingTransport: OpenClawChatTransport {
sessionKey _: String,
message _: String,
thinking _: String,
idempotencyKey _: String,
attachments _: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
idempotencyKey: String,
attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
{
throw NSError(domain: "ChatViewModelAttachmentTests", code: 2)
await self.capture?.store(attachments)
return OpenClawChatSendResponse(runId: idempotencyKey, status: "started")
}
func requestHealth(timeoutMs _: Int) async throws -> Bool {
await self.healthGate?.wait()
true
}
@ -106,4 +156,160 @@ final class ChatViewModelAttachmentTests: XCTestCase {
let errorText = await MainActor.run { viewModel.errorText }
XCTAssertNil(errorText)
}
func testVoiceNoteAttachmentStagesAudioAndDeletesTemporaryFile() async throws {
let fileURL = FileManager.default.temporaryDirectory
.appendingPathComponent("voice-note-20260706-120000.m4a")
let data = Data("voice-note-data".utf8)
try data.write(to: fileURL)
let viewModel = await MainActor.run {
OpenClawChatViewModel(sessionKey: "main", transport: AttachmentProcessingTransport())
}
await viewModel.addVoiceNoteAttachment(fileURL: fileURL, durationSeconds: 8.4)
let attachment = try await MainActor.run { () throws -> (Data, String, String, String, Double?, Bool) in
let attachment = try XCTUnwrap(viewModel.attachments.first)
return (
attachment.data,
attachment.fileName,
attachment.mimeType,
attachment.type,
attachment.durationSeconds,
attachment.preview == nil)
}
XCTAssertEqual(attachment.0, data)
XCTAssertEqual(attachment.1, "voice-note-20260706-120000.m4a")
XCTAssertEqual(attachment.2, "audio/mp4")
XCTAssertEqual(attachment.3, "file")
XCTAssertEqual(attachment.4, 8.4)
XCTAssertTrue(attachment.5)
XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path))
}
func testOversizeVoiceNoteIsRejectedAndDeleted() async throws {
let fileURL = FileManager.default.temporaryDirectory
.appendingPathComponent("voice-note-oversize.m4a")
try Data(repeating: 0x41, count: 5_000_001).write(to: fileURL)
let viewModel = await MainActor.run {
OpenClawChatViewModel(sessionKey: "main", transport: AttachmentProcessingTransport())
}
await viewModel.addVoiceNoteAttachment(fileURL: fileURL, durationSeconds: 180)
let result = await MainActor.run { (viewModel.attachments.count, viewModel.errorText) }
XCTAssertEqual(result.0, 0)
XCTAssertEqual(result.1, "Voice note exceeds the 5 MB attachment limit")
XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path))
}
func testVoiceNoteSendUsesExistingAttachmentPayloadAndOptimisticDuration() async throws {
let capture = AttachmentSendCapture()
let transport = AttachmentProcessingTransport(capture: capture)
let fileURL = FileManager.default.temporaryDirectory
.appendingPathComponent("voice-note-20260706-120001.m4a")
let data = Data("encoded-voice-note".utf8)
try data.write(to: fileURL)
let viewModel = await MainActor.run {
OpenClawChatViewModel(sessionKey: "main", transport: transport)
}
await viewModel.addVoiceNoteAttachment(fileURL: fileURL, durationSeconds: 21.2)
await MainActor.run { viewModel.send() }
try await waitUntil("voice note sent") {
await capture.count() == 1
}
let capturedPayload = await capture.first()
let payload = try XCTUnwrap(capturedPayload)
XCTAssertEqual(payload.type, "file")
XCTAssertEqual(payload.mimeType, "audio/mp4")
XCTAssertEqual(payload.fileName, "voice-note-20260706-120001.m4a")
XCTAssertEqual(payload.content, data.base64EncodedString())
let optimisticAudio = await MainActor.run {
viewModel.messages.last?.content.first { $0.mimeType == "audio/mp4" }
}
XCTAssertEqual(optimisticAudio?.type, "file")
XCTAssertEqual(optimisticAudio?.mimeType, "audio/mp4")
XCTAssertEqual(optimisticAudio?.durationSeconds, 21.2)
}
func testVoiceNoteSendKeepsCapturedDurationWhenDraftChangesDuringHealthCheck() async throws {
let capture = AttachmentSendCapture()
let healthGate = AttachmentHealthGate()
let transport = AttachmentProcessingTransport(capture: capture, healthGate: healthGate)
let draftAttachment = OpenClawPendingAttachment(
url: nil,
data: Data("draft-audio".utf8),
fileName: "draft.m4a",
mimeType: "audio/mp4",
preview: nil,
durationSeconds: 21.2)
let replacementAttachment = OpenClawPendingAttachment(
url: nil,
data: Data("replacement-audio".utf8),
fileName: "replacement.m4a",
mimeType: "audio/mp4",
preview: nil,
durationSeconds: 99)
let viewModel = await MainActor.run {
let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport)
viewModel.attachments = [draftAttachment]
return viewModel
}
await MainActor.run { viewModel.send() }
try await waitUntil("health check started") {
await healthGate.hasEntered()
}
await MainActor.run {
viewModel.removeAttachment(draftAttachment.id)
viewModel.attachments.append(replacementAttachment)
}
await healthGate.release()
try await waitUntil("voice note sent") {
await capture.count() == 1
}
let optimisticAudio = await MainActor.run {
viewModel.messages.last?.content.first { $0.mimeType == "audio/mp4" }
}
XCTAssertEqual(optimisticAudio?.fileName, "draft.m4a")
XCTAssertEqual(optimisticAudio?.durationSeconds, 21.2)
}
@MainActor
func testCanonicalVoiceNotePreservesOptimisticDuration() throws {
let localAudio = OpenClawChatMessageContent(
type: "file",
text: nil,
mimeType: "audio/mp4",
fileName: "voice-note-local.m4a",
durationSeconds: 14.6,
content: AnyCodable("local"))
let canonicalAudio = OpenClawChatMessageContent(
type: "file",
text: nil,
mimeType: "audio/mp4",
fileName: "media-1.m4a",
content: AnyCodable("canonical"))
let existing = OpenClawChatMessage(
role: "user",
content: [localAudio],
timestamp: nil,
idempotencyKey: "run:user")
let incoming = OpenClawChatMessage(
role: "user",
content: [canonicalAudio],
timestamp: nil,
idempotencyKey: "run:user")
let adopted = OpenClawChatViewModel.adoptingCanonicalMessage(incoming, over: existing)
let audio = try XCTUnwrap(adopted.content.first)
XCTAssertEqual(audio.fileName, "media-1.m4a")
XCTAssertEqual(audio.content, AnyCodable("canonical"))
XCTAssertEqual(audio.durationSeconds, 14.6)
}
}

View file

@ -0,0 +1,162 @@
import Foundation
import XCTest
@testable import OpenClawChatUI
@MainActor
private final class FakeVoiceNoteAudioCapture: VoiceNoteAudioCapture {
var permissionGranted = true
var duration: TimeInterval = 12.5
var startError: Error?
var startCount = 0
var cancelCount = 0
var activeURL: URL?
var onStart: (() -> Void)?
func requestPermission() async -> Bool {
self.permissionGranted
}
func start(url: URL) throws {
self.onStart?()
if let startError { throw startError }
self.startCount += 1
self.activeURL = url
try Data("voice-note".utf8).write(to: url)
}
func stop() -> TimeInterval {
self.duration
}
func cancel() {
self.cancelCount += 1
}
}
final class VoiceNoteRecorderTests: XCTestCase {
@MainActor
func testStartAndFinishProduceRecordingWithDuration() async throws {
let capture = FakeVoiceNoteAudioCapture()
let recorder = OpenClawVoiceNoteRecorder(capture: capture)
var activeChanges: [Bool] = []
recorder.onRecordingActiveChanged = { activeChanges.append($0) }
capture.onStart = { XCTAssertEqual(activeChanges, [true]) }
let started = await recorder.start()
XCTAssertTrue(started)
guard case .recording = recorder.state else {
return XCTFail("Expected recording state")
}
let result = try XCTUnwrap(recorder.finish())
XCTAssertEqual(result.durationSeconds, 12.5)
XCTAssertEqual(recorder.state, .finished(recording: result))
XCTAssertEqual(recorder.completedRecording, result)
XCTAssertEqual(activeChanges, [true, false])
XCTAssertTrue(FileManager.default.fileExists(atPath: result.fileURL.path))
try FileManager.default.removeItem(at: result.fileURL)
}
@MainActor
func testCancelReturnsToIdleAndDeletesTemporaryFile() async throws {
let capture = FakeVoiceNoteAudioCapture()
let recorder = OpenClawVoiceNoteRecorder(capture: capture)
let started = await recorder.start()
XCTAssertTrue(started)
let fileURL = try XCTUnwrap(capture.activeURL)
XCTAssertTrue(FileManager.default.fileExists(atPath: fileURL.path))
recorder.cancel()
XCTAssertEqual(recorder.state, .idle)
XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path))
XCTAssertEqual(capture.cancelCount, 1)
}
@MainActor
func testDurationCapAutoFinishes() async throws {
let capture = FakeVoiceNoteAudioCapture()
capture.duration = 0.25
let recorder = OpenClawVoiceNoteRecorder(
capture: capture,
durationLimit: 0,
timerIntervalNanoseconds: 1_000_000)
let started = await recorder.start()
XCTAssertTrue(started)
try await waitUntil("voice note auto-finished") {
await MainActor.run { recorder.completedRecording != nil }
}
let result = try XCTUnwrap(recorder.completedRecording)
XCTAssertEqual(result.durationSeconds, 0.25)
XCTAssertEqual(recorder.state, .finished(recording: result))
try FileManager.default.removeItem(at: result.fileURL)
}
@MainActor
func testPermissionDeniedBecomesUserVisibleFailure() async {
let capture = FakeVoiceNoteAudioCapture()
capture.permissionGranted = false
let recorder = OpenClawVoiceNoteRecorder(capture: capture)
let started = await recorder.start()
XCTAssertFalse(started)
guard case let .failed(message) = recorder.state else {
return XCTFail("Expected failure state")
}
XCTAssertTrue(message.contains("Microphone access"))
XCTAssertEqual(capture.startCount, 0)
}
@MainActor
func testCaptureStartFailureReleasesRecordingActivity() async {
let capture = FakeVoiceNoteAudioCapture()
capture.startError = NSError(domain: "VoiceNoteRecorderTests", code: 1)
let recorder = OpenClawVoiceNoteRecorder(capture: capture)
var activeChanges: [Bool] = []
recorder.onRecordingActiveChanged = { activeChanges.append($0) }
let started = await recorder.start()
XCTAssertFalse(started)
XCTAssertEqual(activeChanges, [true, false])
guard case .failed = recorder.state else {
return XCTFail("Expected failure state")
}
}
@MainActor
func testStartIsRefusedWhileAlreadyRecording() async {
let capture = FakeVoiceNoteAudioCapture()
let recorder = OpenClawVoiceNoteRecorder(capture: capture)
let firstStart = await recorder.start()
let secondStart = await recorder.start()
XCTAssertTrue(firstStart)
XCTAssertFalse(secondStart)
XCTAssertEqual(capture.startCount, 1)
recorder.cancel()
}
func testDurationLabelBoundsMalformedHistoryValues() {
XCTAssertEqual(openClawVoiceNoteDurationLabel(.infinity), "0:00")
XCTAssertEqual(openClawVoiceNoteDurationLabel(.nan), "0:00")
XCTAssertEqual(openClawVoiceNoteDurationLabel(1e100), "3:00")
XCTAssertEqual(openClawVoiceNoteDurationLabel(-1), "0:00")
}
@MainActor
func testRecordButtonRequiresAttachmentInput() {
let recorder = OpenClawVoiceNoteRecorder(capture: FakeVoiceNoteAudioCapture())
let control = OpenClawChatVoiceNoteControl(recorder: recorder, isTalkActive: false)
let button = OpenClawVoiceNoteButton(
control: control,
compact: false,
isComposerEnabled: true,
isAttachmentInputEnabled: false)
XCTAssertFalse(button.isRecordingEnabled)
}
}