@frockbot/plugin-shell 0.3.13 → 0.3.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +33 -32
- package/src/client/FrockBotApp.vue +232 -1
- package/src/client/index.ts +7 -0
- package/src/client/styles.css +44 -0
- package/src/client/voice-dictation.test.ts +105 -0
- package/src/client/voice-dictation.ts +137 -0
- package/src/client/voice-microphone.ts +125 -0
- package/src/client/voice-worklet.ts +75 -0
- package/src/shared.ts +17 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-shell",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.14",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"./client/FrockBotApp.vue": "./src/client/FrockBotApp.vue",
|
|
28
28
|
"./client/notify": "./src/client/notify.ts",
|
|
29
29
|
"./client/styles.css": "./src/client/styles.css",
|
|
30
|
+
"./client/voice-worklet": "./src/client/voice-worklet.ts",
|
|
30
31
|
"./focus": "./src/focus.ts",
|
|
31
32
|
"./frockbot.json": "./frockbot.json",
|
|
32
33
|
"./host": "./src/host.ts",
|
|
@@ -50,42 +51,42 @@
|
|
|
50
51
|
},
|
|
51
52
|
"dependencies": {
|
|
52
53
|
"@cordisjs/plugin-webui": "0.8.2",
|
|
53
|
-
"@frockbot/agent-runtime": "0.3.
|
|
54
|
-
"@frockbot/application-foundation": "0.3.
|
|
55
|
-
"@frockbot/catalog-core": "0.3.
|
|
56
|
-
"@frockbot/client-core": "0.3.
|
|
57
|
-
"@frockbot/client-ui": "0.3.
|
|
58
|
-
"@frockbot/computer-core": "0.3.
|
|
59
|
-
"@frockbot/computer-host-protocol": "0.3.
|
|
60
|
-
"@frockbot/configuration-core": "0.3.
|
|
61
|
-
"@frockbot/connection-core": "0.3.
|
|
62
|
-
"@frockbot/kernel-agent-loop": "0.3.
|
|
63
|
-
"@frockbot/kernel-composition": "0.3.
|
|
64
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
65
|
-
"@frockbot/kernel-do": "0.3.
|
|
66
|
-
"@frockbot/machine-protocol": "0.3.
|
|
67
|
-
"@frockbot/plugin-applets": "0.3.
|
|
68
|
-
"@frockbot/plugin-authoring": "0.3.
|
|
69
|
-
"@frockbot/plugin-bot-template": "0.3.
|
|
70
|
-
"@frockbot/plugin-computer": "0.3.
|
|
71
|
-
"@frockbot/plugin-flock": "0.3.
|
|
72
|
-
"@frockbot/plugin-image": "0.3.
|
|
73
|
-
"@frockbot/plugin-machine-messages": "0.3.
|
|
74
|
-
"@frockbot/plugin-mcp": "0.3.
|
|
75
|
-
"@frockbot/plugin-memory": "0.3.
|
|
76
|
-
"@frockbot/plugin-package-catalog": "0.3.
|
|
77
|
-
"@frockbot/plugin-routines": "0.3.
|
|
78
|
-
"@frockbot/plugin-skills": "0.3.
|
|
79
|
-
"@frockbot/plugin-subagents": "0.3.
|
|
80
|
-
"@frockbot/plugin-user-machine": "0.3.
|
|
81
|
-
"@frockbot/protocol": "0.3.
|
|
54
|
+
"@frockbot/agent-runtime": "0.3.14",
|
|
55
|
+
"@frockbot/application-foundation": "0.3.14",
|
|
56
|
+
"@frockbot/catalog-core": "0.3.14",
|
|
57
|
+
"@frockbot/client-core": "0.3.14",
|
|
58
|
+
"@frockbot/client-ui": "0.3.14",
|
|
59
|
+
"@frockbot/computer-core": "0.3.14",
|
|
60
|
+
"@frockbot/computer-host-protocol": "0.3.14",
|
|
61
|
+
"@frockbot/configuration-core": "0.3.14",
|
|
62
|
+
"@frockbot/connection-core": "0.3.14",
|
|
63
|
+
"@frockbot/kernel-agent-loop": "0.3.14",
|
|
64
|
+
"@frockbot/kernel-composition": "0.3.14",
|
|
65
|
+
"@frockbot/kernel-contracts": "0.3.14",
|
|
66
|
+
"@frockbot/kernel-do": "0.3.14",
|
|
67
|
+
"@frockbot/machine-protocol": "0.3.14",
|
|
68
|
+
"@frockbot/plugin-applets": "0.3.14",
|
|
69
|
+
"@frockbot/plugin-authoring": "0.3.14",
|
|
70
|
+
"@frockbot/plugin-bot-template": "0.3.14",
|
|
71
|
+
"@frockbot/plugin-computer": "0.3.14",
|
|
72
|
+
"@frockbot/plugin-flock": "0.3.14",
|
|
73
|
+
"@frockbot/plugin-image": "0.3.14",
|
|
74
|
+
"@frockbot/plugin-machine-messages": "0.3.14",
|
|
75
|
+
"@frockbot/plugin-mcp": "0.3.14",
|
|
76
|
+
"@frockbot/plugin-memory": "0.3.14",
|
|
77
|
+
"@frockbot/plugin-package-catalog": "0.3.14",
|
|
78
|
+
"@frockbot/plugin-routines": "0.3.14",
|
|
79
|
+
"@frockbot/plugin-skills": "0.3.14",
|
|
80
|
+
"@frockbot/plugin-subagents": "0.3.14",
|
|
81
|
+
"@frockbot/plugin-user-machine": "0.3.14",
|
|
82
|
+
"@frockbot/protocol": "0.3.14",
|
|
82
83
|
"cordis": "4.0.0-rc.8",
|
|
83
84
|
"vue": "3.5.41"
|
|
84
85
|
},
|
|
85
86
|
"devDependencies": {
|
|
86
87
|
"@cloudflare/workers-types": "latest",
|
|
87
|
-
"@frockbot/plugin-prompt": "0.3.
|
|
88
|
-
"@frockbot/plugin-tools": "0.3.
|
|
88
|
+
"@frockbot/plugin-prompt": "0.3.14",
|
|
89
|
+
"@frockbot/plugin-tools": "0.3.14",
|
|
89
90
|
"@types/bun": "1.3.6",
|
|
90
91
|
"@types/node": "26.2.0",
|
|
91
92
|
"@vitejs/plugin-vue": "6.0.8",
|
|
@@ -28,6 +28,19 @@ import {
|
|
|
28
28
|
type WebToolActivity,
|
|
29
29
|
} from "../shared.js";
|
|
30
30
|
import { ComposerDraftStore } from "./composer-draft.js";
|
|
31
|
+
import {
|
|
32
|
+
applyDictationTailV1,
|
|
33
|
+
voiceButtonLabelV1,
|
|
34
|
+
voiceWaveBarsV1,
|
|
35
|
+
VoiceDictationTranscriptV1,
|
|
36
|
+
type VoiceDictationStateV1,
|
|
37
|
+
} from "./voice-dictation.js";
|
|
38
|
+
import {
|
|
39
|
+
startVoiceMicrophoneV1,
|
|
40
|
+
voiceMicrophoneRefusalV1,
|
|
41
|
+
type VoiceMicrophoneV1,
|
|
42
|
+
} from "./voice-microphone.js";
|
|
43
|
+
import type { VoiceDictationSessionV1 } from "@frockbot/client-core";
|
|
31
44
|
import {
|
|
32
45
|
activityTrailBeginV1,
|
|
33
46
|
activityTrailSampleV1,
|
|
@@ -372,6 +385,149 @@ const canSend = computed(
|
|
|
372
385
|
*/
|
|
373
386
|
const showStop = computed(() => isRunning.value && !canSend.value);
|
|
374
387
|
|
|
388
|
+
/*
|
|
389
|
+
* Dictation (voice plan D4).
|
|
390
|
+
*
|
|
391
|
+
* The send button's slot already switches between Send and Stop, and this is
|
|
392
|
+
* the third thing it holds: with nothing to send and nothing to stop, the
|
|
393
|
+
* button offers to listen instead. While it is listening the slot carries a
|
|
394
|
+
* bin and a send, because those are the only two things a person wants next.
|
|
395
|
+
*
|
|
396
|
+
* Nothing here is a second composer. Speech lands in the same draft the
|
|
397
|
+
* keyboard writes to, through the same `ComposerDraftStore`, and Send is the
|
|
398
|
+
* ordinary `sendMessage` — so a message that is half spoken and half typed
|
|
399
|
+
* behaves exactly like one that is entirely typed, including a refusal that
|
|
400
|
+
* gives the draft back.
|
|
401
|
+
*/
|
|
402
|
+
const voiceState = ref<VoiceDictationStateV1>("idle");
|
|
403
|
+
const voiceError = ref<string | undefined>(undefined);
|
|
404
|
+
const voiceBars = ref<number[]>(voiceWaveBarsV1(0, []));
|
|
405
|
+
const voiceTranscript = new VoiceDictationTranscriptV1();
|
|
406
|
+
let voiceSession: VoiceDictationSessionV1 | undefined;
|
|
407
|
+
let voiceMicrophone: VoiceMicrophoneV1 | undefined;
|
|
408
|
+
/** The exact text dictation last wrote, so a typed edit around it survives. */
|
|
409
|
+
let voiceTail = "";
|
|
410
|
+
/** Send was pressed; the last transcript is what we are waiting for. */
|
|
411
|
+
let voiceSendOnFinal = false;
|
|
412
|
+
|
|
413
|
+
const dictating = computed(() => voiceState.value !== "idle");
|
|
414
|
+
const voiceButtonLabel = computed(() => voiceButtonLabelV1(voiceState.value));
|
|
415
|
+
/**
|
|
416
|
+
* The wave button takes the slot only when the slot is otherwise idle: an
|
|
417
|
+
* empty draft, no Turn to stop, and a platform that can actually listen.
|
|
418
|
+
*/
|
|
419
|
+
const showVoiceButton = computed(
|
|
420
|
+
() =>
|
|
421
|
+
state.value.voiceAvailable &&
|
|
422
|
+
!dictating.value &&
|
|
423
|
+
!showStop.value &&
|
|
424
|
+
draftText.value.length === 0,
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
function writeDictationIntoDraft(): void {
|
|
428
|
+
const applied = applyDictationTailV1(
|
|
429
|
+
draft.value,
|
|
430
|
+
voiceTail,
|
|
431
|
+
voiceTranscript.text(),
|
|
432
|
+
);
|
|
433
|
+
voiceTail = applied.tail;
|
|
434
|
+
draft.value = applied.draft;
|
|
435
|
+
void nextTick(syncComposerHeight);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function startDictation(): Promise<void> {
|
|
439
|
+
if (dictating.value || !state.value.voiceAvailable) return;
|
|
440
|
+
voiceError.value = undefined;
|
|
441
|
+
voiceTranscript.reset();
|
|
442
|
+
voiceTail = "";
|
|
443
|
+
voiceSendOnFinal = false;
|
|
444
|
+
voiceBars.value = voiceWaveBarsV1(0, []);
|
|
445
|
+
voiceState.value = "starting";
|
|
446
|
+
const session = web.value.openVoiceDictation({
|
|
447
|
+
ready: () => {
|
|
448
|
+
if (voiceState.value === "starting") voiceState.value = "listening";
|
|
449
|
+
},
|
|
450
|
+
delta: (text) => {
|
|
451
|
+
voiceTranscript.delta(text);
|
|
452
|
+
writeDictationIntoDraft();
|
|
453
|
+
},
|
|
454
|
+
transcript: (text) => {
|
|
455
|
+
voiceTranscript.settle(text);
|
|
456
|
+
writeDictationIntoDraft();
|
|
457
|
+
},
|
|
458
|
+
final: () => {
|
|
459
|
+
void completeDictation();
|
|
460
|
+
},
|
|
461
|
+
failed: (message) => {
|
|
462
|
+
voiceError.value = message;
|
|
463
|
+
void stopDictation();
|
|
464
|
+
},
|
|
465
|
+
closed: () => {
|
|
466
|
+
// A socket that goes away mid-capture leaves the draft exactly where it
|
|
467
|
+
// is; the person can still type the rest and send it.
|
|
468
|
+
if (dictating.value) void stopDictation();
|
|
469
|
+
},
|
|
470
|
+
});
|
|
471
|
+
if (!session) {
|
|
472
|
+
voiceState.value = "idle";
|
|
473
|
+
voiceError.value = "Dictation isn't available on this device.";
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
voiceSession = session;
|
|
477
|
+
try {
|
|
478
|
+
voiceMicrophone = await startVoiceMicrophoneV1({
|
|
479
|
+
audio: (pcm16) => session.sendAudio(pcm16),
|
|
480
|
+
level: (value) => {
|
|
481
|
+
voiceBars.value = voiceWaveBarsV1(value, voiceBars.value);
|
|
482
|
+
},
|
|
483
|
+
});
|
|
484
|
+
} catch (error) {
|
|
485
|
+
voiceError.value = voiceMicrophoneRefusalV1(error);
|
|
486
|
+
await stopDictation();
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Everything captured has been transcribed; send it if that is why we stopped. */
|
|
491
|
+
async function completeDictation(): Promise<void> {
|
|
492
|
+
const send = voiceSendOnFinal;
|
|
493
|
+
await stopDictation();
|
|
494
|
+
if (send) await sendMessage();
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async function stopDictation(): Promise<void> {
|
|
498
|
+
voiceState.value = "idle";
|
|
499
|
+
voiceSendOnFinal = false;
|
|
500
|
+
voiceBars.value = voiceWaveBarsV1(0, []);
|
|
501
|
+
const microphone = voiceMicrophone;
|
|
502
|
+
const session = voiceSession;
|
|
503
|
+
voiceMicrophone = undefined;
|
|
504
|
+
voiceSession = undefined;
|
|
505
|
+
await microphone?.stop();
|
|
506
|
+
session?.close();
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** The bin. Discards what was dictated, exactly as D4 says it does. */
|
|
510
|
+
function discardDictation(): void {
|
|
511
|
+
voiceSession?.cancel();
|
|
512
|
+
voiceTranscript.reset();
|
|
513
|
+
voiceTail = "";
|
|
514
|
+
draft.value = "";
|
|
515
|
+
voiceError.value = undefined;
|
|
516
|
+
void stopDictation();
|
|
517
|
+
void nextTick(syncComposerHeight);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Send, mid-dictation. The audio is committed and the message waits for the
|
|
522
|
+
* last transcript rather than sending half a sentence.
|
|
523
|
+
*/
|
|
524
|
+
function sendDictation(): void {
|
|
525
|
+
if (voiceState.value !== "listening") return;
|
|
526
|
+
voiceSendOnFinal = true;
|
|
527
|
+
voiceState.value = "finishing";
|
|
528
|
+
voiceSession?.commit();
|
|
529
|
+
}
|
|
530
|
+
|
|
375
531
|
/*
|
|
376
532
|
* Tool activity is internal to the Turn. A Turn that produced only tool calls
|
|
377
533
|
* shows the Bot avatar while it runs and nothing once it finishes with no
|
|
@@ -818,6 +974,9 @@ onBeforeUnmount(() => {
|
|
|
818
974
|
window.removeEventListener("hashchange", applySettingsDeepLink);
|
|
819
975
|
phoneLayoutMedia?.removeEventListener("change", onPhoneLayoutChange);
|
|
820
976
|
window.removeEventListener("keydown", onRootKeydown);
|
|
977
|
+
// A microphone outlives a component that stops drawing it unless it is told
|
|
978
|
+
// not to, and a browser shows the recording indicator for as long as it does.
|
|
979
|
+
void stopDictation();
|
|
821
980
|
});
|
|
822
981
|
|
|
823
982
|
watch(
|
|
@@ -1058,8 +1217,20 @@ function handleComposerKeydown(event: KeyboardEvent): void {
|
|
|
1058
1217
|
}
|
|
1059
1218
|
}
|
|
1060
1219
|
}
|
|
1220
|
+
if (event.key === "Escape" && dictating.value) {
|
|
1221
|
+
// Escape is the keyboard's bin, the same as it is for the Skill popover.
|
|
1222
|
+
event.preventDefault();
|
|
1223
|
+
discardDictation();
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1061
1226
|
if (event.key !== "Enter" || event.shiftKey) return;
|
|
1062
1227
|
event.preventDefault();
|
|
1228
|
+
// Enter means send either way; mid-dictation it commits the audio first so
|
|
1229
|
+
// the last word spoken is in the message.
|
|
1230
|
+
if (dictating.value) {
|
|
1231
|
+
sendDictation();
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1063
1234
|
void sendMessage();
|
|
1064
1235
|
}
|
|
1065
1236
|
</script>
|
|
@@ -1492,6 +1663,36 @@ function handleComposerKeydown(event: KeyboardEvent): void {
|
|
|
1492
1663
|
>
|
|
1493
1664
|
{{ draftCounterLabel }}
|
|
1494
1665
|
</p>
|
|
1666
|
+
<!--
|
|
1667
|
+
The capture animation, and the only place the state is written
|
|
1668
|
+
out. It is level-driven from the microphone: bars that move while
|
|
1669
|
+
the room is silent would say the microphone works when it does
|
|
1670
|
+
not.
|
|
1671
|
+
-->
|
|
1672
|
+
<p
|
|
1673
|
+
v-if="dictating"
|
|
1674
|
+
class="voice-capture"
|
|
1675
|
+
role="status"
|
|
1676
|
+
aria-live="polite"
|
|
1677
|
+
>
|
|
1678
|
+
<span class="voice-wave" aria-hidden="true">
|
|
1679
|
+
<span
|
|
1680
|
+
v-for="(bar, index) in voiceBars"
|
|
1681
|
+
:key="index"
|
|
1682
|
+
class="voice-wave-bar"
|
|
1683
|
+
:style="{ transform: `scaleY(${bar})` }"
|
|
1684
|
+
/>
|
|
1685
|
+
</span>
|
|
1686
|
+
<span>{{ voiceButtonLabel }}</span>
|
|
1687
|
+
</p>
|
|
1688
|
+
<!--
|
|
1689
|
+
Why dictation stopped, in the words the server or the browser
|
|
1690
|
+
used. It sits under the draft it could not add to, and the draft
|
|
1691
|
+
itself is untouched.
|
|
1692
|
+
-->
|
|
1693
|
+
<p v-if="voiceError" class="voice-error" role="alert">
|
|
1694
|
+
{{ voiceError }}
|
|
1695
|
+
</p>
|
|
1495
1696
|
</div>
|
|
1496
1697
|
<!--
|
|
1497
1698
|
Start a new conversation. Sits beside the composer because that is
|
|
@@ -1506,14 +1707,44 @@ function handleComposerKeydown(event: KeyboardEvent): void {
|
|
|
1506
1707
|
:disabled="isRunning"
|
|
1507
1708
|
@click="web.startConversation()"
|
|
1508
1709
|
/>
|
|
1710
|
+
<!--
|
|
1711
|
+
The send slot, and its four states. Dictation replaces the one
|
|
1712
|
+
button with two, because while it is listening the only two things
|
|
1713
|
+
worth offering are "throw this away" and "that's the message".
|
|
1714
|
+
-->
|
|
1715
|
+
<template v-if="dictating">
|
|
1716
|
+
<UiIconButton
|
|
1717
|
+
class="voice-discard-button"
|
|
1718
|
+
icon="trash"
|
|
1719
|
+
label="Discard dictation"
|
|
1720
|
+
variant="ghost"
|
|
1721
|
+
@click="discardDictation"
|
|
1722
|
+
/>
|
|
1723
|
+
<UiIconButton
|
|
1724
|
+
class="voice-send-button"
|
|
1725
|
+
icon="arrow-up"
|
|
1726
|
+
label="Send dictated message"
|
|
1727
|
+
variant="primary"
|
|
1728
|
+
:disabled="voiceState !== 'listening'"
|
|
1729
|
+
@click="sendDictation"
|
|
1730
|
+
/>
|
|
1731
|
+
</template>
|
|
1509
1732
|
<UiIconButton
|
|
1510
|
-
v-if="showStop"
|
|
1733
|
+
v-else-if="showStop"
|
|
1511
1734
|
class="stop-button"
|
|
1512
1735
|
icon="stop"
|
|
1513
1736
|
label="Stop generating"
|
|
1514
1737
|
variant="primary"
|
|
1515
1738
|
@click="web.stopRun()"
|
|
1516
1739
|
/>
|
|
1740
|
+
<UiIconButton
|
|
1741
|
+
v-else-if="showVoiceButton"
|
|
1742
|
+
class="voice-button"
|
|
1743
|
+
icon="waveform"
|
|
1744
|
+
:label="voiceButtonLabel"
|
|
1745
|
+
variant="primary"
|
|
1746
|
+
@click="startDictation"
|
|
1747
|
+
/>
|
|
1517
1748
|
<UiIconButton
|
|
1518
1749
|
v-else
|
|
1519
1750
|
type="submit"
|
package/src/client/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from "@frockbot/client-core";
|
|
16
16
|
import { clientSurfaceRegistryKey } from "@frockbot/client-core";
|
|
17
17
|
import { COMPACTED_ANNOUNCEMENT_TEXT_V1 } from "../compaction.js";
|
|
18
|
+
import { voiceCaptureSupportedV1 } from "./voice-microphone.js";
|
|
18
19
|
import { readViewerFocusV1, shouldNotifyForBotV1 } from "../focus.js";
|
|
19
20
|
// Connection mutations use the provider-neutral hosted command contract.
|
|
20
21
|
import type {
|
|
@@ -1417,6 +1418,9 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1417
1418
|
modelSource: "none",
|
|
1418
1419
|
settingsAvailable: true,
|
|
1419
1420
|
connectionsAvailable: ctx.transport.connectionsAvailable !== false,
|
|
1421
|
+
voiceAvailable:
|
|
1422
|
+
typeof ctx.transport.openVoiceDictation === "function" &&
|
|
1423
|
+
voiceCaptureSupportedV1(),
|
|
1420
1424
|
activeBotId: undefined,
|
|
1421
1425
|
composerContext: undefined,
|
|
1422
1426
|
transcripts: {
|
|
@@ -2878,6 +2882,9 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2878
2882
|
}
|
|
2879
2883
|
}
|
|
2880
2884
|
},
|
|
2885
|
+
openVoiceDictation(observer) {
|
|
2886
|
+
return ctx.transport.openVoiceDictation?.(observer);
|
|
2887
|
+
},
|
|
2881
2888
|
async resumeRun(runId: string): Promise<void> {
|
|
2882
2889
|
if (!ctx.transport.reconcileRun) {
|
|
2883
2890
|
web.value.settingsError = "Can't retry this right now.";
|
package/src/client/styles.css
CHANGED
|
@@ -758,6 +758,50 @@
|
|
|
758
758
|
color: var(--frock-danger-text);
|
|
759
759
|
}
|
|
760
760
|
|
|
761
|
+
/*
|
|
762
|
+
* Dictation.
|
|
763
|
+
*
|
|
764
|
+
* The capture line sits under the draft it is writing, inside the composer,
|
|
765
|
+
* because the text arriving in the textarea is the thing it is describing. It
|
|
766
|
+
* is a status line rather than an overlay: nothing about the composer moves
|
|
767
|
+
* when it appears beyond the row it adds.
|
|
768
|
+
*/
|
|
769
|
+
.voice-capture {
|
|
770
|
+
display: flex;
|
|
771
|
+
margin: 0;
|
|
772
|
+
align-items: center;
|
|
773
|
+
gap: 8px;
|
|
774
|
+
color: var(--frock-text-muted);
|
|
775
|
+
font-size: var(--frock-text-xs);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
.voice-wave {
|
|
779
|
+
display: flex;
|
|
780
|
+
height: 14px;
|
|
781
|
+
align-items: center;
|
|
782
|
+
gap: 2px;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/*
|
|
786
|
+
* Each bar is scaled from the microphone's own level, so the animation stops
|
|
787
|
+
* when the room does. `transform` rather than `height`: it is the one property
|
|
788
|
+
* that animates without a layout pass, and this runs at frame rate.
|
|
789
|
+
*/
|
|
790
|
+
.voice-wave-bar {
|
|
791
|
+
width: 2px;
|
|
792
|
+
height: 100%;
|
|
793
|
+
border-radius: 999px;
|
|
794
|
+
background: var(--frock-action-primary);
|
|
795
|
+
transform-origin: center;
|
|
796
|
+
transition: transform var(--frock-motion-fast);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
.voice-error {
|
|
800
|
+
margin: 0;
|
|
801
|
+
color: var(--frock-danger-text);
|
|
802
|
+
font-size: var(--frock-text-xs);
|
|
803
|
+
}
|
|
804
|
+
|
|
761
805
|
.skill-chips {
|
|
762
806
|
display: flex;
|
|
763
807
|
margin: 0;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
applyDictationTailV1,
|
|
4
|
+
voiceButtonLabelV1,
|
|
5
|
+
voiceWaveBarsV1,
|
|
6
|
+
VoiceDictationTranscriptV1,
|
|
7
|
+
} from "./voice-dictation.js";
|
|
8
|
+
|
|
9
|
+
describe("writing dictation into a draft somebody may be editing", () => {
|
|
10
|
+
test("appends the first words to an empty draft", () => {
|
|
11
|
+
expect(applyDictationTailV1("", "", "hello")).toEqual({
|
|
12
|
+
draft: "hello",
|
|
13
|
+
tail: "hello",
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("grows in place rather than repeating itself", () => {
|
|
18
|
+
const first = applyDictationTailV1("", "", "hello");
|
|
19
|
+
const second = applyDictationTailV1(first.draft, first.tail, "hello there");
|
|
20
|
+
expect(second.draft).toBe("hello there");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("keeps a typed prefix, and a typed suffix, around what is spoken", () => {
|
|
24
|
+
const first = applyDictationTailV1("Note: ", "", "buy milk");
|
|
25
|
+
expect(first.draft).toBe("Note: buy milk");
|
|
26
|
+
// The person types on the end while the next words arrive.
|
|
27
|
+
const edited = `${first.draft} today`;
|
|
28
|
+
const second = applyDictationTailV1(
|
|
29
|
+
edited,
|
|
30
|
+
first.tail,
|
|
31
|
+
"buy milk and bread",
|
|
32
|
+
);
|
|
33
|
+
expect(second.draft).toBe("Note: buy milk and bread today");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("rewrites the last occurrence, so a repeated word moves the right one", () => {
|
|
37
|
+
// "go" was typed, then "go" was dictated. Growing the dictated one must
|
|
38
|
+
// not reach back and rewrite the typed one.
|
|
39
|
+
expect(applyDictationTailV1("go go", "go", "gone")).toEqual({
|
|
40
|
+
draft: "go gone",
|
|
41
|
+
tail: "gone",
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("appends rather than fighting when the person deleted what was dictated", () => {
|
|
46
|
+
const first = applyDictationTailV1("", "", "hello");
|
|
47
|
+
const cleared = applyDictationTailV1("", first.tail, "hello there");
|
|
48
|
+
expect(cleared).toEqual({ draft: "hello there", tail: "hello there" });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("never glues two words together", () => {
|
|
52
|
+
expect(applyDictationTailV1("Note:", "", "one").draft).toBe("Note: one");
|
|
53
|
+
expect(applyDictationTailV1("Note: ", "", "one").draft).toBe("Note: one");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("what has been heard so far", () => {
|
|
58
|
+
test("streams deltas, then lets the finished segment replace them", () => {
|
|
59
|
+
const transcript = new VoiceDictationTranscriptV1();
|
|
60
|
+
expect(transcript.empty()).toBe(true);
|
|
61
|
+
transcript.delta("hello ");
|
|
62
|
+
transcript.delta("their");
|
|
63
|
+
expect(transcript.text()).toBe("hello their");
|
|
64
|
+
transcript.settle("Hello there.");
|
|
65
|
+
expect(transcript.text()).toBe("Hello there.");
|
|
66
|
+
expect(transcript.empty()).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("joins segments with a single space and drops an empty one", () => {
|
|
70
|
+
const transcript = new VoiceDictationTranscriptV1();
|
|
71
|
+
transcript.settle(" One. ");
|
|
72
|
+
transcript.settle("");
|
|
73
|
+
transcript.settle("Two.");
|
|
74
|
+
transcript.delta("thr");
|
|
75
|
+
expect(transcript.text()).toBe("One. Two. thr");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("resets to nothing, so a binned capture leaves no tail behind", () => {
|
|
79
|
+
const transcript = new VoiceDictationTranscriptV1();
|
|
80
|
+
transcript.settle("One.");
|
|
81
|
+
transcript.reset();
|
|
82
|
+
expect(transcript.empty()).toBe(true);
|
|
83
|
+
expect(transcript.text()).toBe("");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("the composer's dictation chrome", () => {
|
|
88
|
+
test("names each state for the aria label and the tooltip alike", () => {
|
|
89
|
+
expect(voiceButtonLabelV1("idle")).toBe("Dictate a message");
|
|
90
|
+
expect(voiceButtonLabelV1("listening")).toBe("Listening");
|
|
91
|
+
expect(voiceButtonLabelV1("finishing")).toBe("Finishing dictation");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("the wave follows the microphone and never collapses to a line", () => {
|
|
95
|
+
const silent = voiceWaveBarsV1(0, []);
|
|
96
|
+
expect(silent).toHaveLength(4);
|
|
97
|
+
expect(silent.every((bar) => bar >= 0.2)).toBe(true);
|
|
98
|
+
const loud = voiceWaveBarsV1(1, silent);
|
|
99
|
+
expect(loud[0]).toBeCloseTo(1);
|
|
100
|
+
// The level travels along the bars rather than moving all of them at once.
|
|
101
|
+
expect(loud[1]).toBeCloseTo(silent[0]!);
|
|
102
|
+
expect(voiceWaveBarsV1(Number.NaN, silent)[0]).toBeCloseTo(0.2);
|
|
103
|
+
expect(voiceWaveBarsV1(9, silent)[0]).toBeCloseTo(1);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Dictation, as text arriving in a draft somebody may be editing at the same
|
|
2
|
+
// time (voice plan D4).
|
|
3
|
+
//
|
|
4
|
+
// The composer writes what it hears into the textarea rather than into a
|
|
5
|
+
// separate box, so the message is editable while it is being spoken, a
|
|
6
|
+
// rejected send restores it like any other draft, and Send is the ordinary
|
|
7
|
+
// Send. That makes one thing hard and this module is that one thing: knowing
|
|
8
|
+
// which part of the draft dictation put there, when the person is free to
|
|
9
|
+
// type in the middle of it.
|
|
10
|
+
//
|
|
11
|
+
// The answer is a *tail*: the exact text last written on dictation's behalf.
|
|
12
|
+
// Each update replaces the last occurrence of the previous tail with the next
|
|
13
|
+
// one. Delete it, retype around it, paste over it — if the previous tail is
|
|
14
|
+
// no longer in the draft, the new text is appended rather than forced back
|
|
15
|
+
// into a position nobody asked for. Nothing here reads the caret, so it holds
|
|
16
|
+
// on a phone keyboard and a desktop one alike.
|
|
17
|
+
|
|
18
|
+
export type VoiceDictationStateV1 =
|
|
19
|
+
/** No microphone. The send button is the wave button when the draft is empty. */
|
|
20
|
+
| "idle"
|
|
21
|
+
/** Asked for the microphone, or waiting for the upstream to say `ready`. */
|
|
22
|
+
| "starting"
|
|
23
|
+
/** Capturing. Bin and Send have replaced the wave button. */
|
|
24
|
+
| "listening"
|
|
25
|
+
/** Send was pressed; the last of the audio is being transcribed. */
|
|
26
|
+
| "finishing";
|
|
27
|
+
|
|
28
|
+
export interface DictationDraftV1 {
|
|
29
|
+
draft: string;
|
|
30
|
+
tail: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Joins dictation onto a draft without gluing two words together. */
|
|
34
|
+
function joined(head: string, tail: string): string {
|
|
35
|
+
if (!head) return tail;
|
|
36
|
+
if (!tail) return head;
|
|
37
|
+
return /\s$/u.test(head) ? `${head}${tail}` : `${head} ${tail}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Puts `nextTail` where `previousTail` was, or on the end when it has gone.
|
|
42
|
+
*
|
|
43
|
+
* `lastIndexOf`, not `indexOf`: dictating the same short word twice must
|
|
44
|
+
* rewrite the second one.
|
|
45
|
+
*/
|
|
46
|
+
export function applyDictationTailV1(
|
|
47
|
+
draft: string,
|
|
48
|
+
previousTail: string,
|
|
49
|
+
nextTail: string,
|
|
50
|
+
): DictationDraftV1 {
|
|
51
|
+
if (!previousTail) return { draft: joined(draft, nextTail), tail: nextTail };
|
|
52
|
+
const at = draft.lastIndexOf(previousTail);
|
|
53
|
+
if (at < 0) return { draft: joined(draft, nextTail), tail: nextTail };
|
|
54
|
+
const before = draft.slice(0, at);
|
|
55
|
+
const after = draft.slice(at + previousTail.length);
|
|
56
|
+
return { draft: `${before}${nextTail}${after}`, tail: nextTail };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* What has been heard so far: the finished segments, and the deltas of the
|
|
61
|
+
* one still being spoken.
|
|
62
|
+
*
|
|
63
|
+
* A provider streams a segment as deltas and then re-sends it, punctuated and
|
|
64
|
+
* capitalised, as a `completed` transcript. Keeping the two apart is what lets
|
|
65
|
+
* the finished form replace the rough one in place instead of appearing twice.
|
|
66
|
+
*/
|
|
67
|
+
export class VoiceDictationTranscriptV1 {
|
|
68
|
+
#settled: string[] = [];
|
|
69
|
+
#pending = "";
|
|
70
|
+
|
|
71
|
+
delta(text: string): void {
|
|
72
|
+
this.#pending += text;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** One finished segment; it replaces the deltas that built it. */
|
|
76
|
+
settle(text: string): void {
|
|
77
|
+
const trimmed = text.trim();
|
|
78
|
+
if (trimmed) this.#settled.push(trimmed);
|
|
79
|
+
this.#pending = "";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Everything dictated in this session, as one string. */
|
|
83
|
+
text(): string {
|
|
84
|
+
const settled = this.#settled.join(" ");
|
|
85
|
+
const pending = this.#pending.trim();
|
|
86
|
+
if (!settled) return pending;
|
|
87
|
+
if (!pending) return settled;
|
|
88
|
+
return `${settled} ${pending}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** True until the first word arrives, so an empty capture sends nothing. */
|
|
92
|
+
empty(): boolean {
|
|
93
|
+
return this.text().length === 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
reset(): void {
|
|
97
|
+
this.#settled = [];
|
|
98
|
+
this.#pending = "";
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The composer's label for each state, so the aria text and the title agree. */
|
|
103
|
+
export function voiceButtonLabelV1(state: VoiceDictationStateV1): string {
|
|
104
|
+
switch (state) {
|
|
105
|
+
case "idle":
|
|
106
|
+
return "Dictate a message";
|
|
107
|
+
case "starting":
|
|
108
|
+
return "Starting dictation";
|
|
109
|
+
case "listening":
|
|
110
|
+
return "Listening";
|
|
111
|
+
case "finishing":
|
|
112
|
+
return "Finishing dictation";
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The scale factor of each bar of the capture animation, newest first.
|
|
118
|
+
*
|
|
119
|
+
* Level-driven rather than time-driven: a wave that moves while the room is
|
|
120
|
+
* silent says the microphone is working when it is not. Each call shifts the
|
|
121
|
+
* previous bars along, so a syllable travels across the control instead of
|
|
122
|
+
* every bar jumping at once.
|
|
123
|
+
*
|
|
124
|
+
* `previous` is the value this function last returned, so the whole animation
|
|
125
|
+
* is one `ref` and one assignment.
|
|
126
|
+
*/
|
|
127
|
+
export function voiceWaveBarsV1(
|
|
128
|
+
level: number,
|
|
129
|
+
previous: readonly number[],
|
|
130
|
+
bars = 4,
|
|
131
|
+
): number[] {
|
|
132
|
+
const clamped = Number.isFinite(level) ? Math.min(1, Math.max(0, level)) : 0;
|
|
133
|
+
// A floor, so the control never collapses to a line and loses its shape.
|
|
134
|
+
const next = [0.2 + clamped * 0.8, ...previous].slice(0, bars);
|
|
135
|
+
while (next.length < bars) next.push(0.2);
|
|
136
|
+
return next;
|
|
137
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// The browser's half of dictation: a microphone, resampled to the PCM16 the
|
|
2
|
+
// upstream transcription session expects.
|
|
3
|
+
//
|
|
4
|
+
// The capture itself runs on the audio thread; its source and the reason it is
|
|
5
|
+
// served as a first-party asset are in `voice-worklet.ts`. This module is the
|
|
6
|
+
// graph around it: permission, the context, the silent sink that keeps the
|
|
7
|
+
// node pulled, and the teardown that actually releases the microphone.
|
|
8
|
+
import {
|
|
9
|
+
VOICE_CAPTURE_WORKLET_PATH_V1,
|
|
10
|
+
VOICE_CAPTURE_WORKLET_PROCESSOR_V1,
|
|
11
|
+
} from "./voice-worklet.js";
|
|
12
|
+
|
|
13
|
+
/** What the upstream is told to expect, and therefore what leaves here. */
|
|
14
|
+
export const VOICE_CAPTURE_SAMPLE_RATE_V1 = 16_000;
|
|
15
|
+
|
|
16
|
+
/** Samples per frame at 16 kHz: 32 ms, small enough to feel live. */
|
|
17
|
+
const FRAME_SAMPLES = 512;
|
|
18
|
+
|
|
19
|
+
export interface VoiceMicrophoneV1 {
|
|
20
|
+
stop(): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface VoiceMicrophoneOptionsV1 {
|
|
24
|
+
/** One frame of PCM16, little-endian, mono, 16 kHz. */
|
|
25
|
+
audio(pcm16: ArrayBuffer): void;
|
|
26
|
+
/** Peak amplitude of the frame, 0…1, for the capture animation. */
|
|
27
|
+
level(value: number): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** False on a platform with no microphone API at all; the button stays hidden. */
|
|
31
|
+
export function voiceCaptureSupportedV1(): boolean {
|
|
32
|
+
return (
|
|
33
|
+
typeof navigator !== "undefined" &&
|
|
34
|
+
typeof navigator.mediaDevices?.getUserMedia === "function" &&
|
|
35
|
+
typeof (globalThis as { AudioContext?: unknown }).AudioContext ===
|
|
36
|
+
"function"
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The refusal a person reads when the browser will not give up the
|
|
42
|
+
* microphone. Named cases only: anything else says what the browser said.
|
|
43
|
+
*/
|
|
44
|
+
export function voiceMicrophoneRefusalV1(error: unknown): string {
|
|
45
|
+
const name =
|
|
46
|
+
typeof error === "object" && error !== null && "name" in error
|
|
47
|
+
? String((error as { name: unknown }).name)
|
|
48
|
+
: "";
|
|
49
|
+
if (name === "NotAllowedError" || name === "SecurityError") {
|
|
50
|
+
return "FrockBot needs permission to use your microphone. Allow it in your browser, then try again.";
|
|
51
|
+
}
|
|
52
|
+
if (name === "NotFoundError" || name === "OverconstrainedError") {
|
|
53
|
+
return "No microphone was found. Plug one in, then try again.";
|
|
54
|
+
}
|
|
55
|
+
return `The microphone couldn't start: ${
|
|
56
|
+
error instanceof Error && error.message ? error.message : "unknown error"
|
|
57
|
+
}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function startVoiceMicrophoneV1(
|
|
61
|
+
options: VoiceMicrophoneOptionsV1,
|
|
62
|
+
): Promise<VoiceMicrophoneV1> {
|
|
63
|
+
const stream = await navigator.mediaDevices.getUserMedia({
|
|
64
|
+
audio: {
|
|
65
|
+
channelCount: 1,
|
|
66
|
+
echoCancellation: true,
|
|
67
|
+
noiseSuppression: true,
|
|
68
|
+
autoGainControl: true,
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
const context = new AudioContext();
|
|
72
|
+
let node: AudioWorkletNode | undefined;
|
|
73
|
+
let source: MediaStreamAudioSourceNode | undefined;
|
|
74
|
+
let silence: GainNode | undefined;
|
|
75
|
+
try {
|
|
76
|
+
// Same-origin, so `script-src 'self'` admits it. See `voice-worklet.ts`.
|
|
77
|
+
await context.audioWorklet.addModule(VOICE_CAPTURE_WORKLET_PATH_V1);
|
|
78
|
+
node = new AudioWorkletNode(context, VOICE_CAPTURE_WORKLET_PROCESSOR_V1, {
|
|
79
|
+
numberOfInputs: 1,
|
|
80
|
+
// One silent output, connected below. A graph is pulled from the
|
|
81
|
+
// destination, so a node with no path to it is never asked to process
|
|
82
|
+
// and the microphone produces nothing at all — silently.
|
|
83
|
+
numberOfOutputs: 1,
|
|
84
|
+
outputChannelCount: [1],
|
|
85
|
+
processorOptions: {
|
|
86
|
+
targetRate: VOICE_CAPTURE_SAMPLE_RATE_V1,
|
|
87
|
+
frameSamples: FRAME_SAMPLES,
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
node.port.onmessage = (event: MessageEvent) => {
|
|
91
|
+
const message = event.data as { pcm?: ArrayBuffer; level?: number };
|
|
92
|
+
if (message.pcm) options.audio(message.pcm);
|
|
93
|
+
if (typeof message.level === "number") options.level(message.level);
|
|
94
|
+
};
|
|
95
|
+
source = context.createMediaStreamSource(stream);
|
|
96
|
+
source.connect(node);
|
|
97
|
+
// Silenced at the sink rather than left unconnected: the person must not
|
|
98
|
+
// hear themselves, and the node must still be pulled.
|
|
99
|
+
silence = context.createGain();
|
|
100
|
+
silence.gain.value = 0;
|
|
101
|
+
node.connect(silence);
|
|
102
|
+
silence.connect(context.destination);
|
|
103
|
+
// A suspended context produces silence and no error at all; Safari hands
|
|
104
|
+
// one back whenever the gesture that opened it has already finished.
|
|
105
|
+
if (context.state === "suspended") await context.resume();
|
|
106
|
+
} catch (error) {
|
|
107
|
+
for (const track of stream.getTracks()) track.stop();
|
|
108
|
+
await context.close().catch(() => undefined);
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let stopped = false;
|
|
113
|
+
return {
|
|
114
|
+
async stop() {
|
|
115
|
+
if (stopped) return;
|
|
116
|
+
stopped = true;
|
|
117
|
+
if (node) node.port.onmessage = null;
|
|
118
|
+
source?.disconnect();
|
|
119
|
+
node?.disconnect();
|
|
120
|
+
silence?.disconnect();
|
|
121
|
+
for (const track of stream.getTracks()) track.stop();
|
|
122
|
+
await context.close().catch(() => undefined);
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// The dictation capture worklet, and the path the app serves it from.
|
|
2
|
+
//
|
|
3
|
+
// It is a first-party asset on the app's own origin rather than a blob URL,
|
|
4
|
+
// and that is a security decision, not a packaging one: the hosted client is
|
|
5
|
+
// served under `script-src 'self'`, which a `blob:` module does not satisfy —
|
|
6
|
+
// `context.audioWorklet.addModule(blobUrl)` fails with "Unable to load a
|
|
7
|
+
// worklet's module". Widening the policy to admit blob scripts everywhere, to
|
|
8
|
+
// load one 40-line file, is the wrong trade. So the source lives here as a
|
|
9
|
+
// string, the application Worker answers `GET` on the path below with it
|
|
10
|
+
// (`apps/cloudflare/src/user-application.ts`), and the policy is untouched.
|
|
11
|
+
//
|
|
12
|
+
// Its own module so the Worker can serve the source without pulling the
|
|
13
|
+
// browser-only microphone plumbing in beside it.
|
|
14
|
+
|
|
15
|
+
/** Where the application Worker serves {@link VOICE_CAPTURE_WORKLET_SOURCE_V1}. */
|
|
16
|
+
export const VOICE_CAPTURE_WORKLET_PATH_V1 = "/voice-capture-worklet.js";
|
|
17
|
+
|
|
18
|
+
/** The processor's registered name, shared by the source and the node. */
|
|
19
|
+
export const VOICE_CAPTURE_WORKLET_PROCESSOR_V1 = "frock-voice-capture";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Microphone audio, decimated to the target rate and framed as PCM16.
|
|
23
|
+
*
|
|
24
|
+
* On the audio thread rather than the main one, because the alternative
|
|
25
|
+
* (`ScriptProcessorNode`) shares the thread with Vue's renderer and drops
|
|
26
|
+
* audio exactly when the composer is busiest — while the draft it is writing
|
|
27
|
+
* is being re-laid out.
|
|
28
|
+
*
|
|
29
|
+
* The rate is reached by decimating whatever the context gives us rather than
|
|
30
|
+
* by asking for it: iOS Safari ignores `new AudioContext({ sampleRate })` and
|
|
31
|
+
* hands back 48 kHz regardless, and audio at the wrong rate transcribes as
|
|
32
|
+
* gibberish rather than failing.
|
|
33
|
+
*/
|
|
34
|
+
export const VOICE_CAPTURE_WORKLET_SOURCE_V1 = `
|
|
35
|
+
class FrockVoiceCapture extends AudioWorkletProcessor {
|
|
36
|
+
constructor(options) {
|
|
37
|
+
super();
|
|
38
|
+
this.target = options.processorOptions.targetRate;
|
|
39
|
+
this.frame = options.processorOptions.frameSamples;
|
|
40
|
+
this.buffer = new Float32Array(this.frame);
|
|
41
|
+
this.filled = 0;
|
|
42
|
+
this.position = 0;
|
|
43
|
+
}
|
|
44
|
+
process(inputs) {
|
|
45
|
+
const channel = inputs[0] && inputs[0][0];
|
|
46
|
+
if (!channel) return true;
|
|
47
|
+
const step = sampleRate / this.target;
|
|
48
|
+
let peak = 0;
|
|
49
|
+
for (let index = 0; index < channel.length; index += 1) {
|
|
50
|
+
const value = channel[index];
|
|
51
|
+
const magnitude = value < 0 ? -value : value;
|
|
52
|
+
if (magnitude > peak) peak = magnitude;
|
|
53
|
+
}
|
|
54
|
+
// Decimation: walk the block at a fractional step, carrying the remainder
|
|
55
|
+
// across blocks so no drift accumulates. Enough for speech, and cheap.
|
|
56
|
+
while (this.position < channel.length) {
|
|
57
|
+
this.buffer[this.filled] = channel[Math.floor(this.position)];
|
|
58
|
+
this.position += step;
|
|
59
|
+
this.filled += 1;
|
|
60
|
+
if (this.filled === this.frame) {
|
|
61
|
+
const pcm = new Int16Array(this.frame);
|
|
62
|
+
for (let sample = 0; sample < this.frame; sample += 1) {
|
|
63
|
+
const clamped = Math.max(-1, Math.min(1, this.buffer[sample]));
|
|
64
|
+
pcm[sample] = clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff;
|
|
65
|
+
}
|
|
66
|
+
this.port.postMessage({ pcm: pcm.buffer, level: peak }, [pcm.buffer]);
|
|
67
|
+
this.filled = 0;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
this.position -= channel.length;
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
registerProcessor("${VOICE_CAPTURE_WORKLET_PROCESSOR_V1}", FrockVoiceCapture);
|
|
75
|
+
`;
|
package/src/shared.ts
CHANGED
|
@@ -22,6 +22,10 @@ import type {
|
|
|
22
22
|
SendToUserPayloadV1,
|
|
23
23
|
SkillRefV1,
|
|
24
24
|
} from "@frockbot/kernel-contracts";
|
|
25
|
+
import type {
|
|
26
|
+
VoiceDictationObserverV1,
|
|
27
|
+
VoiceDictationSessionV1,
|
|
28
|
+
} from "@frockbot/client-core";
|
|
25
29
|
import type { McpServerStatusViewV1 } from "@frockbot/plugin-mcp/records";
|
|
26
30
|
import type { PackageSettingDefinition } from "@frockbot/kernel-composition";
|
|
27
31
|
import type { ClientSkillCatalogEntryV1 } from "./skill-protocol.js";
|
|
@@ -256,6 +260,12 @@ export interface FrockBotWebData {
|
|
|
256
260
|
modelSource: "bot" | "default" | "none";
|
|
257
261
|
settingsAvailable: boolean;
|
|
258
262
|
connectionsAvailable: boolean;
|
|
263
|
+
/**
|
|
264
|
+
* False when this platform cannot dictate — no transport socket, or a
|
|
265
|
+
* browser with no microphone API. The composer's send button then never
|
|
266
|
+
* changes shape and nothing about it moves.
|
|
267
|
+
*/
|
|
268
|
+
voiceAvailable: boolean;
|
|
259
269
|
activeBotId?: string;
|
|
260
270
|
composerContext?: unknown;
|
|
261
271
|
messages: WebChatMessage[];
|
|
@@ -511,6 +521,13 @@ export interface FrockBotWebData {
|
|
|
511
521
|
skills?: readonly SkillRefV1[],
|
|
512
522
|
): Promise<SendPromptResult>;
|
|
513
523
|
resumeRun(runId: string): Promise<void>;
|
|
524
|
+
/**
|
|
525
|
+
* Opens one dictation session (voice plan D2). `undefined` on a platform
|
|
526
|
+
* whose transport cannot, which is what `voiceAvailable` reports up front.
|
|
527
|
+
*/
|
|
528
|
+
openVoiceDictation(
|
|
529
|
+
observer: VoiceDictationObserverV1,
|
|
530
|
+
): VoiceDictationSessionV1 | undefined;
|
|
514
531
|
/** Sends the durable Stop command for the observed active run. */
|
|
515
532
|
stopRun(): Promise<void>;
|
|
516
533
|
/** Detaches the local observer only; admitted work stays durable. */
|