fix(mobile): keep dictation recording through a reconnect and keep what was already said (#35338)

* fix(mobile): keep dictation recording through a reconnect

The host tied a phone's dictation to its socket, so any reconnect cancelled
it, and the phone ended the recording as soon as the connection dropped.
The host now owns the session by device and waits a grace period for the
phone to come back. The phone keeps recording through the reconnect and
keeps the text transcribed so far if the connection does not return.

Fixes #35211

* fix(mobile): bound dictation reconnect waits and release retired relay waiters

* fix(mobile): resend an unsent dictation finish after a connection move and send the text so far only when it changes

* fix(mobile): hold dictation requests through a suspended relay and bound the start wait

* fix(mobile): recognise a suspended relay client across the page bridge

* refactor(dictation): refresh one abandon timer, report text by version and share the reconnect wait

* fix(mobile): resend dictation text each second and never wait offline for a cancel
This commit is contained in:
Kelvin Amoaba
2026-10-28 22:27:21 +00:00
committed by GitHub
parent 4881182aad
commit e5b45ac90f
45 changed files with 2058 additions and 1569 deletions
@@ -4,11 +4,7 @@
"family": "speech.dictation-chunk",
"namedDeltas": [],
"values": {
"02cebe6f0062": {
"failures": [],
"pending": 0
},
"0a3ec57e0321": {
"028afac3649e": {
"name": "speech.dictation.chunk#1",
"ordinal": 1,
"args": [
@@ -27,7 +23,7 @@
{
"name": "options",
"value": {
"$rpc": "absent"
"timeoutMs": 30000
}
}
],
@@ -47,84 +43,15 @@
}
}
},
"02cebe6f0062": {
"failures": [],
"pending": 0
},
"12521d219054": {
"name": "speech.dictation.chunk#1",
"ordinal": 2,
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"speech.dictation.chunk\",\"params\":{\"dictationId\":\"dictation-1\",\"audioBase64\":\"ACVKb5S53gM=\",\"sampleRate\":16000}}"
},
"16da7db6669b": {
"name": "speech.dictation.chunk#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.chunk"
},
{
"name": "params",
"value": {
"audioBase64": "ACVKb5S53gM=",
"dictationId": "dictation-1",
"sampleRate": 16000
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-1",
"ok": true,
"result": {
"error": "inner refused",
"ok": false
}
}
}
},
"182109450de8": {
"name": "speech.dictation.chunk#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.chunk"
},
{
"name": "params",
"value": {
"audioBase64": "ACVKb5S53gM=",
"dictationId": "dictation-1",
"sampleRate": 16000
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-1",
"ok": true,
"result": {
"received": true
}
}
}
},
"19c961e90970": {
"name": "dictation-failed",
"ordinal": 3,
@@ -132,7 +59,11 @@
"id": "dictation-1"
}
},
"1ca8c123cb2d": {
"2762d465637e": {
"failures": ["Unknown method"],
"pending": 0
},
"30ae620f58dc": {
"name": "speech.dictation.chunk#1",
"ordinal": 1,
"args": [
@@ -151,7 +82,7 @@
{
"name": "options",
"value": {
"$rpc": "absent"
"timeoutMs": 30000
}
}
],
@@ -166,7 +97,7 @@
}
}
},
"1d0f84aa0730": {
"62cfcef21f01": {
"name": "speech.dictation.chunk#1",
"ordinal": 1,
"args": [
@@ -185,7 +116,40 @@
{
"name": "options",
"value": {
"$rpc": "absent"
"timeoutMs": 30000
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-1",
"ok": true
}
}
},
"66dece39b5ba": {
"name": "speech.dictation.chunk#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.chunk"
},
{
"name": "params",
"value": {
"audioBase64": "ACVKb5S53gM=",
"dictationId": "dictation-1",
"sampleRate": 16000
}
},
{
"name": "options",
"value": {
"timeoutMs": 30000
}
}
],
@@ -203,7 +167,7 @@
}
}
},
"208244a4b935": {
"83a5bdf2ac1a": {
"name": "speech.dictation.chunk#1",
"ordinal": 1,
"args": [
@@ -222,44 +186,7 @@
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"error": {
"code": "method_not_found",
"message": "Unknown method"
},
"id": "frame-1",
"ok": false
}
}
},
"25256657a918": {
"name": "speech.dictation.chunk#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.chunk"
},
{
"name": "params",
"value": {
"audioBase64": "ACVKb5S53gM=",
"dictationId": "dictation-1",
"sampleRate": 16000
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
"timeoutMs": 30000
}
}
],
@@ -269,19 +196,14 @@
"settledAt": 0,
"value": {
"id": "frame-1",
"ok": true
"ok": true,
"result": {
"error": "refused"
}
}
}
},
"2762d465637e": {
"failures": ["Unknown method"],
"pending": 0
},
"28366801162a": {
"failures": ["transport failure"],
"pending": 0
},
"88a268fa1b7b": {
"888a29b4fd97": {
"name": "speech.dictation.chunk#1",
"ordinal": 1,
"args": [
@@ -300,7 +222,80 @@
{
"name": "options",
"value": {
"$rpc": "absent"
"timeoutMs": 30000
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-1",
"ok": true,
"result": {
"error": "inner refused",
"ok": false
}
}
}
},
"99956eef8fff": {
"name": "speech.dictation.chunk#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.chunk"
},
{
"name": "params",
"value": {
"audioBase64": "ACVKb5S53gM=",
"dictationId": "dictation-1",
"sampleRate": 16000
}
},
{
"name": "options",
"value": {
"timeoutMs": 30000
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-1",
"ok": true,
"result": {
"received": true
}
}
}
},
"9d43743b1b10": {
"name": "speech.dictation.chunk#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.chunk"
},
{
"name": "params",
"value": {
"audioBase64": "ACVKb5S53gM=",
"dictationId": "dictation-1",
"sampleRate": 16000
}
},
{
"name": "options",
"value": {
"timeoutMs": 30000
}
}
],
@@ -315,83 +310,10 @@
}
}
},
"923922ffae75": {
"name": "speech.dictation.chunk#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.chunk"
},
{
"name": "params",
"value": {
"audioBase64": "ACVKb5S53gM=",
"dictationId": "dictation-1",
"sampleRate": 16000
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-1",
"ok": true,
"result": {
"$rpc": "null"
}
}
}
},
"a2f842e44f38": {
"failures": ["outer refused"],
"pending": 0
},
"a5db8d0cb481": {
"name": "speech.dictation.chunk#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.chunk"
},
{
"name": "params",
"value": {
"audioBase64": "ACVKb5S53gM=",
"dictationId": "dictation-1",
"sampleRate": 16000
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"error": {
"code": "refused",
"message": ""
},
"id": "frame-1",
"ok": false
}
}
},
"b5157118341f": {
"failures": [""],
"pending": 0
@@ -409,7 +331,7 @@
}
]
},
"e9501f2e1278": {
"da1579f3254f": {
"name": "speech.dictation.chunk#1",
"ordinal": 1,
"args": [
@@ -428,7 +350,81 @@
{
"name": "options",
"value": {
"$rpc": "absent"
"timeoutMs": 30000
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"error": {
"code": "method_not_found",
"message": "Unknown method"
},
"id": "frame-1",
"ok": false
}
}
},
"f6a8a6f550f5": {
"name": "speech.dictation.chunk#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.chunk"
},
{
"name": "params",
"value": {
"audioBase64": "ACVKb5S53gM=",
"dictationId": "dictation-1",
"sampleRate": 16000
}
},
{
"name": "options",
"value": {
"timeoutMs": 30000
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"error": {
"code": "refused",
"message": ""
},
"id": "frame-1",
"ok": false
}
}
},
"f779babe2a18": {
"name": "speech.dictation.chunk#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.chunk"
},
{
"name": "params",
"value": {
"audioBase64": "ACVKb5S53gM=",
"dictationId": "dictation-1",
"sampleRate": 16000
}
},
{
"name": "options",
"value": {
"timeoutMs": 30000
}
}
],
@@ -440,7 +436,7 @@
"id": "frame-1",
"ok": true,
"result": {
"error": "refused"
"$rpc": "null"
}
}
}
@@ -452,7 +448,7 @@
{
"id": "speech-audio-chunk-acknowledged.normal:acknowledged",
"observation": {
"sender": ["182109450de8"],
"sender": ["99956eef8fff"],
"payloads": ["12521d219054"],
"settlements": {
"chunk": "bc459c132276"
@@ -464,7 +460,7 @@
{
"id": "speech-audio-chunk-acknowledged.result-absent:acknowledged",
"observation": {
"sender": ["25256657a918"],
"sender": ["62cfcef21f01"],
"payloads": ["12521d219054"],
"settlements": {
"chunk": "bc459c132276"
@@ -476,7 +472,7 @@
{
"id": "speech-audio-chunk-acknowledged.result-null:acknowledged",
"observation": {
"sender": ["923922ffae75"],
"sender": ["f779babe2a18"],
"payloads": ["12521d219054"],
"settlements": {
"chunk": "bc459c132276"
@@ -488,7 +484,7 @@
{
"id": "speech-audio-chunk-acknowledged.inner-ok-missing:acknowledged",
"observation": {
"sender": ["e9501f2e1278"],
"sender": ["83a5bdf2ac1a"],
"payloads": ["12521d219054"],
"settlements": {
"chunk": "bc459c132276"
@@ -500,7 +496,7 @@
{
"id": "speech-audio-chunk-acknowledged.inner-false-string-error:acknowledged",
"observation": {
"sender": ["16da7db6669b"],
"sender": ["888a29b4fd97"],
"payloads": ["12521d219054"],
"settlements": {
"chunk": "bc459c132276"
@@ -512,7 +508,7 @@
{
"id": "speech-audio-chunk-acknowledged.inner-false-object-error:acknowledged",
"observation": {
"sender": ["0a3ec57e0321"],
"sender": ["028afac3649e"],
"payloads": ["12521d219054"],
"settlements": {
"chunk": "bc459c132276"
@@ -524,7 +520,7 @@
{
"id": "speech-audio-chunk-acknowledged.outer-refused:acknowledged",
"observation": {
"sender": ["1d0f84aa0730"],
"sender": ["66dece39b5ba"],
"payloads": ["12521d219054"],
"settlements": {
"chunk": "bc459c132276"
@@ -536,7 +532,7 @@
{
"id": "speech-audio-chunk-acknowledged.outer-refused-no-message:acknowledged",
"observation": {
"sender": ["a5db8d0cb481"],
"sender": ["f6a8a6f550f5"],
"payloads": ["12521d219054"],
"settlements": {
"chunk": "bc459c132276"
@@ -548,7 +544,7 @@
{
"id": "speech-audio-chunk-acknowledged.method-not-found:acknowledged",
"observation": {
"sender": ["208244a4b935"],
"sender": ["da1579f3254f"],
"payloads": ["12521d219054"],
"settlements": {
"chunk": "bc459c132276"
@@ -560,25 +556,25 @@
{
"id": "speech-audio-chunk-acknowledged.transport-rejection:acknowledged",
"observation": {
"sender": ["1ca8c123cb2d"],
"sender": ["30ae620f58dc"],
"payloads": ["12521d219054"],
"settlements": {
"chunk": "bc459c132276"
},
"state": "28366801162a",
"effects": ["19c961e90970"]
"state": "02cebe6f0062",
"effects": []
}
},
{
"id": "speech-audio-chunk-acknowledged.transport-rejection-no-message:acknowledged",
"observation": {
"sender": ["88a268fa1b7b"],
"sender": ["9d43743b1b10"],
"payloads": ["12521d219054"],
"settlements": {
"chunk": "bc459c132276"
},
"state": "b5157118341f",
"effects": ["19c961e90970"]
"state": "02cebe6f0062",
"effects": []
}
}
]
@@ -262,6 +262,32 @@
}
}
},
"893e5291c492": {
"name": "speech.dictation.cancel#1",
"ordinal": 5,
"args": [
{
"name": "method",
"value": "speech.dictation.cancel"
},
{
"name": "params",
"value": {
"dictationId": "mobile-dictation-1767225600000-8ig2henseon"
}
},
{
"name": "options",
"value": {
"failWhenDisconnected": true
}
}
],
"settlement": {
"status": "pending",
"startedAt": 0
}
},
"97af9f7912fc": {
"name": "dictation-error",
"ordinal": 6,
@@ -351,66 +377,6 @@
}
}
},
"a7dbb6b2864f": {
"name": "speech.dictation.cancel#1",
"ordinal": 5,
"args": [
{
"name": "method",
"value": "speech.dictation.cancel"
},
{
"name": "params",
"value": {
"dictationId": "mobile-dictation-1767225600000-8ig2henseon"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "pending",
"startedAt": 0
}
},
"aca401ac072e": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.start"
},
{
"name": "params",
"value": {
"dictationId": "mobile-dictation-1767225600000-8ig2henseon"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-1",
"ok": true,
"result": {
"started": true
}
}
}
},
"b41f74be4c3d": {
"name": "speech.dictation.finish#1",
"ordinal": 3,
@@ -458,6 +424,40 @@
"message": "Unknown method"
}
},
"bd004568e24c": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.start"
},
{
"name": "params",
"value": {
"dictationId": "mobile-dictation-1767225600000-8ig2henseon"
}
},
{
"name": "options",
"value": {
"timeoutMs": 30000
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-1",
"ok": true,
"result": {
"started": true
}
}
}
},
"cd49b339a081": {
"name": "dictation-error",
"ordinal": 6,
@@ -559,7 +559,7 @@
{
"id": "speech-dictation-session-transcript.normal:transcribed",
"observation": {
"sender": ["aca401ac072e", "4728ad10f92b"],
"sender": ["bd004568e24c", "4728ad10f92b"],
"payloads": ["859d310e63d6", "1bdc94d60271"],
"settlements": {
"mount": "eb79a9b3682a",
@@ -573,7 +573,7 @@
{
"id": "speech-dictation-session-transcript.result-absent:transcribed",
"observation": {
"sender": ["aca401ac072e", "88f795a14a21", "a7dbb6b2864f"],
"sender": ["bd004568e24c", "88f795a14a21", "893e5291c492"],
"payloads": ["859d310e63d6", "1bdc94d60271", "6c3d0754a1ff"],
"settlements": {
"mount": "eb79a9b3682a",
@@ -587,7 +587,7 @@
{
"id": "speech-dictation-session-transcript.result-null:transcribed",
"observation": {
"sender": ["aca401ac072e", "349e2cfd1a1f", "a7dbb6b2864f"],
"sender": ["bd004568e24c", "349e2cfd1a1f", "893e5291c492"],
"payloads": ["859d310e63d6", "1bdc94d60271", "6c3d0754a1ff"],
"settlements": {
"mount": "eb79a9b3682a",
@@ -601,7 +601,7 @@
{
"id": "speech-dictation-session-transcript.inner-ok-missing:transcribed",
"observation": {
"sender": ["aca401ac072e", "07d3e2ed39d8"],
"sender": ["bd004568e24c", "07d3e2ed39d8"],
"payloads": ["859d310e63d6", "1bdc94d60271"],
"settlements": {
"mount": "eb79a9b3682a",
@@ -615,7 +615,7 @@
{
"id": "speech-dictation-session-transcript.inner-false-string-error:transcribed",
"observation": {
"sender": ["aca401ac072e", "f4cf5aad78c0"],
"sender": ["bd004568e24c", "f4cf5aad78c0"],
"payloads": ["859d310e63d6", "1bdc94d60271"],
"settlements": {
"mount": "eb79a9b3682a",
@@ -629,7 +629,7 @@
{
"id": "speech-dictation-session-transcript.inner-false-object-error:transcribed",
"observation": {
"sender": ["aca401ac072e", "74617082bf69"],
"sender": ["bd004568e24c", "74617082bf69"],
"payloads": ["859d310e63d6", "1bdc94d60271"],
"settlements": {
"mount": "eb79a9b3682a",
@@ -643,7 +643,7 @@
{
"id": "speech-dictation-session-transcript.outer-refused:transcribed",
"observation": {
"sender": ["aca401ac072e", "a46c0f6c95b2", "a7dbb6b2864f"],
"sender": ["bd004568e24c", "a46c0f6c95b2", "893e5291c492"],
"payloads": ["859d310e63d6", "1bdc94d60271", "6c3d0754a1ff"],
"settlements": {
"mount": "eb79a9b3682a",
@@ -657,7 +657,7 @@
{
"id": "speech-dictation-session-transcript.outer-refused-no-message:transcribed",
"observation": {
"sender": ["aca401ac072e", "9fce1a80008e", "a7dbb6b2864f"],
"sender": ["bd004568e24c", "9fce1a80008e", "893e5291c492"],
"payloads": ["859d310e63d6", "1bdc94d60271", "6c3d0754a1ff"],
"settlements": {
"mount": "eb79a9b3682a",
@@ -671,7 +671,7 @@
{
"id": "speech-dictation-session-transcript.method-not-found:transcribed",
"observation": {
"sender": ["aca401ac072e", "b41f74be4c3d", "a7dbb6b2864f"],
"sender": ["bd004568e24c", "b41f74be4c3d", "893e5291c492"],
"payloads": ["859d310e63d6", "1bdc94d60271", "6c3d0754a1ff"],
"settlements": {
"mount": "eb79a9b3682a",
@@ -685,7 +685,7 @@
{
"id": "speech-dictation-session-transcript.transport-rejection:transcribed",
"observation": {
"sender": ["aca401ac072e", "cf10282da8e0", "a7dbb6b2864f"],
"sender": ["bd004568e24c", "cf10282da8e0", "893e5291c492"],
"payloads": ["859d310e63d6", "1bdc94d60271", "6c3d0754a1ff"],
"settlements": {
"mount": "eb79a9b3682a",
@@ -699,7 +699,7 @@
{
"id": "speech-dictation-session-transcript.transport-rejection-no-message:transcribed",
"observation": {
"sender": ["aca401ac072e", "6d6bb34ce2ab", "a7dbb6b2864f"],
"sender": ["bd004568e24c", "6d6bb34ce2ab", "893e5291c492"],
"payloads": ["859d310e63d6", "1bdc94d60271", "6c3d0754a1ff"],
"settlements": {
"mount": "eb79a9b3682a",
@@ -4,12 +4,7 @@
"family": "speech.dictation-session",
"namedDeltas": [],
"values": {
"1bdc94d60271": {
"name": "speech.dictation.finish#1",
"ordinal": 4,
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"speech.dictation.finish\",\"params\":{\"dictationId\":\"mobile-dictation-1767225600000-8ig2henseon\"}}"
},
"2104f46e44bd": {
"031fac8ed0c3": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
@@ -26,28 +21,31 @@
{
"name": "options",
"value": {
"$rpc": "absent"
"timeoutMs": 30000
}
}
],
"settlement": {
"status": "rejected",
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"error": {
"category": "Error",
"message": "",
"isRpcDeliveryUnknown": true
"value": {
"id": "frame-1",
"ok": true,
"result": {
"error": "inner refused",
"ok": false
}
}
}
},
"355b3eaef1fb": {
"name": "speech.dictation.cancel#1",
"ordinal": 3,
"062d9c83659b": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.cancel"
"value": "speech.dictation.start"
},
{
"name": "params",
@@ -58,15 +56,92 @@
{
"name": "options",
"value": {
"$rpc": "absent"
"timeoutMs": 30000
}
}
],
"settlement": {
"status": "pending",
"startedAt": 0
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"error": {
"code": "method_not_found",
"message": "Unknown method"
},
"id": "frame-1",
"ok": false
}
}
},
"104a53ec5972": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.start"
},
{
"name": "params",
"value": {
"dictationId": "mobile-dictation-1767225600000-8ig2henseon"
}
},
{
"name": "options",
"value": {
"timeoutMs": 30000
}
}
],
"settlement": {
"status": "rejected",
"startedAt": 0,
"settledAt": 0,
"error": {
"category": "Error",
"message": "transport failure",
"isRpcDeliveryUnknown": true
}
}
},
"136c60a2a62f": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.start"
},
{
"name": "params",
"value": {
"dictationId": "mobile-dictation-1767225600000-8ig2henseon"
}
},
{
"name": "options",
"value": {
"timeoutMs": 30000
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-1",
"ok": true
}
}
},
"1bdc94d60271": {
"name": "speech.dictation.finish#1",
"ordinal": 4,
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"speech.dictation.finish\",\"params\":{\"dictationId\":\"mobile-dictation-1767225600000-8ig2henseon\"}}"
},
"4728ad10f92b": {
"name": "speech.dictation.finish#1",
"ordinal": 3,
@@ -101,7 +176,7 @@
}
}
},
"5655ac21ce64": {
"6d8188e86d2a": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
@@ -118,7 +193,219 @@
{
"name": "options",
"value": {
"$rpc": "absent"
"timeoutMs": 30000
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"error": {
"code": "refused",
"message": ""
},
"id": "frame-1",
"ok": false
}
}
},
"859d310e63d6": {
"name": "speech.dictation.start#1",
"ordinal": 2,
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"speech.dictation.start\",\"params\":{\"dictationId\":\"mobile-dictation-1767225600000-8ig2henseon\"}}"
},
"85d47816bfc5": {
"name": "speech.dictation.cancel#1",
"ordinal": 3,
"args": [
{
"name": "method",
"value": "speech.dictation.cancel"
},
{
"name": "params",
"value": {
"dictationId": "mobile-dictation-1767225600000-8ig2henseon"
}
},
{
"name": "options",
"value": {
"failWhenDisconnected": true
}
}
],
"settlement": {
"status": "pending",
"startedAt": 0
}
},
"9270aeb7d9c6": {
"status": "pending",
"startedAt": 0
},
"a19279fc9c65": {
"error": {
"$rpc": "null"
},
"status": "idle",
"transcripts": ["hello world"]
},
"a8dc23386147": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.start"
},
{
"name": "params",
"value": {
"dictationId": "mobile-dictation-1767225600000-8ig2henseon"
}
},
{
"name": "options",
"value": {
"timeoutMs": 30000
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-1",
"ok": true,
"result": {
"error": "refused"
}
}
}
},
"b7421e659b44": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.start"
},
{
"name": "params",
"value": {
"dictationId": "mobile-dictation-1767225600000-8ig2henseon"
}
},
{
"name": "options",
"value": {
"timeoutMs": 30000
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"error": {
"code": "refused",
"message": "outer refused"
},
"id": "frame-1",
"ok": false
}
}
},
"bd004568e24c": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.start"
},
{
"name": "params",
"value": {
"dictationId": "mobile-dictation-1767225600000-8ig2henseon"
}
},
{
"name": "options",
"value": {
"timeoutMs": 30000
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-1",
"ok": true,
"result": {
"started": true
}
}
}
},
"c9d7f3d355eb": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.start"
},
{
"name": "params",
"value": {
"dictationId": "mobile-dictation-1767225600000-8ig2henseon"
}
},
{
"name": "options",
"value": {
"timeoutMs": 30000
}
}
],
"settlement": {
"status": "rejected",
"startedAt": 0,
"settledAt": 0,
"error": {
"category": "Error",
"message": "",
"isRpcDeliveryUnknown": true
}
}
},
"e05190caf800": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.start"
},
{
"name": "params",
"value": {
"dictationId": "mobile-dictation-1767225600000-8ig2henseon"
}
},
{
"name": "options",
"value": {
"timeoutMs": 30000
}
}
],
@@ -138,224 +425,6 @@
}
}
},
"60ac03a80551": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.start"
},
{
"name": "params",
"value": {
"dictationId": "mobile-dictation-1767225600000-8ig2henseon"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"error": {
"code": "refused",
"message": "outer refused"
},
"id": "frame-1",
"ok": false
}
}
},
"624d174d4477": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.start"
},
{
"name": "params",
"value": {
"dictationId": "mobile-dictation-1767225600000-8ig2henseon"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "rejected",
"startedAt": 0,
"settledAt": 0,
"error": {
"category": "Error",
"message": "transport failure",
"isRpcDeliveryUnknown": true
}
}
},
"68f011596870": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.start"
},
{
"name": "params",
"value": {
"dictationId": "mobile-dictation-1767225600000-8ig2henseon"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-1",
"ok": true
}
}
},
"859d310e63d6": {
"name": "speech.dictation.start#1",
"ordinal": 2,
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"speech.dictation.start\",\"params\":{\"dictationId\":\"mobile-dictation-1767225600000-8ig2henseon\"}}"
},
"9270aeb7d9c6": {
"status": "pending",
"startedAt": 0
},
"a19279fc9c65": {
"error": {
"$rpc": "null"
},
"status": "idle",
"transcripts": ["hello world"]
},
"aca401ac072e": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.start"
},
{
"name": "params",
"value": {
"dictationId": "mobile-dictation-1767225600000-8ig2henseon"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-1",
"ok": true,
"result": {
"started": true
}
}
}
},
"b05828b4ea8e": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.start"
},
{
"name": "params",
"value": {
"dictationId": "mobile-dictation-1767225600000-8ig2henseon"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"error": {
"code": "refused",
"message": ""
},
"id": "frame-1",
"ok": false
}
}
},
"baf7ef332fbf": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.start"
},
{
"name": "params",
"value": {
"dictationId": "mobile-dictation-1767225600000-8ig2henseon"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-1",
"ok": true,
"result": {
"error": "inner refused",
"ok": false
}
}
}
},
"eb79a9b3682a": {
"status": "fulfilled",
"startedAt": 0,
@@ -369,7 +438,14 @@
"ordinal": 4,
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"speech.dictation.cancel\",\"params\":{\"dictationId\":\"mobile-dictation-1767225600000-8ig2henseon\"}}"
},
"eebe8f57e84a": {
"f6a74c428142": {
"error": {
"$rpc": "null"
},
"status": "starting",
"transcripts": []
},
"fb132fa96492": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
@@ -386,7 +462,7 @@
{
"name": "options",
"value": {
"$rpc": "absent"
"timeoutMs": 30000
}
}
],
@@ -402,82 +478,6 @@
}
}
}
},
"f6a74c428142": {
"error": {
"$rpc": "null"
},
"status": "starting",
"transcripts": []
},
"fa7299197bf0": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.start"
},
{
"name": "params",
"value": {
"dictationId": "mobile-dictation-1767225600000-8ig2henseon"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-1",
"ok": true,
"result": {
"error": "refused"
}
}
}
},
"fcb906b12a66": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.start"
},
{
"name": "params",
"value": {
"dictationId": "mobile-dictation-1767225600000-8ig2henseon"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"error": {
"code": "method_not_found",
"message": "Unknown method"
},
"id": "frame-1",
"ok": false
}
}
}
},
"recording": {
@@ -486,7 +486,7 @@
{
"id": "speech-dictation-session-transcript.normal:transcribed",
"observation": {
"sender": ["aca401ac072e", "4728ad10f92b"],
"sender": ["bd004568e24c", "4728ad10f92b"],
"payloads": ["859d310e63d6", "1bdc94d60271"],
"settlements": {
"mount": "eb79a9b3682a",
@@ -500,7 +500,7 @@
{
"id": "speech-dictation-session-transcript.result-absent:transcribed",
"observation": {
"sender": ["68f011596870", "4728ad10f92b"],
"sender": ["136c60a2a62f", "4728ad10f92b"],
"payloads": ["859d310e63d6", "1bdc94d60271"],
"settlements": {
"mount": "eb79a9b3682a",
@@ -514,7 +514,7 @@
{
"id": "speech-dictation-session-transcript.result-null:transcribed",
"observation": {
"sender": ["eebe8f57e84a", "4728ad10f92b"],
"sender": ["fb132fa96492", "4728ad10f92b"],
"payloads": ["859d310e63d6", "1bdc94d60271"],
"settlements": {
"mount": "eb79a9b3682a",
@@ -528,7 +528,7 @@
{
"id": "speech-dictation-session-transcript.inner-ok-missing:transcribed",
"observation": {
"sender": ["fa7299197bf0", "4728ad10f92b"],
"sender": ["a8dc23386147", "4728ad10f92b"],
"payloads": ["859d310e63d6", "1bdc94d60271"],
"settlements": {
"mount": "eb79a9b3682a",
@@ -542,7 +542,7 @@
{
"id": "speech-dictation-session-transcript.inner-false-string-error:transcribed",
"observation": {
"sender": ["baf7ef332fbf", "4728ad10f92b"],
"sender": ["031fac8ed0c3", "4728ad10f92b"],
"payloads": ["859d310e63d6", "1bdc94d60271"],
"settlements": {
"mount": "eb79a9b3682a",
@@ -556,7 +556,7 @@
{
"id": "speech-dictation-session-transcript.inner-false-object-error:transcribed",
"observation": {
"sender": ["5655ac21ce64", "4728ad10f92b"],
"sender": ["e05190caf800", "4728ad10f92b"],
"payloads": ["859d310e63d6", "1bdc94d60271"],
"settlements": {
"mount": "eb79a9b3682a",
@@ -570,7 +570,7 @@
{
"id": "speech-dictation-session-transcript.outer-refused:transcribed",
"observation": {
"sender": ["60ac03a80551", "355b3eaef1fb"],
"sender": ["b7421e659b44", "85d47816bfc5"],
"payloads": ["859d310e63d6", "ebdac948a90d"],
"settlements": {
"mount": "eb79a9b3682a",
@@ -584,7 +584,7 @@
{
"id": "speech-dictation-session-transcript.outer-refused-no-message:transcribed",
"observation": {
"sender": ["b05828b4ea8e", "355b3eaef1fb"],
"sender": ["6d8188e86d2a", "85d47816bfc5"],
"payloads": ["859d310e63d6", "ebdac948a90d"],
"settlements": {
"mount": "eb79a9b3682a",
@@ -598,7 +598,7 @@
{
"id": "speech-dictation-session-transcript.method-not-found:transcribed",
"observation": {
"sender": ["fcb906b12a66", "355b3eaef1fb"],
"sender": ["062d9c83659b", "85d47816bfc5"],
"payloads": ["859d310e63d6", "ebdac948a90d"],
"settlements": {
"mount": "eb79a9b3682a",
@@ -612,7 +612,7 @@
{
"id": "speech-dictation-session-transcript.transport-rejection:transcribed",
"observation": {
"sender": ["624d174d4477", "355b3eaef1fb"],
"sender": ["104a53ec5972", "85d47816bfc5"],
"payloads": ["859d310e63d6", "ebdac948a90d"],
"settlements": {
"mount": "eb79a9b3682a",
@@ -626,7 +626,7 @@
{
"id": "speech-dictation-session-transcript.transport-rejection-no-message:transcribed",
"observation": {
"sender": ["2104f46e44bd", "355b3eaef1fb"],
"sender": ["c9d7f3d355eb", "85d47816bfc5"],
"payloads": ["859d310e63d6", "ebdac948a90d"],
"settlements": {
"mount": "eb79a9b3682a",
@@ -4,7 +4,7 @@
"family": "speech.dictation-start",
"namedDeltas": [],
"values": {
"02d3c28ecfb6": {
"0b8b17cc7b58": {
"name": "speech.dictation.cancel#1",
"ordinal": 3,
"args": [
@@ -21,176 +21,7 @@
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-2",
"ok": true,
"result": {
"$rpc": "null"
}
}
}
},
"1710d94f25d0": {
"name": "speech.dictation.cancel#1",
"ordinal": 3,
"args": [
{
"name": "method",
"value": "speech.dictation.cancel"
},
{
"name": "params",
"value": {
"dictationId": "dictation-1"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-2",
"ok": true,
"result": {
"error": "inner refused",
"ok": false
}
}
}
},
"25f8db2d01b6": {
"name": "speech.dictation.cancel#1",
"ordinal": 3,
"args": [
{
"name": "method",
"value": "speech.dictation.cancel"
},
{
"name": "params",
"value": {
"dictationId": "dictation-1"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "rejected",
"startedAt": 0,
"settledAt": 0,
"error": {
"category": "Error",
"message": "",
"isRpcDeliveryUnknown": true
}
}
},
"3b5708754539": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.start"
},
{
"name": "params",
"value": {
"dictationId": "dictation-1"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-1",
"ok": true,
"result": {
"started": true
}
}
}
},
"46564194b0d9": {
"name": "speech.dictation.cancel#1",
"ordinal": 3,
"args": [
{
"name": "method",
"value": "speech.dictation.cancel"
},
{
"name": "params",
"value": {
"dictationId": "dictation-1"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-2",
"ok": true,
"result": {
"error": "refused"
}
}
}
},
"5f51c1b28c86": {
"name": "speech.dictation.cancel#1",
"ordinal": 3,
"args": [
{
"name": "method",
"value": "speech.dictation.cancel"
},
{
"name": "params",
"value": {
"dictationId": "dictation-1"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
"failWhenDisconnected": true
}
}
],
@@ -208,7 +39,7 @@
}
}
},
"73faa32df517": {
"2e3b79ca68ce": {
"name": "speech.dictation.cancel#1",
"ordinal": 3,
"args": [
@@ -225,7 +56,39 @@
{
"name": "options",
"value": {
"$rpc": "absent"
"failWhenDisconnected": true
}
}
],
"settlement": {
"status": "rejected",
"startedAt": 0,
"settledAt": 0,
"error": {
"category": "Error",
"message": "",
"isRpcDeliveryUnknown": true
}
}
},
"32caef6aa162": {
"name": "speech.dictation.cancel#1",
"ordinal": 3,
"args": [
{
"name": "method",
"value": "speech.dictation.cancel"
},
{
"name": "params",
"value": {
"dictationId": "dictation-1"
}
},
{
"name": "options",
"value": {
"failWhenDisconnected": true
}
}
],
@@ -235,20 +98,45 @@
"settledAt": 0,
"value": {
"id": "frame-2",
"ok": true
}
}
},
"4c42677aca21": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.start"
},
{
"name": "params",
"value": {
"dictationId": "dictation-1"
}
},
{
"name": "options",
"value": {
"timeoutMs": 30000
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-1",
"ok": true,
"result": {
"cancelled": true
"started": true
}
}
}
},
"7ed3d39f0607": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": false
},
"927c61fd29b5": {
"5699810cc2a2": {
"name": "speech.dictation.cancel#1",
"ordinal": 3,
"args": [
@@ -265,47 +153,7 @@
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"error": {
"code": "method_not_found",
"message": "Unknown method"
},
"id": "frame-2",
"ok": false
}
}
},
"a7ddd021010a": {
"name": "speech.dictation.start#1",
"ordinal": 2,
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"speech.dictation.start\",\"params\":{\"dictationId\":\"dictation-1\"}}"
},
"cc661ef3e67e": {
"name": "speech.dictation.cancel#1",
"ordinal": 3,
"args": [
{
"name": "method",
"value": "speech.dictation.cancel"
},
{
"name": "params",
"value": {
"dictationId": "dictation-1"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
"failWhenDisconnected": true
}
}
],
@@ -325,12 +173,7 @@
}
}
},
"ce9cbc8837fa": {
"name": "speech.dictation.cancel#1",
"ordinal": 4,
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"speech.dictation.cancel\",\"params\":{\"dictationId\":\"dictation-1\"}}"
},
"dbc1fdbc29d3": {
"6f5a48ebce0e": {
"name": "speech.dictation.cancel#1",
"ordinal": 3,
"args": [
@@ -347,7 +190,121 @@
{
"name": "options",
"value": {
"$rpc": "absent"
"failWhenDisconnected": true
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-2",
"ok": true,
"result": {
"$rpc": "null"
}
}
}
},
"7ed3d39f0607": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": false
},
"9ab91ee194b8": {
"name": "speech.dictation.cancel#1",
"ordinal": 3,
"args": [
{
"name": "method",
"value": "speech.dictation.cancel"
},
{
"name": "params",
"value": {
"dictationId": "dictation-1"
}
},
{
"name": "options",
"value": {
"failWhenDisconnected": true
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-2",
"ok": true,
"result": {
"cancelled": true
}
}
}
},
"a7ddd021010a": {
"name": "speech.dictation.start#1",
"ordinal": 2,
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"speech.dictation.start\",\"params\":{\"dictationId\":\"dictation-1\"}}"
},
"b20560e60f90": {
"name": "speech.dictation.cancel#1",
"ordinal": 3,
"args": [
{
"name": "method",
"value": "speech.dictation.cancel"
},
{
"name": "params",
"value": {
"dictationId": "dictation-1"
}
},
{
"name": "options",
"value": {
"failWhenDisconnected": true
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"error": {
"code": "method_not_found",
"message": "Unknown method"
},
"id": "frame-2",
"ok": false
}
}
},
"b90df4b1e290": {
"name": "speech.dictation.cancel#1",
"ordinal": 3,
"args": [
{
"name": "method",
"value": "speech.dictation.cancel"
},
{
"name": "params",
"value": {
"dictationId": "dictation-1"
}
},
{
"name": "options",
"value": {
"failWhenDisconnected": true
}
}
],
@@ -365,14 +322,12 @@
}
}
},
"e0fcd8f8c1a9": {
"activeId": {
"$rpc": "null"
},
"idle": false,
"started": false
"ce9cbc8837fa": {
"name": "speech.dictation.cancel#1",
"ordinal": 4,
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"speech.dictation.cancel\",\"params\":{\"dictationId\":\"dictation-1\"}}"
},
"e59c2b1cff33": {
"d0501bb1dd67": {
"name": "speech.dictation.cancel#1",
"ordinal": 3,
"args": [
@@ -389,7 +344,7 @@
{
"name": "options",
"value": {
"$rpc": "absent"
"failWhenDisconnected": true
}
}
],
@@ -399,19 +354,15 @@
"settledAt": 0,
"value": {
"id": "frame-2",
"ok": true
"ok": true,
"result": {
"error": "inner refused",
"ok": false
}
}
}
},
"eb79a9b3682a": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"$rpc": "undefined"
}
},
"f3ac8ed5616e": {
"d80561ce52f2": {
"name": "speech.dictation.cancel#1",
"ordinal": 3,
"args": [
@@ -428,7 +379,7 @@
{
"name": "options",
"value": {
"$rpc": "absent"
"failWhenDisconnected": true
}
}
],
@@ -442,6 +393,55 @@
"isRpcDeliveryUnknown": true
}
}
},
"df38de088787": {
"name": "speech.dictation.cancel#1",
"ordinal": 3,
"args": [
{
"name": "method",
"value": "speech.dictation.cancel"
},
{
"name": "params",
"value": {
"dictationId": "dictation-1"
}
},
{
"name": "options",
"value": {
"failWhenDisconnected": true
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-2",
"ok": true,
"result": {
"error": "refused"
}
}
}
},
"e0fcd8f8c1a9": {
"activeId": {
"$rpc": "null"
},
"idle": false,
"started": false
},
"eb79a9b3682a": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"$rpc": "undefined"
}
}
},
"recording": {
@@ -450,7 +450,7 @@
{
"id": "speech-desktop-start-superseded.normal:stale-start-cancelled",
"observation": {
"sender": ["3b5708754539", "73faa32df517"],
"sender": ["4c42677aca21", "9ab91ee194b8"],
"payloads": ["a7ddd021010a", "ce9cbc8837fa"],
"settlements": {
"supersede": "eb79a9b3682a",
@@ -463,7 +463,7 @@
{
"id": "speech-desktop-start-superseded.result-absent:stale-start-cancelled",
"observation": {
"sender": ["3b5708754539", "e59c2b1cff33"],
"sender": ["4c42677aca21", "32caef6aa162"],
"payloads": ["a7ddd021010a", "ce9cbc8837fa"],
"settlements": {
"supersede": "eb79a9b3682a",
@@ -476,7 +476,7 @@
{
"id": "speech-desktop-start-superseded.result-null:stale-start-cancelled",
"observation": {
"sender": ["3b5708754539", "02d3c28ecfb6"],
"sender": ["4c42677aca21", "6f5a48ebce0e"],
"payloads": ["a7ddd021010a", "ce9cbc8837fa"],
"settlements": {
"supersede": "eb79a9b3682a",
@@ -489,7 +489,7 @@
{
"id": "speech-desktop-start-superseded.inner-ok-missing:stale-start-cancelled",
"observation": {
"sender": ["3b5708754539", "46564194b0d9"],
"sender": ["4c42677aca21", "df38de088787"],
"payloads": ["a7ddd021010a", "ce9cbc8837fa"],
"settlements": {
"supersede": "eb79a9b3682a",
@@ -502,7 +502,7 @@
{
"id": "speech-desktop-start-superseded.inner-false-string-error:stale-start-cancelled",
"observation": {
"sender": ["3b5708754539", "1710d94f25d0"],
"sender": ["4c42677aca21", "d0501bb1dd67"],
"payloads": ["a7ddd021010a", "ce9cbc8837fa"],
"settlements": {
"supersede": "eb79a9b3682a",
@@ -515,7 +515,7 @@
{
"id": "speech-desktop-start-superseded.inner-false-object-error:stale-start-cancelled",
"observation": {
"sender": ["3b5708754539", "cc661ef3e67e"],
"sender": ["4c42677aca21", "5699810cc2a2"],
"payloads": ["a7ddd021010a", "ce9cbc8837fa"],
"settlements": {
"supersede": "eb79a9b3682a",
@@ -528,7 +528,7 @@
{
"id": "speech-desktop-start-superseded.outer-refused:stale-start-cancelled",
"observation": {
"sender": ["3b5708754539", "5f51c1b28c86"],
"sender": ["4c42677aca21", "0b8b17cc7b58"],
"payloads": ["a7ddd021010a", "ce9cbc8837fa"],
"settlements": {
"supersede": "eb79a9b3682a",
@@ -541,7 +541,7 @@
{
"id": "speech-desktop-start-superseded.outer-refused-no-message:stale-start-cancelled",
"observation": {
"sender": ["3b5708754539", "dbc1fdbc29d3"],
"sender": ["4c42677aca21", "b90df4b1e290"],
"payloads": ["a7ddd021010a", "ce9cbc8837fa"],
"settlements": {
"supersede": "eb79a9b3682a",
@@ -554,7 +554,7 @@
{
"id": "speech-desktop-start-superseded.method-not-found:stale-start-cancelled",
"observation": {
"sender": ["3b5708754539", "927c61fd29b5"],
"sender": ["4c42677aca21", "b20560e60f90"],
"payloads": ["a7ddd021010a", "ce9cbc8837fa"],
"settlements": {
"supersede": "eb79a9b3682a",
@@ -567,7 +567,7 @@
{
"id": "speech-desktop-start-superseded.transport-rejection:stale-start-cancelled",
"observation": {
"sender": ["3b5708754539", "f3ac8ed5616e"],
"sender": ["4c42677aca21", "d80561ce52f2"],
"payloads": ["a7ddd021010a", "ce9cbc8837fa"],
"settlements": {
"supersede": "eb79a9b3682a",
@@ -580,7 +580,7 @@
{
"id": "speech-desktop-start-superseded.transport-rejection-no-message:stale-start-cancelled",
"observation": {
"sender": ["3b5708754539", "25f8db2d01b6"],
"sender": ["4c42677aca21", "2e3b79ca68ce"],
"payloads": ["a7ddd021010a", "ce9cbc8837fa"],
"settlements": {
"supersede": "eb79a9b3682a",
@@ -4,7 +4,7 @@
"family": "speech.dictation-start",
"namedDeltas": [],
"values": {
"00ab3d9734bb": {
"02e3f6a398b9": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
@@ -21,354 +21,7 @@
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-1",
"ok": true
}
}
},
"0ec7faed0e3d": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.start"
},
{
"name": "params",
"value": {
"dictationId": "dictation-1"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "rejected",
"startedAt": 0,
"settledAt": 0,
"error": {
"category": "Error",
"message": "transport failure",
"isRpcDeliveryUnknown": true
}
}
},
"38b3ced9bf9c": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.start"
},
{
"name": "params",
"value": {
"dictationId": "dictation-1"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-1",
"ok": true,
"result": {
"error": "refused"
}
}
}
},
"3b5708754539": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.start"
},
{
"name": "params",
"value": {
"dictationId": "dictation-1"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-1",
"ok": true,
"result": {
"started": true
}
}
}
},
"3c4f020193c4": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.start"
},
{
"name": "params",
"value": {
"dictationId": "dictation-1"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"error": {
"code": "method_not_found",
"message": "Unknown method"
},
"id": "frame-1",
"ok": false
}
}
},
"50620ee89249": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.start"
},
{
"name": "params",
"value": {
"dictationId": "dictation-1"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "rejected",
"startedAt": 0,
"settledAt": 0,
"error": {
"category": "Error",
"message": "",
"isRpcDeliveryUnknown": true
}
}
},
"55019d971687": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.start"
},
{
"name": "params",
"value": {
"dictationId": "dictation-1"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-1",
"ok": true,
"result": {
"error": "inner refused",
"ok": false
}
}
}
},
"5bd3d1bd4863": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.start"
},
{
"name": "params",
"value": {
"dictationId": "dictation-1"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"error": {
"code": "refused",
"message": "outer refused"
},
"id": "frame-1",
"ok": false
}
}
},
"73faa32df517": {
"name": "speech.dictation.cancel#1",
"ordinal": 3,
"args": [
{
"name": "method",
"value": "speech.dictation.cancel"
},
{
"name": "params",
"value": {
"dictationId": "dictation-1"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-2",
"ok": true,
"result": {
"cancelled": true
}
}
}
},
"7ed3d39f0607": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": false
},
"a72f6bbdd0b3": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.start"
},
{
"name": "params",
"value": {
"dictationId": "dictation-1"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-1",
"ok": true,
"result": {
"$rpc": "null"
}
}
}
},
"a7ddd021010a": {
"name": "speech.dictation.start#1",
"ordinal": 2,
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"speech.dictation.start\",\"params\":{\"dictationId\":\"dictation-1\"}}"
},
"ba48be88195e": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.start"
},
{
"name": "params",
"value": {
"dictationId": "dictation-1"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
"timeoutMs": 30000
}
}
],
@@ -386,12 +39,7 @@
}
}
},
"ce9cbc8837fa": {
"name": "speech.dictation.cancel#1",
"ordinal": 4,
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"speech.dictation.cancel\",\"params\":{\"dictationId\":\"dictation-1\"}}"
},
"ceca67daa178": {
"0ac1d1294a4a": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
@@ -408,7 +56,224 @@
{
"name": "options",
"value": {
"$rpc": "absent"
"timeoutMs": 30000
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-1",
"ok": true,
"result": {
"$rpc": "null"
}
}
}
},
"106b709a0579": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.start"
},
{
"name": "params",
"value": {
"dictationId": "dictation-1"
}
},
{
"name": "options",
"value": {
"timeoutMs": 30000
}
}
],
"settlement": {
"status": "rejected",
"startedAt": 0,
"settledAt": 0,
"error": {
"category": "Error",
"message": "",
"isRpcDeliveryUnknown": true
}
}
},
"4c42677aca21": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.start"
},
{
"name": "params",
"value": {
"dictationId": "dictation-1"
}
},
{
"name": "options",
"value": {
"timeoutMs": 30000
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-1",
"ok": true,
"result": {
"started": true
}
}
}
},
"7ed3d39f0607": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": false
},
"9ab91ee194b8": {
"name": "speech.dictation.cancel#1",
"ordinal": 3,
"args": [
{
"name": "method",
"value": "speech.dictation.cancel"
},
{
"name": "params",
"value": {
"dictationId": "dictation-1"
}
},
{
"name": "options",
"value": {
"failWhenDisconnected": true
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-2",
"ok": true,
"result": {
"cancelled": true
}
}
}
},
"9c6249e03654": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.start"
},
{
"name": "params",
"value": {
"dictationId": "dictation-1"
}
},
{
"name": "options",
"value": {
"timeoutMs": 30000
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"error": {
"code": "refused",
"message": "outer refused"
},
"id": "frame-1",
"ok": false
}
}
},
"a1030fe3abd4": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.start"
},
{
"name": "params",
"value": {
"dictationId": "dictation-1"
}
},
{
"name": "options",
"value": {
"timeoutMs": 30000
}
}
],
"settlement": {
"status": "rejected",
"startedAt": 0,
"settledAt": 0,
"error": {
"category": "Error",
"message": "transport failure",
"isRpcDeliveryUnknown": true
}
}
},
"a7ddd021010a": {
"name": "speech.dictation.start#1",
"ordinal": 2,
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"speech.dictation.start\",\"params\":{\"dictationId\":\"dictation-1\"}}"
},
"ce9cbc8837fa": {
"name": "speech.dictation.cancel#1",
"ordinal": 4,
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"speech.dictation.cancel\",\"params\":{\"dictationId\":\"dictation-1\"}}"
},
"ced9bd128f52": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.start"
},
{
"name": "params",
"value": {
"dictationId": "dictation-1"
}
},
{
"name": "options",
"value": {
"timeoutMs": 30000
}
}
],
@@ -442,6 +307,141 @@
"value": {
"$rpc": "undefined"
}
},
"f010ef5b6bfc": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.start"
},
{
"name": "params",
"value": {
"dictationId": "dictation-1"
}
},
{
"name": "options",
"value": {
"timeoutMs": 30000
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-1",
"ok": true,
"result": {
"error": "refused"
}
}
}
},
"f2166355786a": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.start"
},
{
"name": "params",
"value": {
"dictationId": "dictation-1"
}
},
{
"name": "options",
"value": {
"timeoutMs": 30000
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"error": {
"code": "method_not_found",
"message": "Unknown method"
},
"id": "frame-1",
"ok": false
}
}
},
"f2eeb517059d": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.start"
},
{
"name": "params",
"value": {
"dictationId": "dictation-1"
}
},
{
"name": "options",
"value": {
"timeoutMs": 30000
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-1",
"ok": true,
"result": {
"error": "inner refused",
"ok": false
}
}
}
},
"fe8f7e77754e": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
{
"name": "method",
"value": "speech.dictation.start"
},
{
"name": "params",
"value": {
"dictationId": "dictation-1"
}
},
{
"name": "options",
"value": {
"timeoutMs": 30000
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-1",
"ok": true
}
}
}
},
"recording": {
@@ -450,7 +450,7 @@
{
"id": "speech-desktop-start-superseded.normal:stale-start-cancelled",
"observation": {
"sender": ["3b5708754539", "73faa32df517"],
"sender": ["4c42677aca21", "9ab91ee194b8"],
"payloads": ["a7ddd021010a", "ce9cbc8837fa"],
"settlements": {
"supersede": "eb79a9b3682a",
@@ -463,7 +463,7 @@
{
"id": "speech-desktop-start-superseded.result-absent:stale-start-cancelled",
"observation": {
"sender": ["00ab3d9734bb", "73faa32df517"],
"sender": ["fe8f7e77754e", "9ab91ee194b8"],
"payloads": ["a7ddd021010a", "ce9cbc8837fa"],
"settlements": {
"supersede": "eb79a9b3682a",
@@ -476,7 +476,7 @@
{
"id": "speech-desktop-start-superseded.result-null:stale-start-cancelled",
"observation": {
"sender": ["a72f6bbdd0b3", "73faa32df517"],
"sender": ["0ac1d1294a4a", "9ab91ee194b8"],
"payloads": ["a7ddd021010a", "ce9cbc8837fa"],
"settlements": {
"supersede": "eb79a9b3682a",
@@ -489,7 +489,7 @@
{
"id": "speech-desktop-start-superseded.inner-ok-missing:stale-start-cancelled",
"observation": {
"sender": ["38b3ced9bf9c", "73faa32df517"],
"sender": ["f010ef5b6bfc", "9ab91ee194b8"],
"payloads": ["a7ddd021010a", "ce9cbc8837fa"],
"settlements": {
"supersede": "eb79a9b3682a",
@@ -502,7 +502,7 @@
{
"id": "speech-desktop-start-superseded.inner-false-string-error:stale-start-cancelled",
"observation": {
"sender": ["55019d971687", "73faa32df517"],
"sender": ["f2eeb517059d", "9ab91ee194b8"],
"payloads": ["a7ddd021010a", "ce9cbc8837fa"],
"settlements": {
"supersede": "eb79a9b3682a",
@@ -515,7 +515,7 @@
{
"id": "speech-desktop-start-superseded.inner-false-object-error:stale-start-cancelled",
"observation": {
"sender": ["ceca67daa178", "73faa32df517"],
"sender": ["ced9bd128f52", "9ab91ee194b8"],
"payloads": ["a7ddd021010a", "ce9cbc8837fa"],
"settlements": {
"supersede": "eb79a9b3682a",
@@ -528,7 +528,7 @@
{
"id": "speech-desktop-start-superseded.outer-refused:stale-start-cancelled",
"observation": {
"sender": ["5bd3d1bd4863", "73faa32df517"],
"sender": ["9c6249e03654", "9ab91ee194b8"],
"payloads": ["a7ddd021010a", "ce9cbc8837fa"],
"settlements": {
"supersede": "eb79a9b3682a",
@@ -541,7 +541,7 @@
{
"id": "speech-desktop-start-superseded.outer-refused-no-message:stale-start-cancelled",
"observation": {
"sender": ["ba48be88195e", "73faa32df517"],
"sender": ["02e3f6a398b9", "9ab91ee194b8"],
"payloads": ["a7ddd021010a", "ce9cbc8837fa"],
"settlements": {
"supersede": "eb79a9b3682a",
@@ -554,7 +554,7 @@
{
"id": "speech-desktop-start-superseded.method-not-found:stale-start-cancelled",
"observation": {
"sender": ["3c4f020193c4", "73faa32df517"],
"sender": ["f2166355786a", "9ab91ee194b8"],
"payloads": ["a7ddd021010a", "ce9cbc8837fa"],
"settlements": {
"supersede": "eb79a9b3682a",
@@ -567,7 +567,7 @@
{
"id": "speech-desktop-start-superseded.transport-rejection:stale-start-cancelled",
"observation": {
"sender": ["0ec7faed0e3d", "73faa32df517"],
"sender": ["a1030fe3abd4", "9ab91ee194b8"],
"payloads": ["a7ddd021010a", "ce9cbc8837fa"],
"settlements": {
"supersede": "eb79a9b3682a",
@@ -580,7 +580,7 @@
{
"id": "speech-desktop-start-superseded.transport-rejection-no-message:stale-start-cancelled",
"observation": {
"sender": ["50620ee89249", "73faa32df517"],
"sender": ["106b709a0579", "9ab91ee194b8"],
"payloads": ["a7ddd021010a", "ce9cbc8837fa"],
"settlements": {
"supersede": "eb79a9b3682a",
@@ -13,7 +13,7 @@
"ordinal": 2,
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"speech.dictation.chunk\",\"params\":{\"dictationId\":\"dictation-1\",\"audioBase64\":\"ACVKb5S53gM=\",\"sampleRate\":16000}}"
},
"182109450de8": {
"99956eef8fff": {
"name": "speech.dictation.chunk#1",
"ordinal": 1,
"args": [
@@ -32,7 +32,7 @@
{
"name": "options",
"value": {
"$rpc": "absent"
"timeoutMs": 30000
}
}
],
@@ -69,7 +69,7 @@
{
"id": "acknowledged",
"observation": {
"sender": ["182109450de8"],
"sender": ["99956eef8fff"],
"payloads": ["12521d219054"],
"settlements": {
"chunk": "bc459c132276"
@@ -4,7 +4,7 @@
"family": "speech.dictation-start",
"namedDeltas": [],
"values": {
"3b5708754539": {
"4c42677aca21": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
@@ -21,7 +21,7 @@
{
"name": "options",
"value": {
"$rpc": "absent"
"timeoutMs": 30000
}
}
],
@@ -61,7 +61,7 @@
{
"id": "recording",
"observation": {
"sender": ["3b5708754539"],
"sender": ["4c42677aca21"],
"payloads": ["a7ddd021010a"],
"settlements": {
"start": "84e5ca07cb7a"
@@ -11,41 +11,7 @@
"idle": true,
"started": "unstarted"
},
"217153ab0a32": {
"name": "speech.dictation.cancel#1",
"ordinal": 4,
"args": [
{
"name": "method",
"value": "speech.dictation.cancel"
},
{
"name": "params",
"value": {
"dictationId": "dictation-1"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-2",
"ok": true,
"result": {
"cancelled": true
}
}
}
},
"3b5708754539": {
"4c42677aca21": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
@@ -62,7 +28,7 @@
{
"name": "options",
"value": {
"$rpc": "absent"
"timeoutMs": 30000
}
}
],
@@ -79,6 +45,40 @@
}
}
},
"4c97e2d0e296": {
"name": "speech.dictation.cancel#1",
"ordinal": 4,
"args": [
{
"name": "method",
"value": "speech.dictation.cancel"
},
{
"name": "params",
"value": {
"dictationId": "dictation-1"
}
},
{
"name": "options",
"value": {
"failWhenDisconnected": true
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-2",
"ok": true,
"result": {
"cancelled": true
}
}
}
},
"7a657475cacc": {
"status": "rejected",
"startedAt": 0,
@@ -111,7 +111,7 @@
{
"id": "rolled-back",
"observation": {
"sender": ["3b5708754539", "217153ab0a32"],
"sender": ["4c42677aca21", "4c97e2d0e296"],
"payloads": ["a7ddd021010a", "d6e61920633f"],
"settlements": {
"start": "7a657475cacc"
@@ -4,7 +4,7 @@
"family": "speech.dictation-start",
"namedDeltas": [],
"values": {
"3b5708754539": {
"4c42677aca21": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
@@ -21,7 +21,7 @@
{
"name": "options",
"value": {
"$rpc": "absent"
"timeoutMs": 30000
}
}
],
@@ -38,7 +38,13 @@
}
}
},
"73faa32df517": {
"7ed3d39f0607": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": false
},
"9ab91ee194b8": {
"name": "speech.dictation.cancel#1",
"ordinal": 3,
"args": [
@@ -55,7 +61,7 @@
{
"name": "options",
"value": {
"$rpc": "absent"
"failWhenDisconnected": true
}
}
],
@@ -72,12 +78,6 @@
}
}
},
"7ed3d39f0607": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": false
},
"a7ddd021010a": {
"name": "speech.dictation.start#1",
"ordinal": 2,
@@ -110,7 +110,7 @@
{
"id": "stale-start-cancelled",
"observation": {
"sender": ["3b5708754539", "73faa32df517"],
"sender": ["4c42677aca21", "9ab91ee194b8"],
"payloads": ["a7ddd021010a", "ce9cbc8837fa"],
"settlements": {
"supersede": "eb79a9b3682a",
@@ -4,40 +4,6 @@
"family": "speech.dictation-session",
"namedDeltas": [],
"values": {
"422e902e66a5": {
"name": "speech.dictation.cancel#1",
"ordinal": 3,
"args": [
{
"name": "method",
"value": "speech.dictation.cancel"
},
{
"name": "params",
"value": {
"dictationId": "mobile-dictation-1767225600000-8ig2henseon"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-2",
"ok": true,
"result": {
"cancelled": true
}
}
}
},
"6b76435b6f3e": {
"error": {
"$rpc": "null"
@@ -50,7 +16,7 @@
"ordinal": 2,
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"speech.dictation.start\",\"params\":{\"dictationId\":\"mobile-dictation-1767225600000-8ig2henseon\"}}"
},
"aca401ac072e": {
"bd004568e24c": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
@@ -67,7 +33,7 @@
{
"name": "options",
"value": {
"$rpc": "absent"
"timeoutMs": 30000
}
}
],
@@ -96,6 +62,40 @@
"name": "speech.dictation.cancel#1",
"ordinal": 4,
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"speech.dictation.cancel\",\"params\":{\"dictationId\":\"mobile-dictation-1767225600000-8ig2henseon\"}}"
},
"ed7a0ff994cd": {
"name": "speech.dictation.cancel#1",
"ordinal": 3,
"args": [
{
"name": "method",
"value": "speech.dictation.cancel"
},
{
"name": "params",
"value": {
"dictationId": "mobile-dictation-1767225600000-8ig2henseon"
}
},
{
"name": "options",
"value": {
"failWhenDisconnected": true
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-2",
"ok": true,
"result": {
"cancelled": true
}
}
}
}
},
"recording": {
@@ -104,7 +104,7 @@
{
"id": "cancelled",
"observation": {
"sender": ["aca401ac072e", "422e902e66a5"],
"sender": ["bd004568e24c", "ed7a0ff994cd"],
"payloads": ["859d310e63d6", "ebdac948a90d"],
"settlements": {
"mount": "eb79a9b3682a",
@@ -55,7 +55,7 @@
"status": "idle",
"transcripts": ["hello world"]
},
"aca401ac072e": {
"bd004568e24c": {
"name": "speech.dictation.start#1",
"ordinal": 1,
"args": [
@@ -72,7 +72,7 @@
{
"name": "options",
"value": {
"$rpc": "absent"
"timeoutMs": 30000
}
}
],
@@ -104,7 +104,7 @@
{
"id": "transcribed",
"observation": {
"sender": ["aca401ac072e", "4728ad10f92b"],
"sender": ["bd004568e24c", "4728ad10f92b"],
"payloads": ["859d310e63d6", "1bdc94d60271"],
"settlements": {
"mount": "eb79a9b3682a",
+111 -10
View File
@@ -4,15 +4,101 @@ import {
} from './mobile-dictation-pending-audio-budget'
import { bytesToBase64 } from './mobile-dictation-session-state'
import { dictationAudioChunkSend } from '../dictation/mobile-dictation-operations'
import type { DictationCaptureChunk } from '../platform/dictation-capture-contract'
import { MOBILE_DICTATION_RECONNECT_GRACE_MS } from '../../../src/shared/rpc-contract/speech-params'
import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import { rpcPayloadMember } from '../transport/rpc-reader-payload'
import { waitForRpcClientReconnected } from '../transport/rpc-client-reconnect-wait'
import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import { isLogicalClientSuspendedError } from '../transport/logical-client-suspended-error'
import type {
DictationCapture,
DictationCaptureChunk
} from '../platform/dictation-capture-contract'
import type { MobileDictationPendingAudioBudget } from './mobile-dictation-pending-audio-budget'
import type { RpcClient } from '../transport/rpc-client'
type MobileDictationAudioChunkQueue = {
pendingChunks: Set<Promise<void>>
pendingAudioBudget: MobileDictationPendingAudioBudget
shouldReleaseBudget: (dictationId: string) => boolean
isActive: (dictationId: string) => boolean
failActiveDictation: (dictationId: string, err: unknown) => void
transcriptSoFar: MobileDictationTranscriptSoFar
}
// Hosts that keep dictation through a reconnect report the text so far; a failure hands it over instead of dropping it.
export class MobileDictationTranscriptSoFar {
private kept: { dictationId: string; text: string } | null = null
note(dictationId: string, text: string): void {
this.kept = { dictationId, text }
}
take(dictationId: string): string {
const kept = this.kept
this.kept = null
return kept?.dictationId === dictationId ? kept.text : ''
}
}
// A request refused or still waiting while the connection moved never reached the host; send it again on the new one, within the host's grace.
export async function sendAcrossConnectionMove<T>(
client: RpcClient,
send: () => Promise<T>
): Promise<T> {
try {
return await send()
} catch (err) {
if (isLogicalClientCutoverError(err) && !isRpcDeliveryUnknown(err)) {
return send()
}
if (isLogicalClientSuspendedError(err) && (await waitForReconnect(client))) {
return send()
}
throw err
}
}
// Every chunk refused during one outage shares a single wait instead of a listener and timer each.
const reconnectWaits = new WeakMap<RpcClient, Promise<boolean>>()
function waitForReconnect(client: RpcClient): Promise<boolean> {
let wait = reconnectWaits.get(client)
if (!wait) {
wait = waitForRpcClientReconnected(client, MOBILE_DICTATION_RECONNECT_GRACE_MS).finally(() =>
reconnectWaits.delete(client)
)
reconnectWaits.set(client, wait)
}
return wait
}
export function subscribeMobileDictationAudioChunks(
capture: Pick<DictationCapture, 'onChunk'>,
source: {
getClient: () => RpcClient | null
getActiveId: () => string | null
isAccepting: () => boolean
},
queue: Omit<MobileDictationAudioChunkQueue, 'isActive'>
): { remove: () => void } {
// Reuse wiring on the microphone hot path instead of allocating per chunk.
const chunkQueue = { ...queue, isActive: (id: string) => source.getActiveId() === id }
return capture.onChunk((chunk) => {
const client = source.getClient()
const dictationId = source.getActiveId()
if (!client || !dictationId || !source.isAccepting()) {
return
}
if (chunk.droppedBytes > 0) {
// Page capture loss and budget overflow both mean the page fell behind the microphone; report the same error. Native capture never drops here.
queue.failActiveDictation(
dictationId,
new Error(MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE)
)
return
}
enqueueMobileDictationAudioChunk(client, dictationId, chunk, chunkQueue)
})
}
export function enqueueMobileDictationAudioChunk(
@@ -30,18 +116,33 @@ export function enqueueMobileDictationAudioChunk(
)
return
}
const sendChunk = dictationAudioChunkSend
.request(client, {
dictationId,
audioBase64: bytesToBase64(bytes),
sampleRate: MOBILE_DICTATION_PCM_SAMPLE_RATE
const params = {
dictationId,
audioBase64: bytesToBase64(bytes),
sampleRate: MOBILE_DICTATION_PCM_SAMPLE_RATE
}
const sendChunk = sendAcrossConnectionMove(client, () =>
// Wait for a reconnect only as long as the host keeps the session; past that, fail and keep the text so far.
dictationAudioChunkSend.request(client, params, {
timeoutMs: MOBILE_DICTATION_RECONNECT_GRACE_MS
})
)
.then((reply) => {
dictationAudioChunkSend.interpret(reply)
// Only newer hosts send the text, and only when it changed; any other result still acknowledges the chunk.
const result = dictationAudioChunkSend.interpret(reply)
const text = result == null ? undefined : rpcPayloadMember(result, 'text')
if (typeof text === 'string' && queue.isActive(dictationId)) {
queue.transcriptSoFar.note(dictationId, text)
}
})
.catch((err) => {
// The host keeps the session through a reconnect; only audio in flight at the drop is lost.
if (!isRpcDeliveryUnknown(err)) {
queue.failActiveDictation(dictationId, err)
}
})
.catch((err) => queue.failActiveDictation(dictationId, err))
.finally(() => {
if (queue.shouldReleaseBudget(dictationId)) {
if (queue.isActive(dictationId)) {
queue.pendingAudioBudget.release(byteLength)
}
queue.pendingChunks.delete(sendChunk)
@@ -72,9 +72,11 @@ describe('startMobileDictationDesktopSession', () => {
expect(harness.setIdle).not.toHaveBeenCalled()
expect(harness.getActiveId()).toBe('dictation-b')
expect(harness.commitRecordingStart).not.toHaveBeenCalled()
expect(harness.sendRequest).toHaveBeenCalledWith('speech.dictation.cancel', {
dictationId: 'dictation-a'
})
expect(harness.sendRequest).toHaveBeenCalledWith(
'speech.dictation.cancel',
{ dictationId: 'dictation-a' },
{ failWhenDisconnected: true }
)
})
it('returns to idle when a disable makes the start stale', async () => {
@@ -112,9 +114,11 @@ describe('startMobileDictationDesktopSession', () => {
// The hook opened capture before requesting a session; refusal must release it and the screen because no outer start catch does so.
expect(harness.rollbackRecordingStart).toHaveBeenCalledOnce()
expect(harness.sendRequest).toHaveBeenCalledWith('speech.dictation.cancel', {
dictationId: 'dictation-a'
})
expect(harness.sendRequest).toHaveBeenCalledWith(
'speech.dictation.cancel',
{ dictationId: 'dictation-a' },
{ failWhenDisconnected: true }
)
})
it('leaves the capture alone when the failure is no longer the current start', async () => {
@@ -135,9 +139,11 @@ describe('startMobileDictationDesktopSession', () => {
// Capture has no start identity; rolling back stale A would stop B’s microphone and release B’s screen.
expect(harness.rollbackRecordingStart).not.toHaveBeenCalled()
// A still owns its desktop session and must cancel that alone.
expect(harness.sendRequest).toHaveBeenCalledWith('speech.dictation.cancel', {
dictationId: 'dictation-a'
})
expect(harness.sendRequest).toHaveBeenCalledWith(
'speech.dictation.cancel',
{ dictationId: 'dictation-a' },
{ failWhenDisconnected: true }
)
})
it('commits recording before returning a current start to the hook', async () => {
@@ -160,9 +166,11 @@ describe('startMobileDictationDesktopSession', () => {
'Audio focus request failed'
)
expect(harness.sendRequest).toHaveBeenCalledWith('speech.dictation.cancel', {
dictationId: 'dictation-a'
})
expect(harness.sendRequest).toHaveBeenCalledWith(
'speech.dictation.cancel',
{ dictationId: 'dictation-a' },
{ failWhenDisconnected: true }
)
expect(harness.getActiveId()).toBeNull()
expect(harness.setIdle).toHaveBeenCalledOnce()
expect(harness.rollbackRecordingStart).toHaveBeenCalledOnce()
@@ -175,9 +183,11 @@ describe('startMobileDictationDesktopSession', () => {
'Failed to start microphone recording'
)
expect(harness.sendRequest).toHaveBeenCalledWith('speech.dictation.cancel', {
dictationId: 'dictation-a'
})
expect(harness.sendRequest).toHaveBeenCalledWith(
'speech.dictation.cancel',
{ dictationId: 'dictation-a' },
{ failWhenDisconnected: true }
)
expect(harness.getActiveId()).toBeNull()
expect(harness.setIdle).toHaveBeenCalledOnce()
expect(harness.rollbackRecordingStart).toHaveBeenCalledOnce()
@@ -3,6 +3,7 @@ import {
dictationSessionCancel,
dictationSessionStart
} from '../dictation/mobile-dictation-operations'
import { MOBILE_DICTATION_RECONNECT_GRACE_MS } from '../../../src/shared/rpc-contract/speech-params'
import type { RpcClient } from '../transport/rpc-client'
type StartMobileDictationDesktopSessionOptions = {
@@ -18,6 +19,16 @@ type StartMobileDictationDesktopSessionOptions = {
rollbackRecordingStart: () => void
}
// Offline there is nothing to cancel against; the host drops a session its phone stops reaching, so never wait for a reconnect.
export function cancelDesktopDictation(client: RpcClient, dictationId: string): Promise<void> {
return dictationSessionCancel
.request(client, { dictationId }, { failWhenDisconnected: true })
.then(
() => undefined,
() => undefined
)
}
function isCurrentStart(options: StartMobileDictationDesktopSessionOptions): boolean {
return isCurrentMobileDictationStart(
options.getCurrentGeneration(),
@@ -42,7 +53,7 @@ async function cancelStaleStart(options: StartMobileDictationDesktopSessionOptio
const { client, dictationId } = options
options.clearActiveId(dictationId)
setIdleIfGenerationCurrent(options)
await dictationSessionCancel.request(client, { dictationId }).catch(() => undefined)
await cancelDesktopDictation(client, dictationId)
}
export async function startMobileDictationDesktopSession(
@@ -51,7 +62,12 @@ export async function startMobileDictationDesktopSession(
const { client, dictationId } = options
try {
const reply = await dictationSessionStart.request(client, { dictationId })
// The mic is already open, so a start stalled by a drop must give up rather than hold it.
const reply = await dictationSessionStart.request(
client,
{ dictationId },
{ timeoutMs: MOBILE_DICTATION_RECONNECT_GRACE_MS }
)
dictationSessionStart.interpret(reply)
} catch (err) {
const wasCurrent = isCurrentStart(options)
@@ -64,7 +80,7 @@ export async function startMobileDictationDesktopSession(
}
}
options.clearActiveId(dictationId)
await dictationSessionCancel.request(client, { dictationId }).catch(() => undefined)
await cancelDesktopDictation(client, dictationId)
// Awaited cleanup may overlap a newer start; stale work must not reset or
// report over the replacement session.
const shouldReport = wasCurrent && canReportStartFailure(options)
@@ -96,7 +112,7 @@ export async function startMobileDictationDesktopSession(
// Continue releasing independently owned resources after native audio failure.
}
options.clearActiveId(dictationId)
await dictationSessionCancel.request(client, { dictationId }).catch(() => undefined)
await cancelDesktopDictation(client, dictationId)
const shouldReport = wasCurrent && canReportStartFailure(options)
setIdleIfGenerationCurrent(options)
if (!shouldReport) {
@@ -2,13 +2,13 @@ import { describe, expect, it } from 'vitest'
import { MobileDictationPendingAudioBudget } from './mobile-dictation-pending-audio-budget'
describe('MobileDictationPendingAudioBudget', () => {
it('caps pending raw PCM audio at five seconds', () => {
it('caps pending raw PCM audio at thirty seconds', () => {
const budget = new MobileDictationPendingAudioBudget()
expect(budget.tryReserve(159_999)).toBe(true)
expect(budget.tryReserve(959_999)).toBe(true)
expect(budget.tryReserve(1)).toBe(true)
expect(budget.pendingAudioBytes).toBe(160_000)
expect(budget.pendingAudioBytes).toBe(960_000)
expect(budget.tryReserve(1)).toBe(false)
expect(budget.pendingAudioBytes).toBe(160_000)
expect(budget.pendingAudioBytes).toBe(960_000)
})
it('releases completed chunks and clamps stale releases after reset', () => {
@@ -1,11 +1,17 @@
export const MOBILE_DICTATION_PCM_SAMPLE_RATE = 16000
import {
DICTATION_SAMPLE_RATE,
MOBILE_DICTATION_RECONNECT_GRACE_MS,
PCM_BYTES_PER_SAMPLE
} from '../../../src/shared/rpc-contract/speech-params'
const PCM_BYTES_PER_SAMPLE = 2
const MAX_PENDING_AUDIO_SECONDS = 5
export const MOBILE_DICTATION_PCM_SAMPLE_RATE = DICTATION_SAMPLE_RATE
// Why: dictation chunk RPCs can wait through reconnect before timing out, so
// Hold the speech captured while a reconnect is pending, for as long as the host keeps the session.
const MAX_PENDING_AUDIO_SECONDS = MOBILE_DICTATION_RECONNECT_GRACE_MS / 1000
// Why: dictation chunk RPCs wait through reconnect before timing out, so
// cap retained raw microphone audio before it is expanded into base64 payloads.
export const MOBILE_DICTATION_MAX_PENDING_AUDIO_BYTES =
const MOBILE_DICTATION_MAX_PENDING_AUDIO_BYTES =
MOBILE_DICTATION_PCM_SAMPLE_RATE * PCM_BYTES_PER_SAMPLE * MAX_PENDING_AUDIO_SECONDS
export const MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE =
@@ -0,0 +1,183 @@
// The host keeps a phone's dictation through a reconnect; the phone must not end it over the chunks the drop took with it.
import { createElement } from 'react'
import { act, create } from 'react-test-renderer'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
createFakeRpcClient,
type FakeRpcClient,
type SentRequest
} from '../mobile-web-shell/bridge-host-test-fakes'
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import type { ConnectionState } from '../transport/types'
import type {
DictationCapture,
DictationCaptureChunk
} from '../platform/dictation-capture-contract'
const seam = vi.hoisted(() => ({
chunkHandlers: new Set<(chunk: DictationCaptureChunk) => void>()
}))
vi.mock('react-native', () => ({
AppState: { currentState: 'active', addEventListener: () => ({ remove: () => {} }) },
Platform: { OS: 'ios' }
}))
vi.mock('../platform/dictation-capture', () => {
const capture: DictationCapture = {
open: async () => ({ ok: true }),
begin: () => true,
end: async () => {},
release: () => {},
onChunk: (handler) => {
seam.chunkHandlers.add(handler)
return { remove: () => seam.chunkHandlers.delete(handler) }
},
onInterruption: () => ({ remove: () => {} })
}
return { useDictationCapture: () => capture }
})
import { useMobileDictation, type UseMobileDictationResult } from './use-mobile-dictation'
type Reply = { text?: string } | Error
const held: { dictation: UseMobileDictationResult | null } = { dictation: null }
function mount(rpc: FakeRpcClient, transcripts: string[], errors: string[]): void {
function Probe(): null {
held.dictation = useMobileDictation({
client: rpc,
enabled: true,
onTranscript: (text) => transcripts.push(text),
onError: (err) => errors.push(err.message)
})
return null
}
act(() => {
create(createElement(Probe))
})
}
function dictation(): UseMobileDictationResult {
if (held.dictation === null) {
throw new Error('nothing mounted')
}
return held.dictation
}
// Answer chunks and finishes with the next scripted reply, and everything else with success.
async function pump(
rpc: FakeRpcClient,
sent: SentRequest[],
chunkReplies: Reply[],
finishReplies: Reply[] = []
): Promise<void> {
for (let round = 0; round < 8; round += 1) {
for (const request of rpc.requests.splice(0)) {
sent.push(request)
const reply: Reply =
request.method === 'speech.dictation.chunk'
? (chunkReplies.shift() ?? {})
: request.method === 'speech.dictation.finish'
? (finishReplies.shift() ?? { text: 'hello world' })
: {}
if (reply instanceof Error) {
request.reject(reply)
} else {
request.resolve({ id: 'desktop', ok: true, result: reply })
}
}
await Promise.resolve()
await Promise.resolve()
}
}
async function record(rpc: FakeRpcClient, sent: SentRequest[], chunkReplies: Reply[]) {
await act(async () => {
const started = dictation().start()
await pump(rpc, sent, [])
await started
})
for (const reply of chunkReplies) {
await act(async () => {
for (const handler of seam.chunkHandlers) {
handler({ data: Uint8Array.from([1, 2]), droppedBytes: 0 })
}
await pump(rpc, sent, [reply])
})
}
}
const methodsOf = (sent: SentRequest[]) => sent.map((request) => request.method)
beforeEach(() => {
seam.chunkHandlers.clear()
held.dictation = null
})
describe('dictation through a reconnect', () => {
it('keeps recording past chunks the drop lost, and resends requests that never left', async () => {
const rpc = createFakeRpcClient()
const sent: SentRequest[] = []
const transcripts: string[] = []
const errors: string[] = []
mount(rpc, transcripts, errors)
await record(rpc, sent, [
{ text: 'hello' },
markRpcDeliveryUnknown(new Error('Connection interrupted')),
new LogicalClientCutoverError(new Error('Connection closed'))
])
expect(dictation().status).toBe('recording')
// Each captured chunk went out once, and the one the cutover held back went out again.
expect(methodsOf(sent).filter((m) => m === 'speech.dictation.chunk')).toHaveLength(4)
await act(async () => {
const stopped = dictation().stop()
await pump(rpc, sent, [], [new LogicalClientCutoverError(new Error('Connection closed'))])
await stopped
})
expect(methodsOf(sent).filter((m) => m === 'speech.dictation.finish')).toHaveLength(2)
expect(methodsOf(sent)).not.toContain('speech.dictation.cancel')
expect(transcripts).toEqual(['hello world'])
expect(errors).toEqual([])
})
it('holds a chunk refused while recovery dials a replacement, and sends it once connected', async () => {
let state: ConnectionState = 'connected'
const rpc = createFakeRpcClient({ getState: () => state })
const sent: SentRequest[] = []
const transcripts: string[] = []
const errors: string[] = []
mount(rpc, transcripts, errors)
state = 'disconnected'
// The OTA page sees the shell's error rebuilt as another class with the same message.
await record(rpc, sent, [new Error('Client suspended')])
expect(methodsOf(sent).filter((m) => m === 'speech.dictation.chunk')).toHaveLength(1)
await act(async () => {
state = 'connected'
rpc.pushState('connected')
await pump(rpc, sent, [])
})
expect(methodsOf(sent).filter((m) => m === 'speech.dictation.chunk')).toHaveLength(2)
expect(dictation().status).toBe('recording')
expect(errors).toEqual([])
})
it('keeps what was transcribed when the connection does not come back', async () => {
const rpc = createFakeRpcClient()
const sent: SentRequest[] = []
const transcripts: string[] = []
const errors: string[] = []
mount(rpc, transcripts, errors)
await record(rpc, sent, [{ text: 'hello' }, new Error('Connection closed')])
expect(transcripts).toEqual(['hello'])
expect(errors).toEqual(['Connection closed'])
})
})
+32 -35
View File
@@ -1,10 +1,11 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useDictationCapture } from '../platform/dictation-capture'
import { MobileDictationPendingAudioBudget } from './mobile-dictation-pending-audio-budget'
import {
MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE,
MobileDictationPendingAudioBudget
} from './mobile-dictation-pending-audio-budget'
import { enqueueMobileDictationAudioChunk } from './mobile-dictation-audio-chunk'
MobileDictationTranscriptSoFar,
sendAcrossConnectionMove,
subscribeMobileDictationAudioChunks
} from './mobile-dictation-audio-chunk'
import {
DICTATION_FINISH_TIMEOUT_MS,
MOBILE_DICTATION_INPUT_CLOSED_ERROR_MESSAGE,
@@ -12,11 +13,11 @@ import {
createMobileDictationId,
isCurrentMobileDictationFinish
} from './mobile-dictation-session-state'
import { startMobileDictationDesktopSession } from './mobile-dictation-desktop-start'
import {
dictationSessionCancel,
dictationSessionFinish
} from '../dictation/mobile-dictation-operations'
cancelDesktopDictation,
startMobileDictationDesktopSession
} from './mobile-dictation-desktop-start'
import { dictationSessionFinish } from '../dictation/mobile-dictation-operations'
import { rpcPayloadMember } from '../transport/rpc-reader-payload'
import type {
DictationStatus,
@@ -43,6 +44,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
const pendingAudioBudgetRef = useRef(new MobileDictationPendingAudioBudget())
const acceptingChunksRef = useRef(false)
const generationRef = useRef(0)
const transcriptSoFarRef = useRef(new MobileDictationTranscriptSoFar())
useLayoutEffect(() => {
// Native audio events can arrive before passive Effects flush, but refs
@@ -89,7 +91,11 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
activeIdRef.current = null
closeDictationAudio()
if (client && dictationId) {
void dictationSessionCancel.request(client, { dictationId }).catch(() => undefined)
void cancelDesktopDictation(client, dictationId)
}
const kept = transcriptSoFarRef.current.take(dictationId)
if (kept) {
onTranscriptRef.current(kept)
}
reportError(err)
},
@@ -97,28 +103,19 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
)
useEffect(() => {
// Reuse wiring on the microphone hot path instead of allocating per chunk.
const audioChunkQueue = {
const source = {
getClient: () => clientRef.current,
getActiveId: () => activeIdRef.current,
isAccepting: () => enabledRef.current && acceptingChunksRef.current
}
const sub = subscribeMobileDictationAudioChunks(capture, source, {
pendingChunks: pendingChunksRef.current,
pendingAudioBudget: pendingAudioBudgetRef.current,
shouldReleaseBudget: (id: string) => activeIdRef.current === id,
failActiveDictation
}
const sub = capture.onChunk((chunk) => {
const client = clientRef.current
const dictationId = activeIdRef.current
if (!client || !dictationId || !enabledRef.current || !acceptingChunksRef.current) {
return
}
if (chunk.droppedBytes > 0) {
// Page capture loss and budget overflow both mean the page fell behind the microphone; report the same error. Native capture never drops here.
failActiveDictation(dictationId, new Error(MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE))
return
}
enqueueMobileDictationAudioChunk(client, dictationId, chunk, audioChunkQueue)
failActiveDictation,
transcriptSoFar: transcriptSoFarRef.current
})
return () => sub.remove()
}, [capture, failActiveDictation, reportError])
}, [capture, failActiveDictation])
const start = useCallback(async () => {
const client = clientRef.current
@@ -221,10 +218,12 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
return
}
const finished = dictationSessionFinish.interpret(
await dictationSessionFinish.request(
client,
{ dictationId },
{ timeoutMs: DICTATION_FINISH_TIMEOUT_MS }
await sendAcrossConnectionMove(client, () =>
dictationSessionFinish.request(
client,
{ dictationId },
{ timeoutMs: DICTATION_FINISH_TIMEOUT_MS }
)
)
)
if (
@@ -269,7 +268,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
activeIdRef.current = null
closeDictationAudio()
if (client && dictationId) {
await dictationSessionCancel.request(client, { dictationId }).catch(() => undefined)
await cancelDesktopDictation(client, dictationId)
}
// The cancel round trip may overlap a new recording; stale completion must neither report over it nor reset it.
if (generationRef.current !== generation) {
@@ -313,9 +312,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
closeDictationAudio()
capture.release()
if (clientRef.current && dictationId) {
void dictationSessionCancel
.request(clientRef.current, { dictationId })
.catch(() => undefined)
void cancelDesktopDictation(clientRef.current, dictationId)
}
}
}, [capture, closeDictationAudio])
@@ -1,11 +1,11 @@
import { z } from 'zod'
import { MOBILE_DICTATION_MAX_PENDING_AUDIO_BYTES } from '../../hooks/mobile-dictation-pending-audio-budget'
import { MAX_DICTATION_AUDIO_CHUNK_BYTES } from '../../../../src/shared/rpc-contract/speech-params'
// The shell owns microphone PCM and its wake lock; the page owns dictation state/RPCs. Pull from the ring through existing request/reply rather than adding a push frame.
// Keep verb grant segments lowercase: a camel-cased audio.readChunk would fail manifest grammar and reject the whole bundle.
// Use the page’s pending-audio budget; a larger shell ring would hand the page bytes its own queue refuses.
export const BRIDGE_AUDIO_RING_MAX_BYTES = MOBILE_DICTATION_MAX_PENDING_AUDIO_BYTES
// A full ring drain goes to the host as one chunk, so stay within its chunk cap; shipped shells validate maxBytes against this.
export const BRIDGE_AUDIO_RING_MAX_BYTES = MAX_DICTATION_AUDIO_CHUNK_BYTES
// Base64 uses four chars per three bytes; a full ring drain fits the message cap.
export const BRIDGE_AUDIO_READ_MAX_BASE64_CHARS = Math.ceil(BRIDGE_AUDIO_RING_MAX_BYTES / 3) * 4
@@ -21,7 +21,10 @@ import {
type BridgePortPair
} from '../mobile-web-shell/bridge/bridge-port-pair-test-harness'
import { MobileDictationPendingAudioBudget } from '../hooks/mobile-dictation-pending-audio-budget'
import { enqueueMobileDictationAudioChunk } from '../hooks/mobile-dictation-audio-chunk'
import {
enqueueMobileDictationAudioChunk,
MobileDictationTranscriptSoFar
} from '../hooks/mobile-dictation-audio-chunk'
import { createNativeAudioCapture, type NativeAudioEngine } from './native-audio'
import { createPageDictationCapture } from './dictation-capture.web'
import {
@@ -98,7 +101,8 @@ async function runDictation(
enqueueMobileDictationAudioChunk(pair.client, 'dictation-1', chunk, {
pendingChunks,
pendingAudioBudget,
shouldReleaseBudget: () => true,
isActive: () => true,
transcriptSoFar: new MobileDictationTranscriptSoFar(),
failActiveDictation: (_id, error) => {
refusals.push(error instanceof Error ? error.message : String(error))
}
@@ -45,6 +45,8 @@ export function MobileTerminalInputActions({
}: MobileTerminalInputActionsProps) {
useMobileColorScheme()
const dictationActive = dictation.isStarting || dictation.isRecording
// Dictation outlives a reconnect, so its stop and cancel must too; only starting needs the connection.
const micDisabled = !canSend && !dictationActive && !dictation.isProcessing
return (
<>
<Pressable
@@ -68,9 +70,9 @@ export function MobileTerminalInputActions({
style={[
buttonStyle,
dictationActive && activeButtonStyle,
!canSend && disabledButtonStyle
micDisabled && disabledButtonStyle
]}
disabled={!canSend}
disabled={micDisabled}
onPress={dictationMode === 'toggle' ? onDictationToggle : undefined}
onPressIn={dictationMode === 'hold' ? onDictationPressIn : undefined}
onPressOut={dictationMode === 'hold' ? onDictationPressOut : undefined}
@@ -110,6 +110,7 @@ type SessionScope = Pick<
| 'activeHandleRef'
| 'flushPendingLiveInputBeforeExternalSend'
| 'canSend'
| 'canCompose'
| 'liveInputEnabled'
| 'showToast'
| 'resetLiveInputFocus'
@@ -143,6 +144,11 @@ let terminalText = ''
function Probe({ client, tab, connState, dictationEnabled }: ProbeOptions): null {
const activeHandle = tab.type === 'terminal' ? tab.terminal : null
const activeHandleRef = useRef(activeHandle)
const gate = resolveMobileTerminalInputGate({
connState,
activeHandle,
activeSessionTabType: tab.type
})
const scope: SessionScope = {
hostId: 'h1',
worktreeId: 'w1',
@@ -166,11 +172,8 @@ function Probe({ client, tab, connState, dictationEnabled }: ProbeOptions): null
dictationRouteContextRef: useRef(null),
activeHandleRef,
flushPendingLiveInputBeforeExternalSend: async () => true,
canSend: resolveMobileTerminalInputGate({
connState,
activeHandle,
activeSessionTabType: tab.type
}).canSend,
canSend: gate.canSend,
canCompose: gate.canCompose,
liveInputEnabled: false,
showToast: () => {},
resetLiveInputFocus: () => {}
@@ -279,7 +282,8 @@ describe('dictation uses the active composer readiness', () => {
expect(draft.text).toBe('Draft')
})
it('cancels an in-flight agent-chat start when the connection drops', async () => {
// The host keeps the session through a reconnect, so a drop is not the end of the input.
it('keeps a start that spans a connection drop, and records once it lands', async () => {
const client = createFakeRpcClient()
const options = {
client,
@@ -289,21 +293,14 @@ describe('dictation uses the active composer readiness', () => {
} satisfies ProbeOptions
await render(options)
await act(async () => model().startDictation())
const pendingStart = client.requests.shift()
expect(pendingStart?.method).toBe('speech.dictation.start')
await render({ ...options, connState: 'disconnected' })
await reply(client, 'speech.dictation.cancel', {})
await act(async () =>
pendingStart?.resolve({ id: 'desktop', ok: true, result: { started: true } })
)
await reply(client, 'speech.dictation.cancel', {})
expect(device.recording).toBe(false)
expect(draft.text).toBe('Draft')
await render({ ...options, connState: 'reconnecting' })
await render(options)
await start(client)
await reply(client, 'speech.dictation.start', { started: true })
expect(model().dictation.status).toBe('recording')
expect(device.recording).toBe(true)
})
it('drops a late transcript after disconnecting, then starts again on reconnect', async () => {
it('delivers the transcript of a finish that spans a connection drop', async () => {
const client = createFakeRpcClient()
const options = {
client,
@@ -313,19 +310,14 @@ describe('dictation uses the active composer readiness', () => {
} satisfies ProbeOptions
await render(options)
await start(client)
await render({ ...options, connState: 'reconnecting' })
expect(model().dictation.status).toBe('recording')
await act(async () => {
void model().dictation.stop()
})
const pendingFinish = client.requests.shift()
expect(pendingFinish?.method).toBe('speech.dictation.finish')
await render({ ...options, connState: 'disconnected' })
await reply(client, 'speech.dictation.cancel', {})
await act(async () =>
pendingFinish?.resolve({ id: 'desktop', ok: true, result: { text: 'late words' } })
)
expect(draft.text).toBe('Draft')
expect(device.recording).toBe(false)
await render(options)
await start(client)
await reply(client, 'speech.dictation.finish', { text: 'words across the drop' })
expect(client.requests).toEqual([])
expect(draft.text).toBe('Draft words across the drop')
})
})
@@ -40,3 +40,37 @@ it('removes the disabled microphone while keeping image attachment available', a
])
await act(async () => renderer.unmount())
})
it('keeps the microphone able to stop a recording while the connection is down', async () => {
const idle = { isStarting: false, isRecording: false, isProcessing: false }
const props = {
canSend: false,
isAttaching: false,
dictation: { ...idle, isRecording: true },
dictationMode: 'toggle',
buttonStyle: {},
activeButtonStyle: {},
disabledButtonStyle: {},
onAttachImage: vi.fn(),
onAttachFile: vi.fn(),
onDictationToggle: vi.fn(),
onDictationPressIn: vi.fn(),
onDictationPressOut: vi.fn(),
onDictationCancel: vi.fn()
}
let renderer!: ReturnType<typeof create>
await act(async () => {
renderer = create(createElement(MobileTerminalInputActions, props))
})
const mic = () =>
renderer.root.find(
(node) =>
String(node.type) === 'Pressable' && node.props.accessibilityLabel !== 'Attach a photo'
)
expect(mic().props.disabled).toBe(false)
await act(async () =>
renderer.update(createElement(MobileTerminalInputActions, { ...props, dictation: idle }))
)
expect(mic().props.disabled).toBe(true)
await act(async () => renderer.unmount())
})
@@ -142,6 +142,7 @@ function Probe() {
deviceTokenRef,
flushPendingLiveInputBeforeExternalSend: async () => true,
canSend: env.connected,
canCompose: true,
liveInputEnabled: false,
showToast: () => {},
resetLiveInputFocus: () => {}
@@ -126,6 +126,7 @@ function mount(
activeHandleRef: useRef('t1'),
flushPendingLiveInputBeforeExternalSend: async () => true,
canSend: true,
canCompose: true,
liveInputEnabled: options.liveInput,
showToast: (message: string) => {
seen.toasts.push(message)
@@ -51,6 +51,7 @@ export function useMobileSessionNativeChatDictation(
activeSessionTab,
flushPendingLiveInputBeforeExternalSend,
canSend,
canCompose,
liveInputEnabled,
showToast,
resetLiveInputFocus
@@ -129,10 +130,11 @@ export function useMobileSessionNativeChatDictation(
}
const chatDictationDestination = useRef<((text: string) => boolean | void) | undefined>(undefined)
const dictationTargetReady = showNativeChat ? connState === 'connected' : canSend
// Starting needs the host, but a reconnect must not end a recording: the host keeps the session through it.
const dictationCanStart = showNativeChat ? connState === 'connected' : canSend
const dictation = useMobileDictation({
client,
enabled: dictationTargetReady && dictationEnabled,
enabled: (showNativeChat || canCompose) && dictationEnabled,
onTranscript: (text) => {
// Route dictation to the visible chat composer locally; terminal mode retains live-input routing.
if (showNativeChatRef.current || chatDictationDestination.current) {
@@ -185,6 +187,9 @@ export function useMobileSessionNativeChatDictation(
const dictationNeedsSetupRef = useRef(false)
const startDictation = useCallback(() => {
if (!dictationCanStart) {
return
}
if (dictationNeedsSetupRef.current) {
// Explain setup before the OS microphone prompt: mic access cannot help until the host can transcribe.
setShowDictationSetup(true)
@@ -204,6 +209,7 @@ export function useMobileSessionNativeChatDictation(
}, [
activeHandle,
dictation,
dictationCanStart,
liveInputTerminalHandles,
reportDictationFailure,
setShowDictationSetup
@@ -1,6 +1,7 @@
import type { MountAdapter } from '../recording-scenario'
import { hookMount, performHookAction } from '../hook-mount'
import type { operationModuleLoader } from '../operation-module-loader'
import type * as AudioChunkModule from '../../../hooks/mobile-dictation-audio-chunk'
const DICTATION_ID = 'dictation-1'
const MODEL_ID = 'whisper-small'
@@ -82,9 +83,10 @@ export function dictationMountAdapters(
}
},
'speech.audio-chunk': ({ client, effect }) => {
const enqueue = modules.load<typeof import('../../../hooks/mobile-dictation-audio-chunk')>(
const chunks = modules.load<typeof AudioChunkModule>(
'mobile/src/hooks/mobile-dictation-audio-chunk.ts'
).enqueueMobileDictationAudioChunk
)
const enqueue = chunks.enqueueMobileDictationAudioChunk
const budget = modules.load<
typeof import('../../../hooks/mobile-dictation-pending-audio-budget')
>('mobile/src/hooks/mobile-dictation-pending-audio-budget.ts')
@@ -105,7 +107,8 @@ export function dictationMountAdapters(
{
pendingChunks,
pendingAudioBudget,
shouldReleaseBudget: () => true,
isActive: () => true,
transcriptSoFar: new chunks.MobileDictationTranscriptSoFar(),
failActiveDictation: (id: string, error: unknown) => {
failures.push(error instanceof Error ? error.message : String(error))
effect('dictation-failed', { id })
@@ -0,0 +1,14 @@
// A suspended client refuses before anything is written, while recovery dials a replacement.
export class LogicalClientSuspendedError extends Error {
constructor() {
super('Client suspended')
}
}
// Why: errors crossing the page bridge are rebuilt as other classes, so also match by message.
export function isLogicalClientSuspendedError(error: unknown): boolean {
return (
error instanceof LogicalClientSuspendedError ||
(error instanceof Error && error.message === 'Client suspended')
)
}
@@ -0,0 +1,51 @@
import type { ConnectionState } from './types'
type RelaySessionStateSource = {
getState(): ConnectionState
onStateChange(listener: (state: ConnectionState) => void): () => void
}
// Requests issued before a relay session connects wait here until it connects, fails, times out or is retired.
export class MobileRelayConnectWaiters {
private readonly releases = new Set<() => void>()
constructor(private readonly session: RelaySessionStateSource) {}
wait(timeoutMs: number): Promise<void> {
if (this.session.getState() === 'connected') {
return Promise.resolve()
}
return new Promise((resolve, reject) => {
const settle = (error: Error | null): void => {
clearTimeout(timer)
unsubscribe()
this.releases.delete(release)
if (error) {
reject(error)
} else {
resolve()
}
}
const release = () => settle(new Error('relay session closed'))
this.releases.add(release)
const unsubscribe = this.session.onStateChange((next) => {
if (next === 'connected') {
settle(null)
} else if (next === 'disconnected' || next === 'auth-failed') {
settle(new Error(`relay session ${next}`))
}
})
const timer = setTimeout(
() => settle(new Error('relay session connection timed out')),
timeoutMs
)
})
}
// A failed session already published disconnected, so requests issued since are still waiting; retiring it must release them to the replacement.
releaseAll(): void {
for (const release of this.releases) {
release()
}
}
}
@@ -326,6 +326,20 @@ describe('mobile relay RPC session', () => {
expect(session.getState()).toBe('disconnected')
})
it('releases requests waiting on a failed session once it is retired, unsent', async () => {
const { session } = await authenticateSession()
fakes.linkOptions!.onError(new Error('relay lost'))
vi.useFakeTimers()
const settled = vi.fn()
session.sendRequest('status.get').catch(settled)
session.close()
// No timer advances: the request must not sit out its connect timeout.
await vi.waitFor(() => expect(settled).toHaveBeenCalledOnce())
expect(isRpcDeliveryUnknown(settled.mock.calls[0]![0])).toBe(false)
expect(fakes.sendText).not.toHaveBeenCalled()
})
it('keeps a synchronous pre-write relay failure definite', async () => {
const { session } = await authenticateSession()
fakes.sendText.mockImplementationOnce(() => {
@@ -16,6 +16,7 @@ import { openRpcRequestBudget, resolvePostConnectRequestTimeout } from './rpc-re
import { isRpcResponse } from './rpc-response-shape'
import { RelayDialStageTracker, type RelayDialStageSource } from './relay-dial-stage'
import { RelayPendingRequests } from './relay-pending-requests'
import { MobileRelayConnectWaiters } from './mobile-relay-connect-waiters'
import { RpcSessionLivenessWatchdog } from './rpc-session-liveness-watchdog'
import { settleMobileRuntimeCapabilities } from './mobile-runtime-capability-negotiation'
import type { RelayHostCloseReason } from '../../../src/shared/relay-host-close-reason'
@@ -138,6 +139,7 @@ export function connectMobileRelayRpcSession(args: {
}
},
close() {
connectWaiters.releaseAll()
if (closed) {
return
}
@@ -157,6 +159,7 @@ export function connectMobileRelayRpcSession(args: {
probeAfterBackground: () =>
state === 'connected' && livenessWatchdog.probeNow(livenessIdentity, RELAY_RESUME_PROBE_MS)
}
const connectWaiters = new MobileRelayConnectWaiters(client)
const livenessWatchdog = new RpcSessionLivenessWatchdog({
transport: 'relay',
idleProbeMs: null,
@@ -265,31 +268,7 @@ export function connectMobileRelayRpcSession(args: {
}
function waitForConnected(timeoutMs = requestTimeoutMs): Promise<void> {
if (state === 'connected') {
return Promise.resolve()
}
return new Promise((resolve, reject) => {
let timer: ReturnType<typeof setTimeout> | null = null
const unsubscribe = client.onStateChange((next) => {
if (next === 'connected') {
finish()
resolve()
} else if (next === 'disconnected' || next === 'auth-failed') {
finish()
reject(new Error(`relay session ${next}`))
}
})
timer = setTimeout(() => {
finish()
reject(new Error('relay session connection timed out'))
}, timeoutMs)
function finish(): void {
if (timer) {
clearTimeout(timer)
}
unsubscribe()
}
})
return connectWaiters.wait(timeoutMs)
}
function publishState(next: ConnectionState): void {
@@ -8,6 +8,7 @@ import {
LogicalClientCutoverError,
type MobileConnectionPath
} from './stable-logical-rpc-client'
import { LogicalClientSuspendedError } from './logical-client-suspended-error'
class FakeSession implements RpcClient {
readonly sendRequest =
@@ -169,6 +170,23 @@ describe('stable logical RPC client', () => {
await expect(client.sendRequest('status.get')).resolves.toEqual(success('next'))
})
it('reports requests the suspended session never sent as suspended, and written ones as ambiguous', async () => {
const session = new FakeSession('connected')
const unsent = deferred<RpcResponse>()
const written = deferred<RpcResponse>()
session.sendRequest.mockReturnValueOnce(unsent.promise).mockReturnValueOnce(written.promise)
const client = createStableLogicalRpcClient(session, 'relay')
const waiting = client.sendRequest('speech.dictation.chunk').catch((error: unknown) => error)
const inFlight = client.sendRequest('speech.dictation.chunk').catch((error: unknown) => error)
client.suspendActiveSession()
unsent.reject(new Error('relay session closed'))
written.reject(markRpcDeliveryUnknown(new Error('Client closed')))
expect(await waiting).toBeInstanceOf(LogicalClientSuspendedError)
expect(isRpcDeliveryUnknown(await inFlight)).toBe(true)
})
it('preserves delivery ambiguity without replaying a mutation after relay replacement', async () => {
const session = new FakeSession('connected')
const replacement = new FakeSession('connected')
@@ -9,6 +9,7 @@ import { projectMobileRpcRequestParams } from './mobile-rpc-request-projection'
import { LogicalClientConnectionPath } from './logical-client-connection-path'
import type { RelayHostReachability } from './relay-host-reachability'
import { isRpcDeliveryUnknown, markRpcDeliveryUnknown } from './rpc-delivery-ambiguity'
import { LogicalClientSuspendedError } from './logical-client-suspended-error'
export type MobileConnectionPath = 'lan' | 'tailscale' | 'relay'
@@ -87,7 +88,7 @@ export function createStableLogicalRpcClient(
return Promise.reject(new Error('Client closed'))
}
if (suspended) {
return Promise.reject(new Error('Client suspended'))
return Promise.reject(new LogicalClientSuspendedError())
}
const requestGeneration = generation
const session = activeSession
@@ -103,7 +104,12 @@ export function createStableLogicalRpcClient(
(error: unknown) => {
// Keep the physical error as cause: only that layer knows whether the frame reached the wire.
reject(
requestGeneration !== generation ? new LogicalClientCutoverError(error) : error
requestGeneration !== generation
? new LogicalClientCutoverError(error)
: // Suspension retires the session under requests it never sent.
suspended && !isRpcDeliveryUnknown(error)
? new LogicalClientSuspendedError()
: error
)
}
)
@@ -112,7 +112,6 @@ describe('paired check details over real encrypted loopback transport', () => {
getRuntimeId: () => 'paired-check-host',
getStartedAt: () => 1,
cleanupSubscriptionsForConnection: () => {},
cancelMobileDictationForConnection: () => {},
onClientDisconnected: () => {},
getRepoPRCheckDetails: (...args: Parameters<typeof query.getRepoPRCheckDetails>) =>
query.getRepoPRCheckDetails(...args).finally(() => {
@@ -49,7 +49,6 @@ it(
getRuntimeId: () => 'close-intent-runtime-test',
getStartedAt: () => 1,
cleanupSubscriptionsForConnection: () => {},
cancelMobileDictationForConnection: () => {},
onClientDisconnected: () => {},
listMobileSessionTabs,
refuseUnattributedMobileSessionTabClose,
@@ -49,7 +49,6 @@ describe('remote runtime request connection integration', () => {
getRuntimeId: () => 'fetch-runtime-test',
getStartedAt: () => 1,
cleanupSubscriptionsForConnection: () => {},
cancelMobileDictationForConnection: () => {},
onClientDisconnected: () => {},
listVisibleRepos: () => repos
} as unknown as OrcaRuntimeService
@@ -137,7 +136,6 @@ describe('remote runtime request connection integration', () => {
subscriptionCleanups.get(id)?.()
subscriptionCleanups.delete(id)
},
cancelMobileDictationForConnection: () => {},
onClientDisconnected: () => {},
showRepo: (selector: string) => {
if (selector !== repo.id && selector !== `id:${repo.id}`) {
@@ -305,7 +303,6 @@ describe('remote runtime request connection integration', () => {
subscriptionCleanups.get(id)?.()
subscriptionCleanups.delete(id)
},
cancelMobileDictationForConnection: () => {},
onClientDisconnected: () => {},
onClientEvent: (listener: (event: RuntimeClientEvent) => void) => {
clientEventListeners.add(listener)
@@ -553,7 +550,6 @@ describe('remote runtime request connection integration', () => {
}
}
},
cancelMobileDictationForConnection: () => {},
onClientDisconnected: () => {},
onClientEvent: (listener: (event: RuntimeClientEvent) => void) => {
clientEventListeners.add(listener)
+1 -2
View File
@@ -30,8 +30,7 @@ describe('speech RPC methods', () => {
dictationId: 'dict-1',
audioBase64: 'AAAA',
sampleRate: 16_000,
clientId: undefined,
connectionId: undefined
clientId: undefined
})
})
+7 -8
View File
@@ -42,29 +42,28 @@ export const SPEECH_METHODS = [
name: 'speech.dictation.start',
permission: 'workspace',
params: DictationStart,
handler: async (params, { runtime, clientId, connectionId }) =>
runtime.startMobileDictation({ ...params, clientId, connectionId })
handler: async (params, { runtime, clientId }) =>
runtime.startMobileDictation({ ...params, clientId })
}),
defineMethod({
name: 'speech.dictation.chunk',
permission: 'workspace',
params: DictationChunk,
handler: (params, { runtime, clientId, connectionId }) =>
runtime.feedMobileDictation({ ...params, clientId, connectionId })
handler: (params, { runtime, clientId }) => runtime.feedMobileDictation({ ...params, clientId })
}),
defineMethod({
name: 'speech.dictation.finish',
permission: 'workspace',
params: DictationHandle,
handler: async (params, { runtime, clientId, connectionId }) =>
runtime.finishMobileDictation({ ...params, clientId, connectionId })
handler: async (params, { runtime, clientId }) =>
runtime.finishMobileDictation({ ...params, clientId })
}),
defineMethod({
name: 'speech.dictation.cancel',
permission: 'workspace',
params: DictationHandle,
handler: async (params, { runtime, clientId, connectionId }) =>
runtime.cancelMobileDictation({ ...params, clientId, connectionId })
handler: async (params, { runtime, clientId }) =>
runtime.cancelMobileDictation({ ...params, clientId })
}),
defineMethod({
name: 'speech.openAiKey.status',
@@ -0,0 +1,76 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultVoiceSettings } from '../../shared/constants'
import {
setSpeechServiceFactories,
type SpeechServiceFactories
} from '../speech/speech-runtime-service'
import { MOBILE_DICTATION_RECONNECT_GRACE_MS } from '../../shared/rpc-contract/speech-params'
import { RuntimeMobileDictationController } from './runtime-mobile-dictation-controller'
import type { RuntimeStore } from './runtime-store-contract'
type Sink = (event: { type: string; text?: string }) => void
let sink: Sink = () => {}
const stopDictation = vi.fn(async () => {})
beforeEach(() => {
vi.useFakeTimers()
stopDictation.mockClear()
const factories = {
createModelManager: () => ({ getModelState: async () => ({ status: 'ready' }) }),
createSttService: () => ({
startDictation: async (_modelId: string, next: Sink) => {
sink = next
},
feedAudio: () => {},
stopDictation
})
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the controller calls only getModelState, startDictation, feedAudio and stopDictation.
setSpeechServiceFactories(factories as unknown as SpeechServiceFactories)
})
afterEach(() => {
setSpeechServiceFactories(null)
vi.useRealTimers()
})
async function startPhoneDictation(): Promise<RuntimeMobileDictationController> {
const store = { getSettings: () => ({ voice: { ...getDefaultVoiceSettings(), enabled: true } }) }
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the controller reads only getSettings.
const controller = new RuntimeMobileDictationController(() => store as unknown as RuntimeStore)
await controller.start({ dictationId: 'd-1', modelId: 'test-model', clientId: 'phone-a' })
sink({ type: 'final', text: 'before the drop' })
return controller
}
const chunk = { dictationId: 'd-1', audioBase64: 'AAAA', sampleRate: 16_000, clientId: 'phone-a' }
describe('mobile dictation across a phone reconnect', () => {
it('keeps the session while the phone keeps reaching it, whatever connection it uses', async () => {
const controller = await startPhoneDictation()
await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_RECONNECT_GRACE_MS - 1)
expect(controller.feed(chunk)).toEqual({ dictationId: 'd-1', text: 'before the drop' })
expect(controller.feed(chunk)).toEqual({ dictationId: 'd-1' })
// A reply carrying the text may be lost at a drop, so it is sent again a second later.
await vi.advanceTimersByTimeAsync(1000)
expect(controller.feed(chunk)).toEqual({ dictationId: 'd-1', text: 'before the drop' })
await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_RECONNECT_GRACE_MS - 1)
sink({ type: 'final', text: 'after it' })
await expect(controller.finish({ dictationId: 'd-1', clientId: 'phone-a' })).resolves.toEqual({
dictationId: 'd-1',
text: 'before the drop after it'
})
})
it('releases the speech model when the phone goes quiet for the grace', async () => {
const controller = await startPhoneDictation()
await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_RECONNECT_GRACE_MS)
expect(stopDictation).toHaveBeenCalledOnce()
expect(() => controller.feed(chunk)).toThrow('dictation_stream_not_started')
})
})
@@ -1,4 +1,5 @@
import { getDefaultVoiceSettings } from '../../shared/constants'
import { MOBILE_DICTATION_RECONNECT_GRACE_MS } from '../../shared/rpc-contract/speech-params'
import { getSpeechModelManager, getSpeechSttService } from '../speech/speech-runtime-service'
import { getCatalogModel, isLocalSpeechModel } from '../speech/model-catalog'
import {
@@ -10,12 +11,16 @@ import type { RuntimeStore } from './runtime-store-contract'
type MobileDictationSession = {
id: string
owner: string
clientId?: string
connectionId?: string
clientId: string
state: 'starting' | 'active' | 'closing'
partialText: string
finalTexts: string[]
errors: string[]
// Bumped by each transcript event; chunks arrive far more often, so replies carry text only after a bump or a second.
textVersion: number
reportedVersion: number
reportedAt: number
abandonTimer?: ReturnType<typeof setTimeout>
}
export class RuntimeMobileDictationController {
@@ -27,7 +32,6 @@ export class RuntimeMobileDictationController {
dictationId: string
modelId?: string
clientId?: string
connectionId?: string
}): Promise<{ dictationId: string; modelId: string }> {
const store = this.requireStore()
const voice = store.getSettings().voice ?? getDefaultVoiceSettings()
@@ -49,6 +53,10 @@ export class RuntimeMobileDictationController {
if (!params.clientId) {
throw new Error('dictation_requires_mobile_client')
}
if (this.session?.clientId === params.clientId && this.session.state === 'active') {
// A phone runs one dictation at a time, so its new start retires one it lost track of (app relaunch inside the grace).
await this.stopSession(this.session)
}
if (this.session) {
throw new Error('dictation_already_active')
}
@@ -58,11 +66,13 @@ export class RuntimeMobileDictationController {
id: params.dictationId,
owner,
clientId: params.clientId,
connectionId: params.connectionId,
state: 'starting',
partialText: '',
finalTexts: [],
errors: []
errors: [],
textVersion: 0,
reportedVersion: 0,
reportedAt: 0
}
try {
await getSpeechSttService(store).startDictation(
@@ -75,6 +85,12 @@ export class RuntimeMobileDictationController {
throw new Error('dictation_canceled')
}
this.session.state = 'active'
const session = this.session
// Owned by the phone, not its socket; it streams continuously, so this long without a request means it is gone.
session.abandonTimer = setTimeout(
() => void this.stopSession(session).catch(() => undefined),
MOBILE_DICTATION_RECONNECT_GRACE_MS
)
} catch (error) {
if (this.session?.id === params.dictationId) {
this.session = null
@@ -89,8 +105,7 @@ export class RuntimeMobileDictationController {
audioBase64: string
sampleRate: number
clientId?: string
connectionId?: string
}): { dictationId: string } {
}): { dictationId: string; text?: string } {
const session = this.requireOwnedSession(params)
if (session.state !== 'active') {
throw new Error('dictation_stream_closing')
@@ -104,68 +119,39 @@ export class RuntimeMobileDictationController {
samples[i] = pcm.readInt16LE(i * 2) / 32768
}
getSpeechSttService(this.requireStore()).feedAudio(samples, params.sampleRate, session.owner)
return { dictationId: params.dictationId }
// The text so far lets the phone keep it if the connection never comes back; resent each second in case a reply was lost.
const now = Date.now()
if (session.reportedVersion === session.textVersion && now - session.reportedAt < 1000) {
return { dictationId: params.dictationId }
}
session.reportedVersion = session.textVersion
session.reportedAt = now
return { dictationId: params.dictationId, text: transcriptOf(session) }
}
async finish(params: {
dictationId: string
clientId?: string
connectionId?: string
}): Promise<{ dictationId: string; text: string }> {
const session = this.requireOwnedSession(params)
session.state = 'closing'
try {
await getSpeechSttService(this.requireStore()).stopDictation(session.owner)
if (session.errors.length > 0) {
throw new Error(session.errors[0])
}
return {
dictationId: params.dictationId,
text: [...session.finalTexts, session.partialText].join(' ').trim()
}
} finally {
if (this.session?.id === session.id) {
this.session = null
}
await this.stopSession(session)
if (session.errors.length > 0) {
throw new Error(session.errors[0])
}
return { dictationId: params.dictationId, text: transcriptOf(session) }
}
async cancel(params: {
dictationId: string
clientId?: string
connectionId?: string
}): Promise<{ dictationId: string }> {
const session = this.session
if (
session?.id === params.dictationId &&
params.clientId &&
session.clientId === params.clientId &&
(!session.connectionId || session.connectionId === params.connectionId)
) {
session.state = 'closing'
try {
await getSpeechSttService(this.requireStore()).stopDictation(session.owner)
} finally {
if (this.session?.id === session.id) {
this.session = null
}
}
if (session?.id === params.dictationId && session.clientId === params.clientId) {
await this.stopSession(session)
}
return { dictationId: params.dictationId }
}
cancelForConnection(connectionId: string): void {
if (this.session?.connectionId === connectionId) {
this.cancelSession(this.session)
}
}
cancelForClient(clientId: string): void {
if (this.session?.clientId === clientId) {
this.cancelSession(this.session)
}
}
private acceptEvent(
dictationId: string,
event: { type: string; text?: string; error?: string }
@@ -176,11 +162,13 @@ export class RuntimeMobileDictationController {
}
if (event.type === 'partial') {
session.partialText = event.text ?? ''
session.textVersion += 1
} else if (event.type === 'final') {
const text = event.text?.trim()
if (text) {
session.finalTexts.push(text)
session.partialText = ''
session.textVersion += 1
}
} else if (event.type === 'error') {
session.errors.push(event.error ?? 'Speech worker error')
@@ -190,33 +178,30 @@ export class RuntimeMobileDictationController {
private requireOwnedSession(params: {
dictationId: string
clientId?: string
connectionId?: string
}): MobileDictationSession {
const session = this.session
if (!session || session.id !== params.dictationId) {
throw new Error('dictation_stream_not_started')
}
if (!params.clientId || session.clientId !== params.clientId) {
if (session.clientId !== params.clientId) {
throw new Error('dictation_owner_mismatch')
}
if (session.connectionId && session.connectionId !== params.connectionId) {
throw new Error('dictation_owner_mismatch')
if (session.state === 'active') {
session.abandonTimer?.refresh()
}
return session
}
private cancelSession(session: MobileDictationSession): void {
if (session.state === 'closing') {
return
}
private async stopSession(session: MobileDictationSession): Promise<void> {
clearTimeout(session.abandonTimer)
session.state = 'closing'
void getSpeechSttService(this.requireStore())
.stopDictation(session.owner)
.finally(() => {
if (this.session?.id === session.id) {
this.session = null
}
})
try {
await getSpeechSttService(this.requireStore()).stopDictation(session.owner)
} finally {
if (this.session?.id === session.id) {
this.session = null
}
}
}
private requireStore(): RuntimeStore {
@@ -227,3 +212,7 @@ export class RuntimeMobileDictationController {
return store
}
}
function transcriptOf(session: MobileDictationSession): string {
return [...session.finalTexts, session.partialText].join(' ').trim()
}
@@ -19,7 +19,6 @@ function runtimeRecordingPlacement() {
machineNameReady: async () => undefined,
getStatus: () => ({ graphStatus: 'unavailable' }),
cleanupSubscriptionsForConnection: () => {},
cancelMobileDictationForConnection: () => {},
onClientDisconnected: () => {},
placeUnshownLiveTerminals
} as unknown as OrcaRuntimeService
@@ -269,7 +269,6 @@ export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch {
this.mobileDesktopRelay?.closePhoneConnection(socket.connectionId)
// Stream cleanup is socket-scoped; disconnect state spans all transports for that device.
this.runtime.cleanupSubscriptionsForConnection(socket.connectionId)
this.runtime.cancelMobileDictationForConnection(socket.connectionId)
// Optional: runtime stand-ins without staged saves must still close cleanly.
void this.runtime.stagedFileSaves?.abortConnection(socket.connectionId)
this.binaryMessageRouter.deleteConnection(socket.connectionId)
@@ -82,7 +82,6 @@ export type RuntimeServiceCommandSurface = {
feedMobileDictation: RuntimeMobileDictationController['feed']
finishMobileDictation: RuntimeMobileDictationController['finish']
cancelMobileDictation: RuntimeMobileDictationController['cancel']
cancelMobileDictationForConnection: RuntimeMobileDictationController['cancelForConnection']
getAllBrowserDrivers: RuntimeBrowserDriverController['getAll']
reclaimBrowserForDesktop: RuntimeBrowserDriverController['reclaimForDesktop']
notifyMessageArrived(handle: string, messageType?: string): void
@@ -195,7 +194,6 @@ export function installRuntimeServiceCommandSurface(
feedMobileDictation: dictation.feed.bind(dictation),
finishMobileDictation: dictation.finish.bind(dictation),
cancelMobileDictation: dictation.cancel.bind(dictation),
cancelMobileDictationForConnection: dictation.cancelForConnection.bind(dictation),
getAllBrowserDrivers: browsers.getAll.bind(browsers),
reclaimBrowserForDesktop: browsers.reclaimForDesktop.bind(browsers),
waitForMessage: waiters.wait.bind(waiters),
@@ -15,7 +15,6 @@ export abstract class OrcaRuntimeWithOnClientDisconnected extends OrcaRuntimeWit
onClientDisconnected(clientId: string): void {
this.revokeTerminalFileGrantsForClient(clientId)
this.mobileDictation.cancelForClient(clientId)
for (const [ptyId, entry] of this.pendingRestoreTimers) {
if (entry.clientId === clientId) {
+3
View File
@@ -9,6 +9,9 @@ export const PCM_BYTES_PER_SAMPLE = 2
export const MAX_DICTATION_AUDIO_SECONDS = 5
// The host drops a phone dictation it hasn't heard from for this long, so the phone waits no longer for a reconnect.
export const MOBILE_DICTATION_RECONNECT_GRACE_MS = 30_000
export const MAX_DICTATION_AUDIO_CHUNK_BYTES =
DICTATION_SAMPLE_RATE * PCM_BYTES_PER_SAMPLE * MAX_DICTATION_AUDIO_SECONDS