@frockbot/plugin-shell 0.3.12 → 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 +34 -32
- package/src/agent.test.ts +78 -0
- package/src/agent.ts +65 -1
- package/src/backend-configuration.test.ts +20 -20
- package/src/backend-recovery-integration.test.ts +10 -10
- package/src/client/AppletCanvas.vue +19 -6
- package/src/client/FrockBotApp.vue +299 -68
- package/src/client/applets-client.test.ts +62 -0
- package/src/client/applets-client.ts +19 -0
- package/src/client/index.test.ts +103 -16
- package/src/client/index.ts +187 -79
- package/src/client/styles.css +56 -61
- 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/notification-id.ts +0 -0
- package/src/run-failure-copy.test.ts +150 -0
- package/src/run-failure-copy.ts +110 -0
- package/src/run-protocol.test.ts +13 -7
- package/src/run-protocol.ts +17 -7
- package/src/shared.ts +17 -0
package/src/client/styles.css
CHANGED
|
@@ -360,11 +360,13 @@
|
|
|
360
360
|
* notices and sends stack inside it, so a one-word reply is a bubble the width
|
|
361
361
|
* of its word rather than a sliver of a shared row.
|
|
362
362
|
*
|
|
363
|
-
* The avatar is not in that column
|
|
364
|
-
* own row
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
363
|
+
* The avatar is not in that column, and not in the article at all: while the
|
|
364
|
+
* Bot is working it sits on its own row at the very end of the thread, so the
|
|
365
|
+
* trail streaming off its right has the width of the transcript to run through
|
|
366
|
+
* rather than the gutter beside a bubble, and a message sent mid-Turn lands
|
|
367
|
+
* above it. Every reply here is from the same Bot — there are no group
|
|
368
|
+
* conversations — so a sheep on every settled line said nothing and cost the
|
|
369
|
+
* column its left edge.
|
|
368
370
|
*/
|
|
369
371
|
.message-assistant {
|
|
370
372
|
flex-direction: column;
|
|
@@ -383,11 +385,16 @@
|
|
|
383
385
|
/*
|
|
384
386
|
* The working row. Its height is the trail's canvas: tall enough for the
|
|
385
387
|
* wobble to be visible, short enough that it reads as one line of the thread.
|
|
388
|
+
* It is the thread's last child, so it takes the same top margin every message
|
|
389
|
+
* has and adds no width of its own — the bubbles above never move sideways
|
|
390
|
+
* when it appears or goes.
|
|
386
391
|
*/
|
|
387
392
|
.bot-working {
|
|
388
393
|
display: flex;
|
|
394
|
+
width: 100%;
|
|
389
395
|
height: 44px;
|
|
390
396
|
align-items: center;
|
|
397
|
+
margin-top: 6px;
|
|
391
398
|
gap: 0;
|
|
392
399
|
}
|
|
393
400
|
|
|
@@ -461,62 +468,6 @@
|
|
|
461
468
|
}
|
|
462
469
|
}
|
|
463
470
|
|
|
464
|
-
@keyframes frock-halo {
|
|
465
|
-
0%,
|
|
466
|
-
100% {
|
|
467
|
-
opacity: 0;
|
|
468
|
-
transform: scale(0.94);
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
50% {
|
|
472
|
-
opacity: 1;
|
|
473
|
-
transform: scale(1.06);
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
/*
|
|
478
|
-
* The bubble a Turn has before it has words: three dots that say the Bot is
|
|
479
|
-
* working, where the reply itself will appear.
|
|
480
|
-
*/
|
|
481
|
-
.message-working {
|
|
482
|
-
display: flex;
|
|
483
|
-
align-items: center;
|
|
484
|
-
min-height: 20px;
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
.working-dots {
|
|
488
|
-
display: inline-flex;
|
|
489
|
-
gap: 4px;
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
.working-dots i {
|
|
493
|
-
width: 6px;
|
|
494
|
-
height: 6px;
|
|
495
|
-
border-radius: 50%;
|
|
496
|
-
background: var(--frock-text-muted);
|
|
497
|
-
animation: frock-working-dot 1.2s ease-in-out infinite;
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
.working-dots i:nth-child(2) {
|
|
501
|
-
animation-delay: 0.15s;
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
.working-dots i:nth-child(3) {
|
|
505
|
-
animation-delay: 0.3s;
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
@keyframes frock-working-dot {
|
|
509
|
-
0%,
|
|
510
|
-
60%,
|
|
511
|
-
100% {
|
|
512
|
-
opacity: 0.3;
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
30% {
|
|
516
|
-
opacity: 1;
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
|
|
520
471
|
/*
|
|
521
472
|
* A bubble runs nearly the width of the transcript, leaving one clear margin
|
|
522
473
|
* on the side it is not anchored to: the Bot's at the end, the User's at the
|
|
@@ -807,6 +758,50 @@
|
|
|
807
758
|
color: var(--frock-danger-text);
|
|
808
759
|
}
|
|
809
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
|
+
|
|
810
805
|
.skill-chips {
|
|
811
806
|
display: flex;
|
|
812
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/notification-id.ts
CHANGED
|
Binary file
|