@devaccel-labs/app-comments-toolbar 1.1.0 → 1.3.0
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/README.md +148 -30
- package/dist/react.js +417 -64
- package/dist/server.js +342 -14
- package/dist/types/capture/api.d.ts +30 -0
- package/dist/types/capture/editor.d.ts +10 -0
- package/dist/types/capture/ignore.d.ts +19 -0
- package/dist/types/capture/index.d.ts +1 -0
- package/dist/types/capture/picker.d.ts +9 -0
- package/dist/types/capture/picture.d.ts +29 -0
- package/dist/types/capture/surface.d.ts +39 -0
- package/dist/types/core/index.d.ts +2 -2
- package/dist/types/core/resolve.d.ts +2 -1
- package/dist/types/core/types.d.ts +11 -0
- package/dist/types/debug-context.d.ts +7 -0
- package/dist/types/dictation.d.ts +9 -0
- package/dist/types/embed/controller.d.ts +61 -0
- package/dist/types/embed/index.d.ts +1 -0
- package/dist/types/embed/options.d.ts +35 -0
- package/dist/types/help/index.d.ts +1 -0
- package/dist/types/help/panel-index.d.ts +1 -0
- package/dist/types/help/panel-styles.d.ts +1 -0
- package/dist/types/help/panel.d.ts +4 -0
- package/dist/types/help/pill-styles.d.ts +11 -0
- package/dist/types/help/pill.d.ts +44 -0
- package/dist/types/help/react.d.ts +12 -0
- package/dist/types/help/requests.d.ts +2 -0
- package/dist/types/help/types.d.ts +119 -0
- package/dist/types/help/wire.d.ts +61 -0
- package/dist/types/native-wire.d.ts +63 -0
- package/dist/types/native.d.ts +43 -0
- package/dist/types/react.d.ts +7 -0
- package/dist/types/replay.d.ts +6 -0
- package/dist/types/runtime/api.d.ts +250 -0
- package/dist/types/runtime/attachment-model.d.ts +16 -0
- package/dist/types/runtime/attachments.d.ts +43 -7
- package/dist/types/runtime/boot.d.ts +39 -0
- package/dist/types/runtime/browser-label.d.ts +5 -0
- package/dist/types/runtime/build-id.d.ts +1 -0
- package/dist/types/runtime/capture-ui.d.ts +45 -21
- package/dist/types/runtime/clarity-model.d.ts +260 -0
- package/dist/types/runtime/clarity-ui.d.ts +62 -0
- package/dist/types/runtime/client.d.ts +6 -197
- package/dist/types/runtime/composer-size.d.ts +30 -0
- package/dist/types/runtime/dashboard.d.ts +17 -2
- package/dist/types/runtime/debug-context-core.d.ts +88 -0
- package/dist/types/runtime/debug-context-host.d.ts +39 -0
- package/dist/types/runtime/debug-context-model.d.ts +352 -0
- package/dist/types/runtime/debug-context-redact.d.ts +48 -0
- package/dist/types/runtime/debug-context.d.ts +36 -0
- package/dist/types/runtime/details-model.d.ts +7 -0
- package/dist/types/runtime/details.d.ts +7 -5
- package/dist/types/runtime/dictation-engine.d.ts +98 -0
- package/dist/types/runtime/dictation-model.d.ts +28 -0
- package/dist/types/runtime/dictation-ui.d.ts +56 -0
- package/dist/types/runtime/dictation.d.ts +66 -0
- package/dist/types/runtime/draft-dock.d.ts +32 -0
- package/dist/types/runtime/draft-model.d.ts +104 -0
- package/dist/types/runtime/draft-store.d.ts +15 -0
- package/dist/types/runtime/edits.d.ts +1 -1
- package/dist/types/runtime/font.d.ts +16 -0
- package/dist/types/runtime/format.d.ts +30 -1
- package/dist/types/runtime/host-watch.d.ts +20 -0
- package/dist/types/runtime/icons.d.ts +16 -2
- package/dist/types/runtime/index.d.ts +2 -0
- package/dist/types/runtime/ink.d.ts +8 -0
- package/dist/types/runtime/kind-model.d.ts +14 -0
- package/dist/types/runtime/live-model.d.ts +25 -0
- package/dist/types/runtime/loader.d.ts +5 -3
- package/dist/types/runtime/markup-editor.d.ts +54 -0
- package/dist/types/runtime/markup-model.d.ts +126 -0
- package/dist/types/runtime/markup.d.ts +69 -0
- package/dist/types/runtime/mention-parse.d.ts +18 -0
- package/dist/types/runtime/mentions.d.ts +4 -4
- package/dist/types/runtime/page-background.d.ts +9 -0
- package/dist/types/runtime/page-image.d.ts +64 -11
- package/dist/types/runtime/placement.d.ts +6 -3
- package/dist/types/runtime/record.d.ts +18 -2
- package/dist/types/runtime/related.d.ts +1 -1
- package/dist/types/runtime/replay-model.d.ts +80 -0
- package/dist/types/runtime/replay.d.ts +5 -38
- package/dist/types/runtime/screen.d.ts +47 -0
- package/dist/types/runtime/screenshot.d.ts +60 -8
- package/dist/types/runtime/session-source.d.ts +45 -0
- package/dist/types/runtime/session.d.ts +20 -18
- package/dist/types/runtime/shield.d.ts +9 -0
- package/dist/types/runtime/shot.d.ts +45 -0
- package/dist/types/runtime/sketch.d.ts +26 -0
- package/dist/types/runtime/styles.d.ts +1 -1
- package/dist/types/runtime/sync.d.ts +1 -0
- package/dist/types/runtime/thread-model.d.ts +18 -0
- package/dist/types/runtime/ticket-form-model.d.ts +57 -0
- package/dist/types/runtime/ticket-form.d.ts +9 -0
- package/dist/types/runtime/toolbar.d.ts +37 -1
- package/dist/types/runtime/ui-scale.d.ts +17 -0
- package/dist/types/runtime/update.d.ts +32 -8
- package/dist/types/runtime/use-debug-context.d.ts +27 -0
- package/dist/types/server/index.d.ts +22 -0
- package/dist/types/server/support.d.ts +20 -0
- package/dist/types/server.d.ts +2 -0
- package/dist/types/vite/plugin.d.ts +15 -0
- package/dist/vite.js +121 -41
- package/package.json +24 -11
- package/src/capture/api.ts +128 -0
- package/src/capture/editor.tsx +178 -0
- package/src/capture/ignore.ts +38 -0
- package/src/capture/index.ts +27 -0
- package/src/capture/picker.tsx +171 -0
- package/src/capture/picture.ts +90 -0
- package/src/capture/surface.tsx +185 -0
- package/src/compiler/annotate.ts +202 -0
- package/src/compiler/babel.d.ts +11 -0
- package/src/compiler/enabled.ts +55 -0
- package/src/compiler/index.ts +2 -0
- package/src/core/index.ts +8 -0
- package/src/core/resolve.ts +307 -0
- package/src/core/types.ts +58 -0
- package/src/debug-context.ts +47 -0
- package/src/dictation.ts +17 -0
- package/src/embed/controller.ts +215 -0
- package/src/embed/index.ts +68 -0
- package/src/embed/options.ts +100 -0
- package/src/help/index.ts +38 -0
- package/src/help/panel-index.ts +17 -0
- package/src/help/panel-styles.ts +123 -0
- package/src/help/panel.ts +840 -0
- package/src/help/pill-styles.ts +75 -0
- package/src/help/pill.ts +338 -0
- package/src/help/react.tsx +143 -0
- package/src/help/requests.ts +111 -0
- package/src/help/types.ts +124 -0
- package/src/help/wire.ts +55 -0
- package/src/native-wire.ts +124 -0
- package/src/native.ts +282 -0
- package/src/react.ts +27 -0
- package/src/replay.ts +13 -0
- package/src/runtime/api.ts +531 -0
- package/src/runtime/attachment-model.ts +42 -0
- package/src/runtime/attachments.tsx +1260 -0
- package/src/runtime/boot.ts +95 -0
- package/src/runtime/browser-label.ts +31 -0
- package/src/runtime/build-id.ts +10 -0
- package/src/runtime/capture-ui.tsx +306 -0
- package/src/runtime/capture.ts +508 -0
- package/src/runtime/clarity-model.ts +634 -0
- package/src/runtime/clarity-ui.tsx +454 -0
- package/src/runtime/client.ts +151 -0
- package/src/runtime/composer-size.tsx +146 -0
- package/src/runtime/dashboard.tsx +1130 -0
- package/src/runtime/debug-context-core.ts +681 -0
- package/src/runtime/debug-context-host.ts +56 -0
- package/src/runtime/debug-context-model.ts +1319 -0
- package/src/runtime/debug-context-redact.ts +259 -0
- package/src/runtime/debug-context.ts +613 -0
- package/src/runtime/details-model.ts +30 -0
- package/src/runtime/details.tsx +395 -0
- package/src/runtime/dictation-engine.ts +616 -0
- package/src/runtime/dictation-model.ts +79 -0
- package/src/runtime/dictation-ui.tsx +363 -0
- package/src/runtime/dictation.ts +271 -0
- package/src/runtime/dock.ts +108 -0
- package/src/runtime/draft-dock.tsx +124 -0
- package/src/runtime/draft-model.ts +251 -0
- package/src/runtime/draft-store.ts +149 -0
- package/src/runtime/edits.tsx +165 -0
- package/src/runtime/font.ts +42 -0
- package/src/runtime/format.ts +129 -0
- package/src/runtime/freshness.ts +23 -0
- package/src/runtime/handoff.ts +76 -0
- package/src/runtime/host-watch.ts +64 -0
- package/src/runtime/icons.tsx +295 -0
- package/src/runtime/idle.ts +20 -0
- package/src/runtime/index.ts +11 -0
- package/src/runtime/ink.ts +6 -0
- package/src/runtime/kind-model.ts +20 -0
- package/src/runtime/live-model.ts +32 -0
- package/src/runtime/loader.tsx +87 -0
- package/src/runtime/markup-editor.tsx +1333 -0
- package/src/runtime/markup-model.ts +485 -0
- package/src/runtime/markup.ts +382 -0
- package/src/runtime/members.ts +58 -0
- package/src/runtime/mention-parse.ts +56 -0
- package/src/runtime/mentions.tsx +205 -0
- package/src/runtime/page-background.ts +51 -0
- package/src/runtime/page-image.ts +1592 -0
- package/src/runtime/placement.ts +336 -0
- package/src/runtime/record.ts +402 -0
- package/src/runtime/redact.ts +30 -0
- package/src/runtime/related.ts +34 -0
- package/src/runtime/render-page.ts +2 -0
- package/src/runtime/replay-model.ts +232 -0
- package/src/runtime/replay.ts +173 -0
- package/src/runtime/runtime-version.ts +7 -0
- package/src/runtime/runtime.tsx +51 -0
- package/src/runtime/screen.ts +182 -0
- package/src/runtime/screenshot.ts +260 -0
- package/src/runtime/session-source.ts +112 -0
- package/src/runtime/session.ts +108 -0
- package/src/runtime/shield.ts +34 -0
- package/src/runtime/shot.tsx +422 -0
- package/src/runtime/sketch.ts +139 -0
- package/src/runtime/styles.ts +1541 -0
- package/src/runtime/sync.ts +300 -0
- package/src/runtime/thread-model.ts +71 -0
- package/src/runtime/ticket-form-model.ts +128 -0
- package/src/runtime/ticket-form.tsx +342 -0
- package/src/runtime/toolbar.tsx +4427 -0
- package/src/runtime/ui-scale.ts +75 -0
- package/src/runtime/update.ts +98 -0
- package/src/runtime/use-debug-context.ts +81 -0
- package/src/server/index.ts +133 -0
- package/src/server/support.ts +499 -0
- package/src/server.ts +4 -0
- package/src/vite/plugin.ts +102 -0
- package/src/vite.ts +1 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useLayoutEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { AttachButton } from "./attachments.js";
|
|
11
|
+
import { dictationPrompt, insertDictated, rebaseAnchor } from "./dictation.js";
|
|
12
|
+
import {
|
|
13
|
+
Dictation,
|
|
14
|
+
dictationSupported,
|
|
15
|
+
idleDictation,
|
|
16
|
+
voiceRecordingSupported,
|
|
17
|
+
type DictationDeps,
|
|
18
|
+
type DictationState,
|
|
19
|
+
} from "./dictation-engine.js";
|
|
20
|
+
import * as Icon from "./icons.js";
|
|
21
|
+
|
|
22
|
+
type Options = {
|
|
23
|
+
transcribe: DictationDeps["transcribe"];
|
|
24
|
+
/** The writing box's text, and how to change it. Dictated words go through the same path as typing. */
|
|
25
|
+
value: string;
|
|
26
|
+
onChange: (next: string) => void;
|
|
27
|
+
/** People who may be named: Whisper spells them right when it knows them. */
|
|
28
|
+
names: readonly string[];
|
|
29
|
+
onNote: (note: string) => void;
|
|
30
|
+
/** The take as a voice file, attached to the box's message. Without it nothing is recorded. */
|
|
31
|
+
onAudio?: (file: File) => void;
|
|
32
|
+
/** "Keep voice": whether the recording is attached when the take ends. */
|
|
33
|
+
keepVoice?: boolean;
|
|
34
|
+
onKeepVoice?: (keep: boolean) => void;
|
|
35
|
+
/** Each piece's final words with when they were spoken, for a clip recording meanwhile. */
|
|
36
|
+
onSaid?: (text: string, spokenAt: number) => void;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type DictationControl = ReturnType<typeof useDictation>;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Dictation into one writing box. Words land where the caret was when dictation started and keep landing
|
|
43
|
+
* after each other there, even while the person types elsewhere in the box (the point moves with their
|
|
44
|
+
* edits). The take is cancelled when the box's owner unmounts.
|
|
45
|
+
*/
|
|
46
|
+
export function useDictation(options: Options) {
|
|
47
|
+
const latest = useRef(options);
|
|
48
|
+
const anchor = useRef(0);
|
|
49
|
+
const seen = useRef("");
|
|
50
|
+
const field = useRef<HTMLTextAreaElement | null>(null);
|
|
51
|
+
const [state, setState] = useState<DictationState>(idleDictation);
|
|
52
|
+
const [engine] = useState(() => new Dictation());
|
|
53
|
+
useLayoutEffect(() => {
|
|
54
|
+
latest.current = options;
|
|
55
|
+
engine.connect({
|
|
56
|
+
transcribe: (wav, prompt, signal) => latest.current.transcribe(wav, prompt, signal),
|
|
57
|
+
prompt: () => {
|
|
58
|
+
const { value, names } = latest.current;
|
|
59
|
+
return dictationPrompt(
|
|
60
|
+
value.slice(0, rebaseAnchor(seen.current, value, anchor.current)),
|
|
61
|
+
names,
|
|
62
|
+
);
|
|
63
|
+
},
|
|
64
|
+
write: (text) => {
|
|
65
|
+
const { value, onChange } = latest.current;
|
|
66
|
+
const at = rebaseAnchor(seen.current, value, anchor.current);
|
|
67
|
+
const next = insertDictated(value, at, text);
|
|
68
|
+
const box = field.current;
|
|
69
|
+
const follow =
|
|
70
|
+
box?.isConnected &&
|
|
71
|
+
document.activeElement === box &&
|
|
72
|
+
box.selectionStart === box.selectionEnd &&
|
|
73
|
+
box.selectionEnd === at;
|
|
74
|
+
seen.current = next.value;
|
|
75
|
+
anchor.current = next.caret;
|
|
76
|
+
// A second piece can land before React re-renders with the first.
|
|
77
|
+
latest.current = { ...latest.current, value: next.value };
|
|
78
|
+
onChange(next.value);
|
|
79
|
+
if (box && follow)
|
|
80
|
+
requestAnimationFrame(() => {
|
|
81
|
+
if (box.value === next.value) box.setSelectionRange(next.caret, next.caret);
|
|
82
|
+
});
|
|
83
|
+
},
|
|
84
|
+
onState: setState,
|
|
85
|
+
note: (text) => latest.current.onNote(text),
|
|
86
|
+
said: (text, spokenAt) => latest.current.onSaid?.(text, spokenAt),
|
|
87
|
+
...(options.onAudio
|
|
88
|
+
? {
|
|
89
|
+
audio: (file: File) => latest.current.onAudio?.(file),
|
|
90
|
+
keepAudio: () => latest.current.keepVoice !== false,
|
|
91
|
+
}
|
|
92
|
+
: {}),
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
useEffect(() => () => engine.cancel(), [engine]);
|
|
96
|
+
|
|
97
|
+
/** `recordVoice: false` leaves this take's voice out (the screen recording has it). */
|
|
98
|
+
const start = useCallback(
|
|
99
|
+
(box: HTMLTextAreaElement | null, recordVoice = true) => {
|
|
100
|
+
const { value } = latest.current;
|
|
101
|
+
field.current = box;
|
|
102
|
+
// Words from the last take still on their way keep their place; a fresh take starts at the caret.
|
|
103
|
+
if (engine.current.queued === 0 && engine.current.stalled === null) {
|
|
104
|
+
seen.current = value;
|
|
105
|
+
anchor.current = Math.min(
|
|
106
|
+
box?.value === value ? box.selectionEnd : value.length,
|
|
107
|
+
value.length,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
void engine.start({ recordVoice });
|
|
111
|
+
},
|
|
112
|
+
[engine],
|
|
113
|
+
);
|
|
114
|
+
const toggle = useCallback(
|
|
115
|
+
(box: HTMLTextAreaElement | null, recordVoice = true) => {
|
|
116
|
+
if (engine.current.phase === "idle") start(box, recordVoice);
|
|
117
|
+
else engine.stop();
|
|
118
|
+
},
|
|
119
|
+
[engine, start],
|
|
120
|
+
);
|
|
121
|
+
const stop = useCallback(() => engine.stop(), [engine]);
|
|
122
|
+
const cancel = useCallback(() => engine.cancel(), [engine]);
|
|
123
|
+
const setKeepVoice = useCallback((keep: boolean) => latest.current.onKeepVoice?.(keep), []);
|
|
124
|
+
const listening = state.phase !== "idle";
|
|
125
|
+
const recordsVoice = Boolean(options.onAudio) && voiceRecordingSupported();
|
|
126
|
+
const keepVoice = options.keepVoice !== false;
|
|
127
|
+
return useMemo(
|
|
128
|
+
() => ({
|
|
129
|
+
engine,
|
|
130
|
+
state,
|
|
131
|
+
start,
|
|
132
|
+
toggle,
|
|
133
|
+
stop,
|
|
134
|
+
cancel,
|
|
135
|
+
/** The mic is open or being opened. */
|
|
136
|
+
listening,
|
|
137
|
+
/** Anything still to write: posting now would lose words. */
|
|
138
|
+
busy: listening || state.queued > 0,
|
|
139
|
+
/** The pill replaces the attach buttons. */
|
|
140
|
+
shown: listening || state.queued > 0 || state.stalled !== null,
|
|
141
|
+
/** The take is recorded too; the pill offers "Keep voice". */
|
|
142
|
+
recordsVoice,
|
|
143
|
+
keepVoice,
|
|
144
|
+
setKeepVoice,
|
|
145
|
+
}),
|
|
146
|
+
[engine, state, start, toggle, stop, cancel, listening, recordsVoice, keepVoice, setKeepVoice],
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** ⌘⇧Space / Ctrl+Shift+Space in a writing box starts or stops dictation. */
|
|
151
|
+
export function isDictationKey(event: ReactKeyboardEvent<HTMLTextAreaElement>) {
|
|
152
|
+
return (
|
|
153
|
+
(event.metaKey || event.ctrlKey) && event.shiftKey && !event.altKey && event.code === "Space"
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function clock(ms: number) {
|
|
158
|
+
const seconds = Math.floor(ms / 1000);
|
|
159
|
+
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const BARS = 7;
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The live part of the pill: level bars and elapsed time, painted straight to the DOM every frame so the
|
|
166
|
+
* rest of the toolbar never re-renders for them.
|
|
167
|
+
*/
|
|
168
|
+
function Meter({
|
|
169
|
+
engine,
|
|
170
|
+
onSilentMic,
|
|
171
|
+
}: {
|
|
172
|
+
engine: Dictation;
|
|
173
|
+
onSilentMic: (silent: boolean) => void;
|
|
174
|
+
}) {
|
|
175
|
+
const wave = useRef<HTMLSpanElement>(null);
|
|
176
|
+
const time = useRef<HTMLSpanElement>(null);
|
|
177
|
+
const report = useRef(onSilentMic);
|
|
178
|
+
useLayoutEffect(() => {
|
|
179
|
+
report.current = onSilentMic;
|
|
180
|
+
});
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
const history = Array.from({ length: BARS }, () => 0);
|
|
183
|
+
let frame = 0;
|
|
184
|
+
let lastShift = 0;
|
|
185
|
+
let lastClock = "";
|
|
186
|
+
let silent: boolean | null = null;
|
|
187
|
+
const paint = (now: number) => {
|
|
188
|
+
const meter = engine.meter();
|
|
189
|
+
if (now - lastShift > 70) {
|
|
190
|
+
history.shift();
|
|
191
|
+
history.push(meter.level);
|
|
192
|
+
lastShift = now;
|
|
193
|
+
const bars = wave.current?.children;
|
|
194
|
+
if (bars)
|
|
195
|
+
for (let index = 0; index < bars.length; index++) {
|
|
196
|
+
const bar = bars[index];
|
|
197
|
+
if (bar instanceof HTMLElement)
|
|
198
|
+
bar.style.transform = `scaleY(${0.18 + (history[index] ?? 0) * 0.82})`;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const shown = clock(meter.elapsedMs);
|
|
202
|
+
if (shown !== lastClock && time.current) {
|
|
203
|
+
time.current.textContent = shown;
|
|
204
|
+
lastClock = shown;
|
|
205
|
+
}
|
|
206
|
+
if (meter.silentMic !== silent) {
|
|
207
|
+
silent = meter.silentMic;
|
|
208
|
+
report.current(silent);
|
|
209
|
+
}
|
|
210
|
+
frame = requestAnimationFrame(paint);
|
|
211
|
+
};
|
|
212
|
+
frame = requestAnimationFrame(paint);
|
|
213
|
+
return () => cancelAnimationFrame(frame);
|
|
214
|
+
}, [engine]);
|
|
215
|
+
return (
|
|
216
|
+
<>
|
|
217
|
+
<span className="cs-dictate-wave" ref={wave} aria-hidden="true">
|
|
218
|
+
{Array.from({ length: BARS }, (_, index) => (
|
|
219
|
+
<i key={index} />
|
|
220
|
+
))}
|
|
221
|
+
</span>
|
|
222
|
+
<span className="cs-dictate-time" ref={time}>
|
|
223
|
+
0:00
|
|
224
|
+
</span>
|
|
225
|
+
</>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function fieldFor(button: HTMLElement, name: string) {
|
|
230
|
+
return (
|
|
231
|
+
button.closest("form")?.querySelector<HTMLTextAreaElement>(`[data-cs-ui="${name}"]`) ?? null
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The mic button, and while dictating the pill that replaces it: stop, live level, time, and what is
|
|
237
|
+
* happening (listening, writing, or a piece that needs a retry). `field` is the `data-cs-ui` of the box
|
|
238
|
+
* in the same form that the words go into.
|
|
239
|
+
*/
|
|
240
|
+
export function DictateButton({
|
|
241
|
+
dictation,
|
|
242
|
+
field,
|
|
243
|
+
disabled,
|
|
244
|
+
compact = false,
|
|
245
|
+
}: {
|
|
246
|
+
dictation: DictationControl;
|
|
247
|
+
field: string;
|
|
248
|
+
disabled: boolean;
|
|
249
|
+
compact?: boolean;
|
|
250
|
+
}) {
|
|
251
|
+
const [silentMic, setSilentMic] = useState(false);
|
|
252
|
+
const { state, engine } = dictation;
|
|
253
|
+
const supported = dictationSupported();
|
|
254
|
+
|
|
255
|
+
if (!dictation.shown) {
|
|
256
|
+
return (
|
|
257
|
+
<span className="cs-attach">
|
|
258
|
+
<AttachButton
|
|
259
|
+
className="cs-icon-button"
|
|
260
|
+
data-cs-ui="dictate"
|
|
261
|
+
data-tip={supported ? "Dictate ⌘⇧Space" : "Dictation needs https"}
|
|
262
|
+
aria-label="Dictate"
|
|
263
|
+
aria-keyshortcuts="Meta+Shift+Space Control+Shift+Space"
|
|
264
|
+
disabled={disabled}
|
|
265
|
+
// Keeps the caret in the box, so dictation starts where it is and typing can carry on.
|
|
266
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
267
|
+
onClick={(event) => dictation.start(fieldFor(event.currentTarget, field))}
|
|
268
|
+
>
|
|
269
|
+
<Icon.Mic />
|
|
270
|
+
</AttachButton>
|
|
271
|
+
</span>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const status = state.stalled
|
|
276
|
+
? null
|
|
277
|
+
: state.phase === "starting"
|
|
278
|
+
? "Allow the microphone…"
|
|
279
|
+
: state.phase === "listening"
|
|
280
|
+
? silentMic
|
|
281
|
+
? "No sound. Is the mic muted?"
|
|
282
|
+
: state.queued > 0
|
|
283
|
+
? "Writing…"
|
|
284
|
+
: "Listening…"
|
|
285
|
+
: "Writing…";
|
|
286
|
+
|
|
287
|
+
return (
|
|
288
|
+
<div
|
|
289
|
+
className={[
|
|
290
|
+
"cs-dictate",
|
|
291
|
+
dictation.listening ? "is-live" : "",
|
|
292
|
+
state.stalled ? "is-stalled" : "",
|
|
293
|
+
compact ? "is-compact" : "",
|
|
294
|
+
].join(" ")}
|
|
295
|
+
role="group"
|
|
296
|
+
aria-label="Dictation"
|
|
297
|
+
data-cs-ui="dictation"
|
|
298
|
+
data-cs-phase={state.phase}
|
|
299
|
+
>
|
|
300
|
+
{dictation.listening ? (
|
|
301
|
+
<button
|
|
302
|
+
type="button"
|
|
303
|
+
className="cs-dictate-stop"
|
|
304
|
+
data-cs-ui="dictate-stop"
|
|
305
|
+
aria-label="Stop dictation"
|
|
306
|
+
title="Stop (Esc)"
|
|
307
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
308
|
+
onClick={() => dictation.stop()}
|
|
309
|
+
>
|
|
310
|
+
<span />
|
|
311
|
+
</button>
|
|
312
|
+
) : state.stalled ? null : (
|
|
313
|
+
<span className="cs-dictate-spin" aria-hidden="true" />
|
|
314
|
+
)}
|
|
315
|
+
{state.phase === "listening" ? <Meter engine={engine} onSilentMic={setSilentMic} /> : null}
|
|
316
|
+
{dictation.listening && dictation.recordsVoice && !compact ? (
|
|
317
|
+
<button
|
|
318
|
+
type="button"
|
|
319
|
+
className="cs-dictate-keep"
|
|
320
|
+
data-cs-ui="dictate-keep-voice"
|
|
321
|
+
aria-pressed={dictation.keepVoice}
|
|
322
|
+
title={
|
|
323
|
+
dictation.keepVoice
|
|
324
|
+
? "The recording is attached when you stop. Click to keep only the text."
|
|
325
|
+
: "Only the text is kept. Click to attach the recording too."
|
|
326
|
+
}
|
|
327
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
328
|
+
onClick={() => dictation.setKeepVoice(!dictation.keepVoice)}
|
|
329
|
+
>
|
|
330
|
+
Keep voice
|
|
331
|
+
</button>
|
|
332
|
+
) : null}
|
|
333
|
+
{state.stalled ? (
|
|
334
|
+
<span className="cs-dictate-stalled" title={state.stalled} role="alert">
|
|
335
|
+
<span className="cs-dictate-status">Couldn’t transcribe</span>
|
|
336
|
+
<button
|
|
337
|
+
type="button"
|
|
338
|
+
className="cs-link"
|
|
339
|
+
data-cs-ui="dictate-retry"
|
|
340
|
+
onClick={() => engine.retry()}
|
|
341
|
+
>
|
|
342
|
+
Retry
|
|
343
|
+
</button>
|
|
344
|
+
<button
|
|
345
|
+
type="button"
|
|
346
|
+
className="cs-link"
|
|
347
|
+
data-cs-ui="dictate-skip"
|
|
348
|
+
onClick={() => engine.skip()}
|
|
349
|
+
>
|
|
350
|
+
Skip
|
|
351
|
+
</button>
|
|
352
|
+
</span>
|
|
353
|
+
) : (
|
|
354
|
+
<span
|
|
355
|
+
className={`cs-dictate-status${silentMic && state.phase === "listening" ? " is-warn" : ""}`}
|
|
356
|
+
aria-live="polite"
|
|
357
|
+
>
|
|
358
|
+
{status}
|
|
359
|
+
</span>
|
|
360
|
+
)}
|
|
361
|
+
</div>
|
|
362
|
+
);
|
|
363
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dictation's pure parts (ADR-APPC-010): resample the microphone to 16 kHz, cut the take into pieces at
|
|
3
|
+
* pauses, write each piece as WAV, and place the words that come back into the writing box. No browser
|
|
4
|
+
* audio APIs here, so all of it runs under unit tests. `dictation-engine.ts` drives it from a live mic.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { decibels } from "./dictation-model.js";
|
|
8
|
+
|
|
9
|
+
export const SAMPLE_RATE = 16_000;
|
|
10
|
+
const FRAME = 480;
|
|
11
|
+
const FRAME_SECONDS = FRAME / SAMPLE_RATE;
|
|
12
|
+
const frames = (seconds: number) => Math.round(seconds / FRAME_SECONDS);
|
|
13
|
+
|
|
14
|
+
/** Sound kept from before the first loud frame, so a soft first syllable isn't clipped. */
|
|
15
|
+
const PREROLL = frames(0.3);
|
|
16
|
+
/** A pause this long ends a piece. Pauses between words are shorter; between sentences, longer. */
|
|
17
|
+
const END_PAUSE = frames(0.7);
|
|
18
|
+
/** Quiet kept after the last word: enough for Whisper to hear the end, not enough to invent more. */
|
|
19
|
+
const TAIL = frames(0.3);
|
|
20
|
+
/** Less speech than this is a click or a breath and is never sent. */
|
|
21
|
+
const MIN_SPEECH = frames(0.25);
|
|
22
|
+
/** Whisper reads 30 s windows; a piece is cut before that even when nobody pauses. */
|
|
23
|
+
export const MAX_PIECE_SECONDS = 28;
|
|
24
|
+
const MAX_FRAMES = frames(MAX_PIECE_SECONDS);
|
|
25
|
+
/** A forced cut lands on the quietest frame of the last few seconds, which is usually between words. */
|
|
26
|
+
const CUT_SEARCH = frames(3);
|
|
27
|
+
|
|
28
|
+
export type Piece = { samples: Float32Array; seconds: number; speechSeconds: number };
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Streaming sample-rate conversion. Downsampling averages every input sample an output sample covers (a
|
|
32
|
+
* box filter, enough low-pass for speech); upsampling interpolates. State carries across chunks, so the
|
|
33
|
+
* output is the same however the input is split.
|
|
34
|
+
*/
|
|
35
|
+
export class Resampler {
|
|
36
|
+
private readonly step: number;
|
|
37
|
+
private rest = new Float32Array(0);
|
|
38
|
+
private offset = 0;
|
|
39
|
+
|
|
40
|
+
constructor(
|
|
41
|
+
readonly from: number,
|
|
42
|
+
readonly to = SAMPLE_RATE,
|
|
43
|
+
) {
|
|
44
|
+
this.step = from / to;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
push(input: Float32Array): Float32Array {
|
|
48
|
+
if (this.from === this.to) return input.slice();
|
|
49
|
+
const data = new Float32Array(this.rest.length + input.length);
|
|
50
|
+
data.set(this.rest);
|
|
51
|
+
data.set(input, this.rest.length);
|
|
52
|
+
const out: number[] = [];
|
|
53
|
+
let start = this.offset;
|
|
54
|
+
const step = this.step;
|
|
55
|
+
const ready = () =>
|
|
56
|
+
step >= 1 ? start + step <= data.length : Math.floor(start) + 1 < data.length;
|
|
57
|
+
while (ready()) {
|
|
58
|
+
if (step >= 1) {
|
|
59
|
+
const end = start + step;
|
|
60
|
+
let sum = 0;
|
|
61
|
+
let weight = 0;
|
|
62
|
+
for (let index = Math.floor(start); index < Math.ceil(end); index++) {
|
|
63
|
+
const covered = Math.min(index + 1, end) - Math.max(index, start);
|
|
64
|
+
sum += data[index]! * covered;
|
|
65
|
+
weight += covered;
|
|
66
|
+
}
|
|
67
|
+
out.push(weight ? sum / weight : 0);
|
|
68
|
+
} else {
|
|
69
|
+
const index = Math.floor(start);
|
|
70
|
+
const fraction = start - index;
|
|
71
|
+
out.push(data[index]! * (1 - fraction) + data[index + 1]! * fraction);
|
|
72
|
+
}
|
|
73
|
+
start += step;
|
|
74
|
+
}
|
|
75
|
+
const keep = Math.floor(start);
|
|
76
|
+
this.rest = data.slice(keep);
|
|
77
|
+
this.offset = start - keep;
|
|
78
|
+
return Float32Array.from(out);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Cuts 16 kHz audio into pieces at pauses. Speech is a frame louder than the room: a noise floor that falls
|
|
84
|
+
* fast and rises slowly, plus a margin, never quieter than −55 dBFS. A piece ends after `END_PAUSE` of quiet,
|
|
85
|
+
* or is cut at `MAX_PIECE_SECONDS`; pieces with almost no speech are dropped, so silence never reaches
|
|
86
|
+
* Whisper (which turns silence into "Thank you.").
|
|
87
|
+
*/
|
|
88
|
+
export class Segmenter {
|
|
89
|
+
/** Loudness of the newest frame. */
|
|
90
|
+
lastDb = -100;
|
|
91
|
+
/** Loudest frame so far: −100 means the mic delivered digital silence. */
|
|
92
|
+
peakDb = -100;
|
|
93
|
+
private floor = -65;
|
|
94
|
+
private partial = new Float32Array(FRAME);
|
|
95
|
+
private filled = 0;
|
|
96
|
+
private preroll: Float32Array[] = [];
|
|
97
|
+
private active: Array<{ samples: Float32Array; db: number; speech: boolean }> | null = null;
|
|
98
|
+
|
|
99
|
+
push(samples: Float32Array): Piece[] {
|
|
100
|
+
const out: Piece[] = [];
|
|
101
|
+
let at = 0;
|
|
102
|
+
while (at < samples.length) {
|
|
103
|
+
const take = Math.min(FRAME - this.filled, samples.length - at);
|
|
104
|
+
this.partial.set(samples.subarray(at, at + take), this.filled);
|
|
105
|
+
this.filled += take;
|
|
106
|
+
at += take;
|
|
107
|
+
if (this.filled < FRAME) break;
|
|
108
|
+
const piece = this.frame(this.partial);
|
|
109
|
+
if (piece) out.push(piece);
|
|
110
|
+
this.partial = new Float32Array(FRAME);
|
|
111
|
+
this.filled = 0;
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The audio of the piece still being spoken (not yet cut), for a live preview; null between pieces. */
|
|
117
|
+
pending(): Float32Array | null {
|
|
118
|
+
const active = this.active;
|
|
119
|
+
if (!active?.some((frame) => frame.speech)) return null;
|
|
120
|
+
const samples = new Float32Array(active.length * FRAME);
|
|
121
|
+
active.forEach((frame, index) => samples.set(frame.samples, index * FRAME));
|
|
122
|
+
return samples;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The piece in progress, when the take ends. */
|
|
126
|
+
flush(): Piece | null {
|
|
127
|
+
const active = this.active;
|
|
128
|
+
this.active = null;
|
|
129
|
+
this.preroll = [];
|
|
130
|
+
if (!active) return null;
|
|
131
|
+
let quiet = 0;
|
|
132
|
+
while (quiet < active.length && !active[active.length - 1 - quiet]!.speech) quiet++;
|
|
133
|
+
return this.piece(active.slice(0, active.length - Math.max(0, quiet - TAIL)));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private frame(samples: Float32Array): Piece | null {
|
|
137
|
+
const db = decibels(samples);
|
|
138
|
+
this.lastDb = db;
|
|
139
|
+
this.peakDb = Math.max(this.peakDb, db);
|
|
140
|
+
const threshold = Math.min(-25, Math.max(-55, this.floor + 12));
|
|
141
|
+
const speech = db > threshold;
|
|
142
|
+
this.floor =
|
|
143
|
+
db < this.floor
|
|
144
|
+
? this.floor * 0.7 + db * 0.3
|
|
145
|
+
: this.floor + (db - this.floor) * (speech ? 0.002 : 0.01);
|
|
146
|
+
|
|
147
|
+
if (!this.active) {
|
|
148
|
+
if (!speech) {
|
|
149
|
+
this.preroll.push(samples);
|
|
150
|
+
if (this.preroll.length > PREROLL) this.preroll.shift();
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
this.active = this.preroll.map((frame) => ({ samples: frame, db: -100, speech: false }));
|
|
154
|
+
this.preroll = [];
|
|
155
|
+
}
|
|
156
|
+
const active = this.active;
|
|
157
|
+
active.push({ samples, db, speech });
|
|
158
|
+
|
|
159
|
+
let quiet = 0;
|
|
160
|
+
while (quiet < active.length && !active[active.length - 1 - quiet]!.speech) quiet++;
|
|
161
|
+
if (quiet >= END_PAUSE) {
|
|
162
|
+
const kept = active.length - (quiet - TAIL);
|
|
163
|
+
this.active = null;
|
|
164
|
+
this.preroll = active.slice(-PREROLL).map((frame) => frame.samples);
|
|
165
|
+
return this.piece(active.slice(0, kept));
|
|
166
|
+
}
|
|
167
|
+
if (active.length >= MAX_FRAMES) {
|
|
168
|
+
let cut = active.length - 1;
|
|
169
|
+
for (let index = active.length - CUT_SEARCH; index < active.length; index++) {
|
|
170
|
+
if (active[index]!.db < active[cut]!.db) cut = index;
|
|
171
|
+
}
|
|
172
|
+
this.active = active.slice(cut);
|
|
173
|
+
return this.piece(active.slice(0, cut));
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private piece(list: Array<{ samples: Float32Array; speech: boolean }>): Piece | null {
|
|
179
|
+
const speech = list.filter((frame) => frame.speech).length;
|
|
180
|
+
if (speech < MIN_SPEECH) return null;
|
|
181
|
+
const samples = new Float32Array(list.length * FRAME);
|
|
182
|
+
list.forEach((frame, index) => samples.set(frame.samples, index * FRAME));
|
|
183
|
+
return {
|
|
184
|
+
samples,
|
|
185
|
+
seconds: list.length * FRAME_SECONDS,
|
|
186
|
+
speechSeconds: speech * FRAME_SECONDS,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 16-bit mono PCM WAV. Quiet takes are raised (up to 8x, to a 0.9 peak) so a far-away mic still reads
|
|
193
|
+
* clearly; loud ones are left alone.
|
|
194
|
+
*/
|
|
195
|
+
export function encodeWav(samples: Float32Array, rate = SAMPLE_RATE): ArrayBuffer {
|
|
196
|
+
let peak = 0;
|
|
197
|
+
for (const sample of samples) peak = Math.max(peak, Math.abs(sample));
|
|
198
|
+
const gain = peak > 0 && peak < 0.3 ? Math.min(8, 0.9 / peak) : 1;
|
|
199
|
+
const buffer = new ArrayBuffer(44 + samples.length * 2);
|
|
200
|
+
const view = new DataView(buffer);
|
|
201
|
+
const text = (at: number, value: string) => {
|
|
202
|
+
for (let index = 0; index < value.length; index++)
|
|
203
|
+
view.setUint8(at + index, value.charCodeAt(index));
|
|
204
|
+
};
|
|
205
|
+
text(0, "RIFF");
|
|
206
|
+
view.setUint32(4, 36 + samples.length * 2, true);
|
|
207
|
+
text(8, "WAVE");
|
|
208
|
+
text(12, "fmt ");
|
|
209
|
+
view.setUint32(16, 16, true);
|
|
210
|
+
view.setUint16(20, 1, true);
|
|
211
|
+
view.setUint16(22, 1, true);
|
|
212
|
+
view.setUint32(24, rate, true);
|
|
213
|
+
view.setUint32(28, rate * 2, true);
|
|
214
|
+
view.setUint16(32, 2, true);
|
|
215
|
+
view.setUint16(34, 16, true);
|
|
216
|
+
text(36, "data");
|
|
217
|
+
view.setUint32(40, samples.length * 2, true);
|
|
218
|
+
samples.forEach((sample, index) => {
|
|
219
|
+
const value = Math.max(-1, Math.min(1, sample * gain));
|
|
220
|
+
view.setInt16(44 + index * 2, value < 0 ? value * 0x8000 : value * 0x7fff, true);
|
|
221
|
+
});
|
|
222
|
+
return buffer;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export {
|
|
226
|
+
decibels,
|
|
227
|
+
dictationPrompt,
|
|
228
|
+
insertDictated,
|
|
229
|
+
meterLevel,
|
|
230
|
+
rebaseAnchor,
|
|
231
|
+
voiceRecordingName,
|
|
232
|
+
} from "./dictation-model.js";
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* What to tell someone when the browser won't give us the microphone. A refusal can come from the site
|
|
236
|
+
* setting, from the operating system, or from a browser that never shows a prompt at all (an app's built-in
|
|
237
|
+
* browser, or Chrome after the prompt was dismissed a few times); `permission` (from the Permissions API, when
|
|
238
|
+
* the browser has it) and the error's text tell those apart, because each needs a different fix.
|
|
239
|
+
*/
|
|
240
|
+
export function microphoneError(
|
|
241
|
+
error: unknown,
|
|
242
|
+
permission?: PermissionState | null,
|
|
243
|
+
userAgent = typeof navigator === "undefined" ? "" : navigator.userAgent,
|
|
244
|
+
/** The page's Permissions-Policy verdict for `microphone`, when the browser exposes it. */
|
|
245
|
+
pageAllows: boolean | null = null,
|
|
246
|
+
) {
|
|
247
|
+
const name = error instanceof Error || error instanceof DOMException ? error.name : "";
|
|
248
|
+
const message = error instanceof Error || error instanceof DOMException ? error.message : "";
|
|
249
|
+
if (name === "NotAllowedError" || name === "SecurityError" || name === "PermissionDeniedError") {
|
|
250
|
+
// An Electron app's built-in browser (T3 Code, Slack, …) answers "denied" without ever prompting, so the
|
|
251
|
+
// address-bar advice would send the person looking for a setting that doesn't exist.
|
|
252
|
+
if (pageAllows === false)
|
|
253
|
+
return "This page’s Permissions-Policy turns the microphone off, so the browser can’t ask for it.";
|
|
254
|
+
if (/\bElectron\//.test(userAgent))
|
|
255
|
+
return "This app’s built-in browser doesn’t allow the microphone. Open the page in Chrome, Safari or Edge to dictate.";
|
|
256
|
+
if (/system/i.test(message))
|
|
257
|
+
return "Your computer blocks this browser from the microphone. Allow it in System Settings → Privacy & Security → Microphone, then try again.";
|
|
258
|
+
if (permission === "denied")
|
|
259
|
+
return "Microphone access is blocked for this site. Click the icon left of the address, set Microphone to Allow, then reload.";
|
|
260
|
+
return "This browser didn’t let the page ask for the microphone. Allow it for this site from the address bar, or open the page in Chrome, Safari or Edge if you’re in an app’s built-in browser.";
|
|
261
|
+
}
|
|
262
|
+
if (
|
|
263
|
+
name === "NotFoundError" ||
|
|
264
|
+
name === "OverconstrainedError" ||
|
|
265
|
+
name === "DevicesNotFoundError"
|
|
266
|
+
)
|
|
267
|
+
return "No microphone found. Plug one in and try again.";
|
|
268
|
+
if (name === "NotReadableError" || name === "AbortError" || name === "TrackStartError")
|
|
269
|
+
return "The microphone is in use by another app, or your system blocked it.";
|
|
270
|
+
return "Couldn’t start the microphone.";
|
|
271
|
+
}
|