mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(mobile): resend dictation audio lost when the connection drops (#35777)
* fix(mobile): resend dictation audio a dropped connection left unanswered The host feeds chunks in seq order, each seq once, holding audio that arrives ahead of a resend for up to the reconnect grace, and says so in its start reply. To such a host the phone resends chunks rejected as delivery-unknown after each reconnect, so speech sent on a dying connection is no longer lost. * fix(mobile): cap held dictation chunks by count as well as bytes * fix(mobile): skip every gap needed to keep held dictation audio within the cap
This commit is contained in:
+201
-190
@@ -4,7 +4,22 @@
|
||||
"family": "speech.dictation-chunk",
|
||||
"namedDeltas": [],
|
||||
"values": {
|
||||
"028afac3649e": {
|
||||
"02cebe6f0062": {
|
||||
"failures": [],
|
||||
"pending": 0
|
||||
},
|
||||
"19c961e90970": {
|
||||
"name": "dictation-failed",
|
||||
"ordinal": 3,
|
||||
"value": {
|
||||
"id": "dictation-1"
|
||||
}
|
||||
},
|
||||
"2762d465637e": {
|
||||
"failures": ["Unknown method"],
|
||||
"pending": 0
|
||||
},
|
||||
"36a6b302a73d": {
|
||||
"name": "speech.dictation.chunk#1",
|
||||
"ordinal": 1,
|
||||
"args": [
|
||||
@@ -17,7 +32,8 @@
|
||||
"value": {
|
||||
"audioBase64": "ACVKb5S53gM=",
|
||||
"dictationId": "dictation-1",
|
||||
"sampleRate": 16000
|
||||
"sampleRate": 16000,
|
||||
"seq": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -43,27 +59,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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}}"
|
||||
},
|
||||
"19c961e90970": {
|
||||
"name": "dictation-failed",
|
||||
"ordinal": 3,
|
||||
"value": {
|
||||
"id": "dictation-1"
|
||||
}
|
||||
},
|
||||
"2762d465637e": {
|
||||
"failures": ["Unknown method"],
|
||||
"pending": 0
|
||||
},
|
||||
"30ae620f58dc": {
|
||||
"5133fafc8626": {
|
||||
"name": "speech.dictation.chunk#1",
|
||||
"ordinal": 1,
|
||||
"args": [
|
||||
@@ -76,41 +72,8 @@
|
||||
"value": {
|
||||
"audioBase64": "ACVKb5S53gM=",
|
||||
"dictationId": "dictation-1",
|
||||
"sampleRate": 16000
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"timeoutMs": 30000
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "rejected",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"error": {
|
||||
"category": "Error",
|
||||
"message": "transport failure",
|
||||
"isRpcDeliveryUnknown": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"62cfcef21f01": {
|
||||
"name": "speech.dictation.chunk#1",
|
||||
"ordinal": 1,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "speech.dictation.chunk"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"audioBase64": "ACVKb5S53gM=",
|
||||
"dictationId": "dictation-1",
|
||||
"sampleRate": 16000
|
||||
"sampleRate": 16000,
|
||||
"seq": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -126,11 +89,14 @@
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-1",
|
||||
"ok": true
|
||||
"ok": true,
|
||||
"result": {
|
||||
"received": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"66dece39b5ba": {
|
||||
"65e5e348650c": {
|
||||
"name": "speech.dictation.chunk#1",
|
||||
"ordinal": 1,
|
||||
"args": [
|
||||
@@ -143,7 +109,45 @@
|
||||
"value": {
|
||||
"audioBase64": "ACVKb5S53gM=",
|
||||
"dictationId": "dictation-1",
|
||||
"sampleRate": 16000
|
||||
"sampleRate": 16000,
|
||||
"seq": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"timeoutMs": 30000
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-1",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"error": "refused"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"66cc21847b0f": {
|
||||
"name": "speech.dictation.chunk#1",
|
||||
"ordinal": 1,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "speech.dictation.chunk"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"audioBase64": "ACVKb5S53gM=",
|
||||
"dictationId": "dictation-1",
|
||||
"sampleRate": 16000,
|
||||
"seq": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -167,7 +171,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"83a5bdf2ac1a": {
|
||||
"67bd399925f2": {
|
||||
"name": "speech.dictation.chunk#1",
|
||||
"ordinal": 1,
|
||||
"args": [
|
||||
@@ -180,7 +184,48 @@
|
||||
"value": {
|
||||
"audioBase64": "ACVKb5S53gM=",
|
||||
"dictationId": "dictation-1",
|
||||
"sampleRate": 16000
|
||||
"sampleRate": 16000,
|
||||
"seq": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"timeoutMs": 30000
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "rejected",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"error": {
|
||||
"category": "Error",
|
||||
"message": "transport failure",
|
||||
"isRpcDeliveryUnknown": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"70f6f0244686": {
|
||||
"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,\"seq\":0}}"
|
||||
},
|
||||
"8c69a5dd9dd6": {
|
||||
"name": "speech.dictation.chunk#1",
|
||||
"ordinal": 1,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "speech.dictation.chunk"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"audioBase64": "ACVKb5S53gM=",
|
||||
"dictationId": "dictation-1",
|
||||
"sampleRate": 16000,
|
||||
"seq": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -198,12 +243,12 @@
|
||||
"id": "frame-1",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"error": "refused"
|
||||
"$rpc": "null"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"888a29b4fd97": {
|
||||
"8da39029e37b": {
|
||||
"name": "speech.dictation.chunk#1",
|
||||
"ordinal": 1,
|
||||
"args": [
|
||||
@@ -216,7 +261,8 @@
|
||||
"value": {
|
||||
"audioBase64": "ACVKb5S53gM=",
|
||||
"dictationId": "dictation-1",
|
||||
"sampleRate": 16000
|
||||
"sampleRate": 16000,
|
||||
"seq": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -232,15 +278,11 @@
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-1",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"error": "inner refused",
|
||||
"ok": false
|
||||
}
|
||||
"ok": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"99956eef8fff": {
|
||||
"a280755140c5": {
|
||||
"name": "speech.dictation.chunk#1",
|
||||
"ordinal": 1,
|
||||
"args": [
|
||||
@@ -253,43 +295,8 @@
|
||||
"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
|
||||
"sampleRate": 16000,
|
||||
"seq": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -314,24 +321,7 @@
|
||||
"failures": ["outer refused"],
|
||||
"pending": 0
|
||||
},
|
||||
"b5157118341f": {
|
||||
"failures": [""],
|
||||
"pending": 0
|
||||
},
|
||||
"bc459c132276": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": [
|
||||
{
|
||||
"status": "fulfilled",
|
||||
"value": {
|
||||
"$rpc": "undefined"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"da1579f3254f": {
|
||||
"b0ee39c0ea35": {
|
||||
"name": "speech.dictation.chunk#1",
|
||||
"ordinal": 1,
|
||||
"args": [
|
||||
@@ -344,44 +334,8 @@
|
||||
"value": {
|
||||
"audioBase64": "ACVKb5S53gM=",
|
||||
"dictationId": "dictation-1",
|
||||
"sampleRate": 16000
|
||||
}
|
||||
},
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
},
|
||||
"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
|
||||
"sampleRate": 16000,
|
||||
"seq": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -405,7 +359,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"f779babe2a18": {
|
||||
"b5157118341f": {
|
||||
"failures": [""],
|
||||
"pending": 0
|
||||
},
|
||||
"bc459c132276": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": [
|
||||
{
|
||||
"status": "fulfilled",
|
||||
"value": {
|
||||
"$rpc": "undefined"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"e37b9cab121b": {
|
||||
"name": "speech.dictation.chunk#1",
|
||||
"ordinal": 1,
|
||||
"args": [
|
||||
@@ -418,7 +389,46 @@
|
||||
"value": {
|
||||
"audioBase64": "ACVKb5S53gM=",
|
||||
"dictationId": "dictation-1",
|
||||
"sampleRate": 16000
|
||||
"sampleRate": 16000,
|
||||
"seq": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
},
|
||||
"f6826a08b96a": {
|
||||
"name": "speech.dictation.chunk#1",
|
||||
"ordinal": 1,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "speech.dictation.chunk"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"audioBase64": "ACVKb5S53gM=",
|
||||
"dictationId": "dictation-1",
|
||||
"sampleRate": 16000,
|
||||
"seq": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -436,7 +446,8 @@
|
||||
"id": "frame-1",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"$rpc": "null"
|
||||
"error": "inner refused",
|
||||
"ok": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -448,8 +459,8 @@
|
||||
{
|
||||
"id": "speech-audio-chunk-acknowledged.normal:acknowledged",
|
||||
"observation": {
|
||||
"sender": ["99956eef8fff"],
|
||||
"payloads": ["12521d219054"],
|
||||
"sender": ["5133fafc8626"],
|
||||
"payloads": ["70f6f0244686"],
|
||||
"settlements": {
|
||||
"chunk": "bc459c132276"
|
||||
},
|
||||
@@ -460,8 +471,8 @@
|
||||
{
|
||||
"id": "speech-audio-chunk-acknowledged.result-absent:acknowledged",
|
||||
"observation": {
|
||||
"sender": ["62cfcef21f01"],
|
||||
"payloads": ["12521d219054"],
|
||||
"sender": ["8da39029e37b"],
|
||||
"payloads": ["70f6f0244686"],
|
||||
"settlements": {
|
||||
"chunk": "bc459c132276"
|
||||
},
|
||||
@@ -472,8 +483,8 @@
|
||||
{
|
||||
"id": "speech-audio-chunk-acknowledged.result-null:acknowledged",
|
||||
"observation": {
|
||||
"sender": ["f779babe2a18"],
|
||||
"payloads": ["12521d219054"],
|
||||
"sender": ["8c69a5dd9dd6"],
|
||||
"payloads": ["70f6f0244686"],
|
||||
"settlements": {
|
||||
"chunk": "bc459c132276"
|
||||
},
|
||||
@@ -484,8 +495,8 @@
|
||||
{
|
||||
"id": "speech-audio-chunk-acknowledged.inner-ok-missing:acknowledged",
|
||||
"observation": {
|
||||
"sender": ["83a5bdf2ac1a"],
|
||||
"payloads": ["12521d219054"],
|
||||
"sender": ["65e5e348650c"],
|
||||
"payloads": ["70f6f0244686"],
|
||||
"settlements": {
|
||||
"chunk": "bc459c132276"
|
||||
},
|
||||
@@ -496,8 +507,8 @@
|
||||
{
|
||||
"id": "speech-audio-chunk-acknowledged.inner-false-string-error:acknowledged",
|
||||
"observation": {
|
||||
"sender": ["888a29b4fd97"],
|
||||
"payloads": ["12521d219054"],
|
||||
"sender": ["f6826a08b96a"],
|
||||
"payloads": ["70f6f0244686"],
|
||||
"settlements": {
|
||||
"chunk": "bc459c132276"
|
||||
},
|
||||
@@ -508,8 +519,8 @@
|
||||
{
|
||||
"id": "speech-audio-chunk-acknowledged.inner-false-object-error:acknowledged",
|
||||
"observation": {
|
||||
"sender": ["028afac3649e"],
|
||||
"payloads": ["12521d219054"],
|
||||
"sender": ["36a6b302a73d"],
|
||||
"payloads": ["70f6f0244686"],
|
||||
"settlements": {
|
||||
"chunk": "bc459c132276"
|
||||
},
|
||||
@@ -520,8 +531,8 @@
|
||||
{
|
||||
"id": "speech-audio-chunk-acknowledged.outer-refused:acknowledged",
|
||||
"observation": {
|
||||
"sender": ["66dece39b5ba"],
|
||||
"payloads": ["12521d219054"],
|
||||
"sender": ["66cc21847b0f"],
|
||||
"payloads": ["70f6f0244686"],
|
||||
"settlements": {
|
||||
"chunk": "bc459c132276"
|
||||
},
|
||||
@@ -532,8 +543,8 @@
|
||||
{
|
||||
"id": "speech-audio-chunk-acknowledged.outer-refused-no-message:acknowledged",
|
||||
"observation": {
|
||||
"sender": ["f6a8a6f550f5"],
|
||||
"payloads": ["12521d219054"],
|
||||
"sender": ["b0ee39c0ea35"],
|
||||
"payloads": ["70f6f0244686"],
|
||||
"settlements": {
|
||||
"chunk": "bc459c132276"
|
||||
},
|
||||
@@ -544,8 +555,8 @@
|
||||
{
|
||||
"id": "speech-audio-chunk-acknowledged.method-not-found:acknowledged",
|
||||
"observation": {
|
||||
"sender": ["da1579f3254f"],
|
||||
"payloads": ["12521d219054"],
|
||||
"sender": ["e37b9cab121b"],
|
||||
"payloads": ["70f6f0244686"],
|
||||
"settlements": {
|
||||
"chunk": "bc459c132276"
|
||||
},
|
||||
@@ -556,8 +567,8 @@
|
||||
{
|
||||
"id": "speech-audio-chunk-acknowledged.transport-rejection:acknowledged",
|
||||
"observation": {
|
||||
"sender": ["30ae620f58dc"],
|
||||
"payloads": ["12521d219054"],
|
||||
"sender": ["67bd399925f2"],
|
||||
"payloads": ["70f6f0244686"],
|
||||
"settlements": {
|
||||
"chunk": "bc459c132276"
|
||||
},
|
||||
@@ -568,8 +579,8 @@
|
||||
{
|
||||
"id": "speech-audio-chunk-acknowledged.transport-rejection-no-message:acknowledged",
|
||||
"observation": {
|
||||
"sender": ["9d43743b1b10"],
|
||||
"payloads": ["12521d219054"],
|
||||
"sender": ["a280755140c5"],
|
||||
"payloads": ["70f6f0244686"],
|
||||
"settlements": {
|
||||
"chunk": "bc459c132276"
|
||||
},
|
||||
|
||||
@@ -8,12 +8,7 @@
|
||||
"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}}"
|
||||
},
|
||||
"99956eef8fff": {
|
||||
"5133fafc8626": {
|
||||
"name": "speech.dictation.chunk#1",
|
||||
"ordinal": 1,
|
||||
"args": [
|
||||
@@ -26,7 +21,8 @@
|
||||
"value": {
|
||||
"audioBase64": "ACVKb5S53gM=",
|
||||
"dictationId": "dictation-1",
|
||||
"sampleRate": 16000
|
||||
"sampleRate": 16000,
|
||||
"seq": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -49,6 +45,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"70f6f0244686": {
|
||||
"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,\"seq\":0}}"
|
||||
},
|
||||
"bc459c132276": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
@@ -69,8 +70,8 @@
|
||||
{
|
||||
"id": "acknowledged",
|
||||
"observation": {
|
||||
"sender": ["99956eef8fff"],
|
||||
"payloads": ["12521d219054"],
|
||||
"sender": ["5133fafc8626"],
|
||||
"payloads": ["70f6f0244686"],
|
||||
"settlements": {
|
||||
"chunk": "bc459c132276"
|
||||
},
|
||||
|
||||
@@ -14549,7 +14549,8 @@
|
||||
"params": {
|
||||
"audioBase64": "ACVKb5S53gM=",
|
||||
"dictationId": "dictation-1",
|
||||
"sampleRate": 16000
|
||||
"sampleRate": 16000,
|
||||
"seq": 0
|
||||
},
|
||||
"reply": {
|
||||
"ok": true,
|
||||
|
||||
@@ -52,18 +52,26 @@ export function useMobileDictationTranscriptSoFar() {
|
||||
}
|
||||
|
||||
// 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.
|
||||
// With resendWhile the host dedupes, so a request whose reply a drop lost goes again after each reconnect too, while it still holds.
|
||||
export async function sendAcrossConnectionMove<T>(
|
||||
client: RpcClient,
|
||||
send: () => Promise<T>
|
||||
send: () => Promise<T>,
|
||||
resendWhile?: () => boolean
|
||||
): Promise<T> {
|
||||
const again = () => (resendWhile ? sendAcrossConnectionMove(client, send, resendWhile) : send())
|
||||
try {
|
||||
return await send()
|
||||
} catch (err) {
|
||||
if (isLogicalClientCutoverError(err) && !isRpcDeliveryUnknown(err)) {
|
||||
return send()
|
||||
return again()
|
||||
}
|
||||
if (isLogicalClientSuspendedError(err) && (await waitForReconnect(client))) {
|
||||
return send()
|
||||
// A timeout on a connection still up gets no second try there; a reply lost to a drop does.
|
||||
const lostToDrop = isLogicalClientCutoverError(err) || client.getState() !== 'connected'
|
||||
const resend =
|
||||
isLogicalClientSuspendedError(err) ||
|
||||
(resendWhile !== undefined && isRpcDeliveryUnknown(err) && lostToDrop)
|
||||
if (resend && (await waitForReconnect(client)) && (resendWhile?.() ?? true)) {
|
||||
return again()
|
||||
}
|
||||
throw err
|
||||
}
|
||||
@@ -130,13 +138,18 @@ export function enqueueMobileDictationAudioChunk(
|
||||
const params = {
|
||||
dictationId,
|
||||
audioBase64: bytesToBase64(bytes),
|
||||
sampleRate: MOBILE_DICTATION_PCM_SAMPLE_RATE
|
||||
sampleRate: MOBILE_DICTATION_PCM_SAMPLE_RATE,
|
||||
seq: queue.pendingAudioBudget.nextSeq()
|
||||
}
|
||||
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
|
||||
})
|
||||
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
|
||||
}),
|
||||
// A recording cancelled during the outage must not reach the host when it comes back.
|
||||
queue.pendingAudioBudget.hostDedupesResends ? () => queue.isActive(dictationId) : undefined
|
||||
)
|
||||
.then((reply) => {
|
||||
// Only newer hosts send the text, and only when it changed; any other result still acknowledges the chunk.
|
||||
@@ -147,7 +160,7 @@ export function enqueueMobileDictationAudioChunk(
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
// The host keeps the session through a reconnect; only audio in flight at the drop is lost.
|
||||
// The host keeps the session through a reconnect; a host that cannot dedupe loses only the audio in flight at the drop.
|
||||
if (!isRpcDeliveryUnknown(err)) {
|
||||
queue.failActiveDictation(dictationId, err)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
dictationSessionStart
|
||||
} from '../dictation/mobile-dictation-operations'
|
||||
import { MOBILE_DICTATION_RECONNECT_GRACE_MS } from '../../../src/shared/rpc-contract/speech-params'
|
||||
import { rpcPayloadMember } from '../transport/rpc-reader-payload'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
|
||||
type StartMobileDictationDesktopSessionOptions = {
|
||||
@@ -16,7 +17,7 @@ type StartMobileDictationDesktopSessionOptions = {
|
||||
getActiveId: () => string | null
|
||||
clearActiveId: (dictationId: string) => void
|
||||
setIdle: () => void
|
||||
commitRecordingStart: () => boolean
|
||||
commitRecordingStart: (hostDedupesResends: boolean) => boolean
|
||||
rollbackRecordingStart: () => void
|
||||
}
|
||||
|
||||
@@ -61,6 +62,7 @@ export async function startMobileDictationDesktopSession(
|
||||
options: StartMobileDictationDesktopSessionOptions
|
||||
): Promise<boolean> {
|
||||
const { client, dictationId } = options
|
||||
let hostDedupesResends = false
|
||||
|
||||
try {
|
||||
// The mic is already open, so a start stalled by a drop must give up rather than hold it.
|
||||
@@ -69,7 +71,9 @@ export async function startMobileDictationDesktopSession(
|
||||
{ dictationId },
|
||||
{ timeoutMs: MOBILE_DICTATION_RECONNECT_GRACE_MS }
|
||||
)
|
||||
dictationSessionStart.interpret(reply)
|
||||
const started = dictationSessionStart.interpret(reply)
|
||||
hostDedupesResends =
|
||||
started != null && rpcPayloadMember(started, 'dedupesResentChunks') === true
|
||||
} catch (err) {
|
||||
const wasCurrent = isCurrentStart(options)
|
||||
// Capture already holds the screen; roll both back only while this start is current. A stale start could otherwise stop its replacement’s microphone. Capture itself has no start identity.
|
||||
@@ -100,7 +104,7 @@ export async function startMobileDictationDesktopSession(
|
||||
|
||||
try {
|
||||
// Commit before returning so queued cancellation cannot resurrect recording after cleanup.
|
||||
if (!options.commitRecordingStart()) {
|
||||
if (!options.commitRecordingStart(hostDedupesResends)) {
|
||||
throw new Error(
|
||||
translate(
|
||||
'hooks.dictationDesktopStart.recordingFailed',
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
import {
|
||||
DICTATION_SAMPLE_RATE,
|
||||
MOBILE_DICTATION_RECONNECT_GRACE_MS,
|
||||
PCM_BYTES_PER_SAMPLE
|
||||
MOBILE_DICTATION_MAX_PENDING_AUDIO_BYTES
|
||||
} from '../../../src/shared/rpc-contract/speech-params'
|
||||
|
||||
export const MOBILE_DICTATION_PCM_SAMPLE_RATE = DICTATION_SAMPLE_RATE
|
||||
|
||||
// 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.
|
||||
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 =
|
||||
'Connection is too slow for voice dictation. Try again when the connection improves.'
|
||||
|
||||
export class MobileDictationPendingAudioBudget {
|
||||
private pendingBytes = 0
|
||||
private sentChunks = 0
|
||||
// Set at each start: a host that feeds each chunk seq once can take audio a drop left unanswered again.
|
||||
hostDedupesResends = false
|
||||
|
||||
constructor(private readonly maxPendingBytes = MOBILE_DICTATION_MAX_PENDING_AUDIO_BYTES) {}
|
||||
|
||||
@@ -26,6 +20,10 @@ export class MobileDictationPendingAudioBudget {
|
||||
return this.pendingBytes
|
||||
}
|
||||
|
||||
nextSeq(): number {
|
||||
return this.sentChunks++
|
||||
}
|
||||
|
||||
tryReserve(byteLength: number): boolean {
|
||||
const normalizedByteLength = normalizeByteLength(byteLength)
|
||||
if (this.pendingBytes + normalizedByteLength > this.maxPendingBytes) {
|
||||
@@ -39,8 +37,10 @@ export class MobileDictationPendingAudioBudget {
|
||||
this.pendingBytes = Math.max(0, this.pendingBytes - normalizeByteLength(byteLength))
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
reset(hostDedupesResends = false): void {
|
||||
this.pendingBytes = 0
|
||||
this.sentChunks = 0
|
||||
this.hostDedupesResends = hostDedupesResends
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type SentRequest
|
||||
} from '../mobile-web-shell/bridge-host-test-fakes'
|
||||
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
|
||||
import { rpcPayloadMember } from '../transport/rpc-reader-payload'
|
||||
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
import type {
|
||||
@@ -182,6 +183,71 @@ describe('dictation through a reconnect', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('audio sent on a connection that died', () => {
|
||||
async function dropMidChunk(
|
||||
startResult: Record<string, unknown>,
|
||||
{ cancelOffline = false } = {}
|
||||
) {
|
||||
let state: ConnectionState = 'connected'
|
||||
const rpc = createFakeRpcClient({ getState: () => state })
|
||||
const sent: SentRequest[] = []
|
||||
mount(rpc, [], [])
|
||||
await act(async () => {
|
||||
const started = dictation().start()
|
||||
while (rpc.requests.length === 0) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
rpc.requests.splice(0)[0].resolve({ id: 'desktop', ok: true, result: startResult })
|
||||
await started
|
||||
})
|
||||
const speak = () => {
|
||||
for (const handler of seam.chunkHandlers) {
|
||||
handler({ data: Uint8Array.from([1, 2]), droppedBytes: 0 })
|
||||
}
|
||||
}
|
||||
await act(async () => {
|
||||
speak()
|
||||
speak()
|
||||
const [answered, lost] = rpc.requests.splice(0)
|
||||
sent.push(answered, lost)
|
||||
answered.resolve({ id: 'desktop', ok: true, result: {} })
|
||||
state = 'reconnecting'
|
||||
lost.reject(markRpcDeliveryUnknown(new Error('Connection interrupted')))
|
||||
await Promise.resolve()
|
||||
speak()
|
||||
})
|
||||
if (cancelOffline) {
|
||||
await act(async () => {
|
||||
const cancelled = dictation().cancel()
|
||||
await pump(rpc, sent, [])
|
||||
await cancelled
|
||||
})
|
||||
}
|
||||
await act(async () => {
|
||||
state = 'connected'
|
||||
rpc.pushState('connected')
|
||||
await pump(rpc, sent, [])
|
||||
})
|
||||
return sent
|
||||
.filter((request) => request.method === 'speech.dictation.chunk')
|
||||
.map((request) => rpcPayloadMember(request.args[1], 'seq'))
|
||||
}
|
||||
|
||||
it('goes again to a host that feeds each chunk once', async () => {
|
||||
await expect(dropMidChunk({ dedupesResentChunks: true })).resolves.toEqual([0, 1, 2, 1])
|
||||
})
|
||||
|
||||
it('is not resent once the recording was cancelled during the outage', async () => {
|
||||
await expect(
|
||||
dropMidChunk({ dedupesResentChunks: true }, { cancelOffline: true })
|
||||
).resolves.toEqual([0, 1, 2])
|
||||
})
|
||||
|
||||
it('never goes twice to a host that would feed it twice', async () => {
|
||||
await expect(dropMidChunk({})).resolves.toEqual([0, 1, 2])
|
||||
})
|
||||
})
|
||||
|
||||
describe('the text so far', () => {
|
||||
it('shows what the host heard while recording and clears it as the final text lands', async () => {
|
||||
const rpc = createFakeRpcClient()
|
||||
|
||||
@@ -171,10 +171,10 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
|
||||
}
|
||||
},
|
||||
setIdle: () => applyStatus('idle'),
|
||||
commitRecordingStart: () => {
|
||||
commitRecordingStart: (hostDedupesResends) => {
|
||||
acceptingChunksRef.current = true
|
||||
pendingChunksRef.current.clear()
|
||||
pendingAudioBudgetRef.current.reset()
|
||||
pendingAudioBudgetRef.current.reset(hostDedupesResends)
|
||||
if (!capture.begin()) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { MOBILE_DICTATION_MAX_PENDING_AUDIO_BYTES } from '../../shared/rpc-contract/speech-params'
|
||||
|
||||
export type MobileDictationChunk = { audioBase64: string; sampleRate: number }
|
||||
|
||||
// Past what the phone holds through a reconnect, a missing chunk is given up on.
|
||||
const MAX_HELD_BASE64_LENGTH = Math.ceil(MOBILE_DICTATION_MAX_PENDING_AUDIO_BYTES / 3) * 4
|
||||
// The grace's worth of native microphone events, one chunk per ~32ms; tiny chunks must not pile up under the byte cap.
|
||||
const MAX_HELD_CHUNKS = 1_000
|
||||
|
||||
// A chunk resent after a drop can land behind audio captured later, so release audio in seq order and each seq once.
|
||||
export class MobileDictationChunkOrder {
|
||||
private nextSeq = 0
|
||||
private heldLength = 0
|
||||
private readonly held = new Map<number, MobileDictationChunk>()
|
||||
|
||||
accept(seq: number, chunk: MobileDictationChunk): MobileDictationChunk[] {
|
||||
if (seq < this.nextSeq || this.held.has(seq)) {
|
||||
return []
|
||||
}
|
||||
this.held.set(seq, chunk)
|
||||
this.heldLength += chunk.audioBase64.length
|
||||
const ready = this.releaseInOrder()
|
||||
while (this.heldLength > MAX_HELD_BASE64_LENGTH || this.held.size > MAX_HELD_CHUNKS) {
|
||||
this.nextSeq = Infinity
|
||||
for (const held of this.held.keys()) {
|
||||
this.nextSeq = Math.min(this.nextSeq, held)
|
||||
}
|
||||
ready.push(...this.releaseInOrder())
|
||||
}
|
||||
return ready
|
||||
}
|
||||
|
||||
private releaseInOrder(): MobileDictationChunk[] {
|
||||
const ready: MobileDictationChunk[] = []
|
||||
for (let next = this.held.get(this.nextSeq); next; next = this.held.get(this.nextSeq)) {
|
||||
ready.push(next)
|
||||
this.held.delete(this.nextSeq)
|
||||
this.heldLength -= next.audioBase64.length
|
||||
this.nextSeq += 1
|
||||
}
|
||||
return ready
|
||||
}
|
||||
|
||||
// What is still held when the phone finishes, skipping chunks that never came.
|
||||
drain(): MobileDictationChunk[] {
|
||||
const rest = [...this.held].sort(([a], [b]) => a - b).map(([, chunk]) => chunk)
|
||||
this.held.clear()
|
||||
this.heldLength = 0
|
||||
return rest
|
||||
}
|
||||
}
|
||||
@@ -18,18 +18,20 @@ const stopDictation = vi.fn(async (_owner: string, options?: { discard?: boolean
|
||||
await transcription
|
||||
}
|
||||
})
|
||||
const feedAudio = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
stopDictation.mockClear()
|
||||
transcription = Promise.resolve()
|
||||
feedAudio.mockClear()
|
||||
const factories = {
|
||||
createModelManager: () => ({ getModelState: async () => ({ status: 'ready' }) }),
|
||||
createSttService: () => ({
|
||||
startDictation: async (_modelId: string, next: Sink) => {
|
||||
sink = next
|
||||
},
|
||||
feedAudio: () => {},
|
||||
feedAudio,
|
||||
stopDictation
|
||||
})
|
||||
}
|
||||
@@ -81,6 +83,51 @@ describe('mobile dictation across a phone reconnect', () => {
|
||||
expect(stopDictation).toHaveBeenCalledOnce()
|
||||
expect(() => controller.feed(chunk)).toThrow('dictation_stream_not_started')
|
||||
})
|
||||
|
||||
it('feeds resent audio once and in the order it was spoken', async () => {
|
||||
const controller = await startPhoneDictation()
|
||||
const spoken = (seq: number) => ({
|
||||
...chunk,
|
||||
seq,
|
||||
audioBase64: Buffer.from(Int16Array.of(seq).buffer).toString('base64')
|
||||
})
|
||||
|
||||
// 1 is resent after 2 overtook it; 0 and 1 arrive twice; 3 never comes.
|
||||
for (const seq of [0, 2, 0, 1, 1, 4]) {
|
||||
controller.feed(spoken(seq))
|
||||
}
|
||||
await controller.finish({ dictationId: 'd-1', clientId: 'phone-a' })
|
||||
|
||||
const fed = feedAudio.mock.calls.map(([samples]) => Math.round(samples[0] * 32768))
|
||||
expect(fed).toEqual([0, 1, 2, 4])
|
||||
})
|
||||
|
||||
// 35s of speech, or more tiny chunks than 30s of native microphone events, is more than a reconnect holds back.
|
||||
it.each([
|
||||
{ held: 'long chunks', count: 7, audioBase64: Buffer.alloc(160_000).toString('base64') },
|
||||
{ held: 'tiny chunks', count: 1001, audioBase64: 'AAA=' }
|
||||
])('stops waiting for a chunk that never comes behind $held', async ({ count, audioBase64 }) => {
|
||||
const controller = await startPhoneDictation()
|
||||
|
||||
for (let seq = 1; seq <= count; seq += 1) {
|
||||
controller.feed({ ...chunk, audioBase64, seq })
|
||||
}
|
||||
|
||||
expect(feedAudio).toHaveBeenCalledTimes(count)
|
||||
})
|
||||
it('gives up on as many gaps as it takes to stay within what a reconnect holds back', async () => {
|
||||
const controller = await startPhoneDictation()
|
||||
const fiveSeconds = Buffer.alloc(160_000).toString('base64')
|
||||
|
||||
// Every other chunk is missing: ten tiny ones, then ten of five seconds each.
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
const audioBase64 = index < 10 ? 'AAA=' : fiveSeconds
|
||||
controller.feed({ ...chunk, audioBase64, seq: 2 * index + 1 })
|
||||
}
|
||||
|
||||
// At most five 5s chunks stay held under the 30s cap.
|
||||
expect(feedAudio).toHaveBeenCalledTimes(15)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mobile dictation cancel', () => {
|
||||
@@ -92,6 +139,6 @@ describe('mobile dictation cancel', () => {
|
||||
|
||||
await expect(
|
||||
controller.start({ dictationId: 'd-2', modelId: 'test-model', clientId: 'phone-a' })
|
||||
).resolves.toEqual({ dictationId: 'd-2', modelId: 'test-model' })
|
||||
).resolves.toEqual({ dictationId: 'd-2', modelId: 'test-model', dedupesResentChunks: true })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
hasLocalSpeechRecognizer,
|
||||
LOCAL_SPEECH_UNAVAILABLE_ERROR
|
||||
} from '../speech/stt-worker-paths'
|
||||
import {
|
||||
MobileDictationChunkOrder,
|
||||
type MobileDictationChunk
|
||||
} from './mobile-dictation-chunk-order'
|
||||
import type { RuntimeStore } from './runtime-store-contract'
|
||||
|
||||
type MobileDictationSession = {
|
||||
@@ -20,6 +24,7 @@ type MobileDictationSession = {
|
||||
textVersion: number
|
||||
reportedVersion: number
|
||||
reportedAt: number
|
||||
chunkOrder: MobileDictationChunkOrder
|
||||
abandonTimer?: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
@@ -32,7 +37,7 @@ export class RuntimeMobileDictationController {
|
||||
dictationId: string
|
||||
modelId?: string
|
||||
clientId?: string
|
||||
}): Promise<{ dictationId: string; modelId: string }> {
|
||||
}): Promise<{ dictationId: string; modelId: string; dedupesResentChunks: true }> {
|
||||
const store = this.requireStore()
|
||||
const voice = store.getSettings().voice ?? getDefaultVoiceSettings()
|
||||
if (!voice.enabled) {
|
||||
@@ -72,7 +77,8 @@ export class RuntimeMobileDictationController {
|
||||
errors: [],
|
||||
textVersion: 0,
|
||||
reportedVersion: 0,
|
||||
reportedAt: 0
|
||||
reportedAt: 0,
|
||||
chunkOrder: new MobileDictationChunkOrder()
|
||||
}
|
||||
try {
|
||||
await getSpeechSttService(store).startDictation(
|
||||
@@ -97,13 +103,15 @@ export class RuntimeMobileDictationController {
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return { dictationId: params.dictationId, modelId }
|
||||
// Tells the phone it may resend chunks a drop left unanswered.
|
||||
return { dictationId: params.dictationId, modelId, dedupesResentChunks: true }
|
||||
}
|
||||
|
||||
feed(params: {
|
||||
dictationId: string
|
||||
audioBase64: string
|
||||
sampleRate: number
|
||||
seq?: number
|
||||
clientId?: string
|
||||
}): { dictationId: string; text?: string } {
|
||||
const session = this.requireOwnedSession(params)
|
||||
@@ -113,12 +121,12 @@ export class RuntimeMobileDictationController {
|
||||
if (session.errors.length > 0) {
|
||||
throw new Error(session.errors[0])
|
||||
}
|
||||
const pcm = Buffer.from(params.audioBase64, 'base64')
|
||||
const samples = new Float32Array(Math.floor(pcm.length / 2))
|
||||
for (let i = 0; i < samples.length; i += 1) {
|
||||
samples[i] = pcm.readInt16LE(i * 2) / 32768
|
||||
// Phones that send no seq never resend.
|
||||
const ready =
|
||||
params.seq === undefined ? [params] : session.chunkOrder.accept(params.seq, params)
|
||||
for (const chunk of ready) {
|
||||
this.feedChunk(session, chunk)
|
||||
}
|
||||
getSpeechSttService(this.requireStore()).feedAudio(samples, params.sampleRate, session.owner)
|
||||
// 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) {
|
||||
@@ -134,6 +142,9 @@ export class RuntimeMobileDictationController {
|
||||
clientId?: string
|
||||
}): Promise<{ dictationId: string; text: string }> {
|
||||
const session = this.requireOwnedSession(params)
|
||||
for (const chunk of session.chunkOrder.drain()) {
|
||||
this.feedChunk(session, chunk)
|
||||
}
|
||||
await this.stopSession(session, { discard: false })
|
||||
if (session.errors.length > 0) {
|
||||
throw new Error(session.errors[0])
|
||||
@@ -152,6 +163,15 @@ export class RuntimeMobileDictationController {
|
||||
return { dictationId: params.dictationId }
|
||||
}
|
||||
|
||||
private feedChunk(session: MobileDictationSession, chunk: MobileDictationChunk): void {
|
||||
const pcm = Buffer.from(chunk.audioBase64, 'base64')
|
||||
const samples = new Float32Array(Math.floor(pcm.length / 2))
|
||||
for (let i = 0; i < samples.length; i += 1) {
|
||||
samples[i] = pcm.readInt16LE(i * 2) / 32768
|
||||
}
|
||||
getSpeechSttService(this.requireStore()).feedAudio(samples, chunk.sampleRate, session.owner)
|
||||
}
|
||||
|
||||
private acceptEvent(
|
||||
dictationId: string,
|
||||
event: { type: string; text?: string; error?: string }
|
||||
|
||||
@@ -12,6 +12,10 @@ 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
|
||||
|
||||
// The speech a phone holds through a reconnect, and so what the host holds back behind a missing resend.
|
||||
export const MOBILE_DICTATION_MAX_PENDING_AUDIO_BYTES =
|
||||
(DICTATION_SAMPLE_RATE * PCM_BYTES_PER_SAMPLE * MOBILE_DICTATION_RECONNECT_GRACE_MS) / 1000
|
||||
|
||||
export const MAX_DICTATION_AUDIO_CHUNK_BYTES =
|
||||
DICTATION_SAMPLE_RATE * PCM_BYTES_PER_SAMPLE * MAX_DICTATION_AUDIO_SECONDS
|
||||
|
||||
@@ -37,7 +41,9 @@ export const DictationChunk = z.object({
|
||||
)
|
||||
// Buffer.from silently discards malformed base64; refuse corrupt PCM instead.
|
||||
.refine(isValidAudioBase64, 'Audio chunk must be base64'),
|
||||
sampleRate: z.number().finite().positive()
|
||||
sampleRate: z.number().finite().positive(),
|
||||
// Counts up from 0 per dictation, so a chunk resent after a drop is fed once.
|
||||
seq: z.number().int().nonnegative().optional()
|
||||
})
|
||||
|
||||
export const DictationHandle = z.object({
|
||||
|
||||
Reference in New Issue
Block a user