@devaccel-labs/app-comments-toolbar 1.0.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 +152 -18
- package/dist/react.js +516 -61
- 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 +8 -1
- 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 +7 -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 +3 -1
- 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 -11
- 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 +123 -0
- 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/runtime.d.ts +6 -0
- package/dist/types/runtime/screen.d.ts +47 -0
- package/dist/types/runtime/screenshot.d.ts +60 -6
- 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 +37 -0
- 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 +26 -13
- 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
- package/dist/chunks/chunk-CSKXLF33.js +0 -153
- package/dist/chunks/chunk-HUM5FQII.js +0 -160
- package/dist/chunks/render-page-76MLIT6M.js +0 -1421
- package/dist/chunks/runtime-JX75TKER.js +0 -9267
- package/dist/chunks/screenshot-66ARTBGR.js +0 -12
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One dictation take from a live microphone (ADR-APPC-010). The mic feeds a `Segmenter`; every piece it
|
|
3
|
+
* cuts is encoded and sent for transcription in order, one at a time, while the person keeps talking, so
|
|
4
|
+
* the words arrive a second or two after each pause. A piece that fails is retried, then held with its
|
|
5
|
+
* audio until the person retries or skips it; nothing after it is written out of order.
|
|
6
|
+
*
|
|
7
|
+
* A composer that can hold files may also keep the take itself: the same mic stream is recorded as compressed
|
|
8
|
+
* audio while it is transcribed, and handed over as one file when the take ends (see `DictationDeps.audio`).
|
|
9
|
+
*/
|
|
10
|
+
import { ApiError } from "./client.js";
|
|
11
|
+
import { voiceRecordingName } from "./dictation-model.js";
|
|
12
|
+
import {
|
|
13
|
+
encodeWav,
|
|
14
|
+
meterLevel,
|
|
15
|
+
microphoneError,
|
|
16
|
+
Resampler,
|
|
17
|
+
SAMPLE_RATE,
|
|
18
|
+
Segmenter,
|
|
19
|
+
type Piece,
|
|
20
|
+
} from "./dictation.js";
|
|
21
|
+
|
|
22
|
+
export { voiceRecordingName } from "./dictation-model.js";
|
|
23
|
+
|
|
24
|
+
/** A take stops itself after this long. */
|
|
25
|
+
export const MAX_TAKE_MS = 10 * 60_000;
|
|
26
|
+
const RETRY_DELAYS_MS = [600, 1_500, 4_000];
|
|
27
|
+
const REQUEST_TIMEOUT_MS = 25_000;
|
|
28
|
+
/** Nothing above this from the mic for `SILENT_MIC_MS`: it is sending digital silence (muted, or the wrong device). */
|
|
29
|
+
const SILENT_MIC_DB = -95;
|
|
30
|
+
const SILENT_MIC_MS = 3_000;
|
|
31
|
+
/** How often the piece still being spoken is re-read for the live preview, and the least audio worth it. */
|
|
32
|
+
const PREVIEW_EVERY_MS = 1_200;
|
|
33
|
+
const PREVIEW_MIN_SAMPLES = SAMPLE_RATE * 0.8;
|
|
34
|
+
|
|
35
|
+
export type DictationPhase = "idle" | "starting" | "listening";
|
|
36
|
+
|
|
37
|
+
export type DictationState = {
|
|
38
|
+
phase: DictationPhase;
|
|
39
|
+
/** Pieces cut and not yet written, including the one being transcribed. */
|
|
40
|
+
queued: number;
|
|
41
|
+
/** The first piece that couldn't be transcribed; everything after it waits. */
|
|
42
|
+
stalled: string | null;
|
|
43
|
+
/**
|
|
44
|
+
* Rough words for what was said and isn't written yet (needs `DictationDeps.preview`). Each piece's
|
|
45
|
+
* part is replaced by its final text when that is written.
|
|
46
|
+
*/
|
|
47
|
+
preview: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const idleDictation: DictationState = {
|
|
51
|
+
phase: "idle",
|
|
52
|
+
queued: 0,
|
|
53
|
+
stalled: null,
|
|
54
|
+
preview: "",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type DictationDeps = {
|
|
58
|
+
transcribe(wav: Blob, prompt: string, signal: AbortSignal): Promise<string>;
|
|
59
|
+
/** The context sent with the next piece; read when it is sent, so it includes earlier pieces. */
|
|
60
|
+
prompt(): string;
|
|
61
|
+
write(text: string): void;
|
|
62
|
+
onState(state: DictationState): void;
|
|
63
|
+
/** Why a take stopped or never started, for the person to read. */
|
|
64
|
+
note(text: string): void;
|
|
65
|
+
/** Optional: a quick read of the piece still being spoken, shown as it grows. Failures are ignored. */
|
|
66
|
+
preview?(wav: Blob, prompt: string, signal: AbortSignal): Promise<string>;
|
|
67
|
+
/**
|
|
68
|
+
* Optional: the take as a voice recording, once it ends. Only composers that can attach files give this;
|
|
69
|
+
* the mic is then recorded alongside the transcription. Not called for a cancelled take.
|
|
70
|
+
*/
|
|
71
|
+
audio?(file: File): void;
|
|
72
|
+
/** Whether to hand over the recording, read when the take ends so it can change mid-take. Default: yes. */
|
|
73
|
+
keepAudio?(): boolean;
|
|
74
|
+
/**
|
|
75
|
+
* Optional: a piece's final words with when they were spoken (`Date.now()` at the start of the piece, so a
|
|
76
|
+
* clip recording meanwhile can place them on its clock). Called right after `write`.
|
|
77
|
+
*/
|
|
78
|
+
said?(text: string, spokenAt: number): void;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
type Recording = { recorder: MediaRecorder; chunks: Blob[]; startedAt: number };
|
|
82
|
+
|
|
83
|
+
type Capture = {
|
|
84
|
+
stream: MediaStream;
|
|
85
|
+
context: AudioContext;
|
|
86
|
+
nodes: AudioNode[];
|
|
87
|
+
startedAt: number;
|
|
88
|
+
limit: ReturnType<typeof setTimeout>;
|
|
89
|
+
recording: Recording | null;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* `preview` is the live text that piece had when it was cut, shown until its final text is written. `spokenAt`
|
|
94
|
+
* is when the piece started (epoch ms), stamped at the cut by its length.
|
|
95
|
+
*/
|
|
96
|
+
type Waiting = { wav: Blob; seconds: number; preview: string; spokenAt: number };
|
|
97
|
+
|
|
98
|
+
/** Only one box listens at a time; starting another stops this one. */
|
|
99
|
+
let listening: Dictation | null = null;
|
|
100
|
+
|
|
101
|
+
/** The AudioWorklet that hands the mic's samples to the page in ~2048-sample blocks. */
|
|
102
|
+
const TAP = `class CsDictationTap extends AudioWorkletProcessor {
|
|
103
|
+
constructor() { super(); this.block = new Float32Array(2048); this.filled = 0; }
|
|
104
|
+
process(inputs) {
|
|
105
|
+
const channel = inputs[0] && inputs[0][0];
|
|
106
|
+
if (channel) for (let i = 0; i < channel.length; i++) {
|
|
107
|
+
this.block[this.filled++] = channel[i];
|
|
108
|
+
if (this.filled === this.block.length) {
|
|
109
|
+
this.port.postMessage(this.block, [this.block.buffer]);
|
|
110
|
+
this.block = new Float32Array(2048);
|
|
111
|
+
this.filled = 0;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
registerProcessor("cs-dictation-tap", CsDictationTap);`;
|
|
118
|
+
|
|
119
|
+
/** Compressed formats for the kept recording, best first. */
|
|
120
|
+
const AUDIO_TYPES = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"];
|
|
121
|
+
/** A take shorter than this is a slip of the finger, not something worth attaching. */
|
|
122
|
+
const MIN_RECORDING_MS = 1_000;
|
|
123
|
+
|
|
124
|
+
function audioType(): string | null {
|
|
125
|
+
if (typeof MediaRecorder === "undefined") return null;
|
|
126
|
+
return AUDIO_TYPES.find((type) => MediaRecorder.isTypeSupported(type)) ?? null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Whether this browser can keep a dictation take as a voice recording. */
|
|
130
|
+
export function voiceRecordingSupported(): boolean {
|
|
131
|
+
return audioType() !== null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Records the mic as compressed audio, or null when the browser can't or nobody wants it. */
|
|
135
|
+
function startRecording(stream: MediaStream): Recording | null {
|
|
136
|
+
const type = audioType();
|
|
137
|
+
if (!type) return null;
|
|
138
|
+
try {
|
|
139
|
+
// Speech needs little: 32 kbps keeps a full 10-minute take near 2.4 MB, well under the 10 MB file cap.
|
|
140
|
+
const recorder = new MediaRecorder(stream, { mimeType: type, audioBitsPerSecond: 32_000 });
|
|
141
|
+
const recording: Recording = { recorder, chunks: [], startedAt: Date.now() };
|
|
142
|
+
recorder.addEventListener("dataavailable", (event: BlobEvent) => {
|
|
143
|
+
if (event.data.size > 0) recording.chunks.push(event.data);
|
|
144
|
+
});
|
|
145
|
+
recorder.start(1_000);
|
|
146
|
+
return recording;
|
|
147
|
+
} catch {
|
|
148
|
+
// The transcription still works without it.
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Ends the recording and hands the file over, unless `deliver` is null (the take was dropped). */
|
|
154
|
+
function finishRecording(recording: Recording, deliver: ((file: File) => void) | null) {
|
|
155
|
+
const { recorder, chunks, startedAt } = recording;
|
|
156
|
+
const done = () => {
|
|
157
|
+
if (!deliver || chunks.length === 0 || Date.now() - startedAt < MIN_RECORDING_MS) return;
|
|
158
|
+
const type = (recorder.mimeType || chunks[0]!.type || "audio/webm").split(";")[0]!.trim();
|
|
159
|
+
const blob = new Blob(chunks, { type });
|
|
160
|
+
deliver(new File([blob], voiceRecordingName(new Date(startedAt), type), { type }));
|
|
161
|
+
};
|
|
162
|
+
// The mic was unplugged: the recorder has already stopped and flushed.
|
|
163
|
+
if (recorder.state === "inactive") return done();
|
|
164
|
+
recorder.addEventListener("stop", done, { once: true });
|
|
165
|
+
try {
|
|
166
|
+
recorder.stop();
|
|
167
|
+
} catch {
|
|
168
|
+
// Already stopped by the track ending.
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function audioContextClass(): typeof AudioContext | null {
|
|
173
|
+
if (typeof window === "undefined") return null;
|
|
174
|
+
const legacy: unknown = Reflect.get(window, "webkitAudioContext");
|
|
175
|
+
return (
|
|
176
|
+
window.AudioContext ?? (typeof legacy === "function" ? (legacy as typeof AudioContext) : null)
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function dictationSupported() {
|
|
181
|
+
return Boolean(
|
|
182
|
+
typeof navigator !== "undefined" &&
|
|
183
|
+
typeof navigator.mediaDevices?.getUserMedia === "function" &&
|
|
184
|
+
audioContextClass() &&
|
|
185
|
+
(typeof window === "undefined" || window.isSecureContext !== false),
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The node that taps the mic. An AudioWorklet runs off the main thread, so a busy page can't drop audio;
|
|
191
|
+
* pages whose CSP blocks `blob:` scripts get the older ScriptProcessor, which works everywhere.
|
|
192
|
+
*/
|
|
193
|
+
async function tap(context: AudioContext, onSamples: (samples: Float32Array) => void) {
|
|
194
|
+
if (context.audioWorklet && typeof AudioWorkletNode !== "undefined") {
|
|
195
|
+
const url = URL.createObjectURL(new Blob([TAP], { type: "text/javascript" }));
|
|
196
|
+
try {
|
|
197
|
+
await context.audioWorklet.addModule(url);
|
|
198
|
+
const node = new AudioWorkletNode(context, "cs-dictation-tap", {
|
|
199
|
+
numberOfInputs: 1,
|
|
200
|
+
numberOfOutputs: 1,
|
|
201
|
+
channelCount: 1,
|
|
202
|
+
channelCountMode: "explicit",
|
|
203
|
+
});
|
|
204
|
+
node.port.onmessage = (event: MessageEvent<Float32Array>) => onSamples(event.data);
|
|
205
|
+
return node;
|
|
206
|
+
} catch {
|
|
207
|
+
// CSP or an old engine: fall through.
|
|
208
|
+
} finally {
|
|
209
|
+
URL.revokeObjectURL(url);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const node = context.createScriptProcessor(4096, 1, 1);
|
|
213
|
+
node.onaudioprocess = (event) => onSamples(event.inputBuffer.getChannelData(0).slice());
|
|
214
|
+
return node;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The site's microphone permission, where the browser exposes it. */
|
|
218
|
+
async function microphonePermission(): Promise<PermissionState | null> {
|
|
219
|
+
try {
|
|
220
|
+
const status = await navigator.permissions?.query({ name: "microphone" });
|
|
221
|
+
return status?.state ?? null;
|
|
222
|
+
} catch {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Whether the page's Permissions-Policy lets it use the mic, where the browser can say (Chromium). */
|
|
228
|
+
function microphoneAllowedByPage(): boolean | null {
|
|
229
|
+
const policy: unknown =
|
|
230
|
+
Reflect.get(document, "permissionsPolicy") ?? Reflect.get(document, "featurePolicy");
|
|
231
|
+
const allows: unknown = policy ? Reflect.get(policy, "allowsFeature") : null;
|
|
232
|
+
if (typeof allows !== "function") return null;
|
|
233
|
+
try {
|
|
234
|
+
return Boolean(allows.call(policy, "microphone"));
|
|
235
|
+
} catch {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The browser's own answer, appended to a refusal so a screenshot shows which refusal it was: error name and
|
|
242
|
+
* text, the site permission, the page policy, and whether this is an app's built-in (Electron) browser.
|
|
243
|
+
*/
|
|
244
|
+
function refusalDetail(error: unknown, permission: PermissionState | null) {
|
|
245
|
+
const name = error instanceof Error || error instanceof DOMException ? error.name : "Error";
|
|
246
|
+
const message = error instanceof Error || error instanceof DOMException ? error.message : "";
|
|
247
|
+
const policy = microphoneAllowedByPage();
|
|
248
|
+
const parts = [
|
|
249
|
+
`${name}${message ? `: ${message}` : ""}`,
|
|
250
|
+
`site permission ${permission ?? "unknown"}`,
|
|
251
|
+
`page policy ${policy === null ? "unknown" : policy ? "allows" : "blocks"} mic`,
|
|
252
|
+
/\bElectron\//.test(navigator.userAgent) ? "Electron" : null,
|
|
253
|
+
];
|
|
254
|
+
return parts.filter(Boolean).join(" · ");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function retryable(error: unknown) {
|
|
258
|
+
// fetch's TypeError (offline, DNS, a dropped connection) and our timeout are worth another go.
|
|
259
|
+
if (!(error instanceof ApiError)) return true;
|
|
260
|
+
if (fatal(error)) return false;
|
|
261
|
+
return error.status === 408 || error.status === 429 || error.status >= 500;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Errors that make every later piece fail too: stop listening rather than pile up audio. */
|
|
265
|
+
function fatal(error: unknown) {
|
|
266
|
+
return (
|
|
267
|
+
error instanceof ApiError &&
|
|
268
|
+
(error.status === 401 ||
|
|
269
|
+
error.status === 403 ||
|
|
270
|
+
error.code === "dictation_unavailable" ||
|
|
271
|
+
error.code === "rate_limited")
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function failureText(error: unknown) {
|
|
276
|
+
if (error instanceof ApiError) return error.message;
|
|
277
|
+
if (typeof navigator !== "undefined" && navigator.onLine === false)
|
|
278
|
+
return "You’re offline. Your words are kept and will be written when you’re back.";
|
|
279
|
+
return "Couldn’t reach the transcription service.";
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function wait(ms: number, signal: AbortSignal) {
|
|
283
|
+
return new Promise<void>((resolve, reject) => {
|
|
284
|
+
const timer = setTimeout(resolve, ms);
|
|
285
|
+
signal.addEventListener(
|
|
286
|
+
"abort",
|
|
287
|
+
() => {
|
|
288
|
+
clearTimeout(timer);
|
|
289
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
290
|
+
},
|
|
291
|
+
{ once: true },
|
|
292
|
+
);
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export class Dictation {
|
|
297
|
+
private state: DictationState = idleDictation;
|
|
298
|
+
private capture: Capture | null = null;
|
|
299
|
+
private segmenter = new Segmenter();
|
|
300
|
+
private queue: Waiting[] = [];
|
|
301
|
+
private sending = false;
|
|
302
|
+
private stalled = false;
|
|
303
|
+
private abort = new AbortController();
|
|
304
|
+
/** Bumped by `cancel` and by every start, so an awaited step from an older take does nothing. */
|
|
305
|
+
private take = 0;
|
|
306
|
+
private previewTimer: ReturnType<typeof setInterval> | null = null;
|
|
307
|
+
private previewing = false;
|
|
308
|
+
/** Bumped when a piece is cut, so a preview of the old piece that lands late is dropped. */
|
|
309
|
+
private previewPiece = 0;
|
|
310
|
+
private previewedSamples = 0;
|
|
311
|
+
private live = "";
|
|
312
|
+
private readonly onOnline = () => {
|
|
313
|
+
if (this.stalled) this.retry();
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
/** Nothing is wired until `connect`: a take started before then writes nowhere. */
|
|
317
|
+
private deps: DictationDeps = {
|
|
318
|
+
transcribe: () => Promise.reject(new Error("Dictation isn’t connected.")),
|
|
319
|
+
prompt: () => "",
|
|
320
|
+
write: () => {},
|
|
321
|
+
onState: () => {},
|
|
322
|
+
note: () => {},
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
constructor(deps?: DictationDeps) {
|
|
326
|
+
if (deps) this.deps = deps;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Points the take at the latest callbacks; the React hook calls this after every render. */
|
|
330
|
+
connect(deps: DictationDeps) {
|
|
331
|
+
this.deps = deps;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
get current() {
|
|
335
|
+
return this.state;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Live loudness (0–1), elapsed time and whether the mic seems dead, read by the meter every frame. */
|
|
339
|
+
meter() {
|
|
340
|
+
const capture = this.capture;
|
|
341
|
+
const elapsed = capture ? performance.now() - capture.startedAt : 0;
|
|
342
|
+
return {
|
|
343
|
+
level: capture ? meterLevel(this.segmenter.lastDb) : 0,
|
|
344
|
+
elapsedMs: elapsed,
|
|
345
|
+
silentMic:
|
|
346
|
+
Boolean(capture) && elapsed > SILENT_MIC_MS && this.segmenter.peakDb < SILENT_MIC_DB,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Must be called from the click or key press, so the browser lets the audio start. `recordVoice: false`
|
|
352
|
+
* skips the voice recording for this take (dictating over a screen recording, which has its own sound).
|
|
353
|
+
*/
|
|
354
|
+
async start({ recordVoice = true }: { recordVoice?: boolean } = {}) {
|
|
355
|
+
if (this.state.phase !== "idle") return;
|
|
356
|
+
const AudioContextClass = audioContextClass();
|
|
357
|
+
if (!dictationSupported() || !AudioContextClass) {
|
|
358
|
+
this.deps.note(
|
|
359
|
+
"Dictation needs a secure (https) page in a browser that allows microphone access.",
|
|
360
|
+
);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (listening && listening !== this) listening.stop();
|
|
364
|
+
listening = this;
|
|
365
|
+
const take = ++this.take;
|
|
366
|
+
// Created inside the gesture: Safari only lets a context start from one.
|
|
367
|
+
const context = new AudioContextClass();
|
|
368
|
+
void context.resume().catch(() => {});
|
|
369
|
+
this.set({ phase: "starting" });
|
|
370
|
+
let stream: MediaStream;
|
|
371
|
+
try {
|
|
372
|
+
stream = await navigator.mediaDevices.getUserMedia({
|
|
373
|
+
audio: {
|
|
374
|
+
channelCount: 1,
|
|
375
|
+
echoCancellation: true,
|
|
376
|
+
noiseSuppression: true,
|
|
377
|
+
autoGainControl: true,
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
} catch (error) {
|
|
381
|
+
void context.close().catch(() => {});
|
|
382
|
+
if (take === this.take) {
|
|
383
|
+
this.set({ phase: "idle" });
|
|
384
|
+
const permission = await microphonePermission();
|
|
385
|
+
const detail = refusalDetail(error, permission);
|
|
386
|
+
// eslint-disable-next-line no-console -- the browser console is where a host page developer diagnoses a refused microphone.
|
|
387
|
+
console.warn("[app-comments] dictation: microphone refused", detail, error);
|
|
388
|
+
this.deps.note(
|
|
389
|
+
`${microphoneError(error, permission, navigator.userAgent, microphoneAllowedByPage())} (${detail})`,
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
if (listening === this) listening = null;
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (take !== this.take) {
|
|
396
|
+
stream.getTracks().forEach((track) => track.stop());
|
|
397
|
+
void context.close().catch(() => {});
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
try {
|
|
401
|
+
await context.resume();
|
|
402
|
+
const resampler = new Resampler(context.sampleRate);
|
|
403
|
+
this.segmenter = new Segmenter();
|
|
404
|
+
const source = context.createMediaStreamSource(stream);
|
|
405
|
+
const node = await tap(context, (samples) => {
|
|
406
|
+
if (take !== this.take || !this.capture) return;
|
|
407
|
+
for (const piece of this.segmenter.push(resampler.push(samples))) this.enqueue(piece);
|
|
408
|
+
});
|
|
409
|
+
if (take !== this.take) throw new DOMException("Cancelled", "AbortError");
|
|
410
|
+
// Silent output keeps the graph pulling audio in every browser without playing the mic back.
|
|
411
|
+
const mute = context.createGain();
|
|
412
|
+
mute.gain.value = 0;
|
|
413
|
+
source.connect(node);
|
|
414
|
+
node.connect(mute);
|
|
415
|
+
mute.connect(context.destination);
|
|
416
|
+
for (const track of stream.getAudioTracks())
|
|
417
|
+
track.addEventListener("ended", () => {
|
|
418
|
+
if (take === this.take) this.stop("The microphone disconnected.");
|
|
419
|
+
});
|
|
420
|
+
this.capture = {
|
|
421
|
+
stream,
|
|
422
|
+
context,
|
|
423
|
+
nodes: [source, node, mute],
|
|
424
|
+
recording: recordVoice && this.deps.audio ? startRecording(stream) : null,
|
|
425
|
+
startedAt: performance.now(),
|
|
426
|
+
limit: setTimeout(
|
|
427
|
+
() =>
|
|
428
|
+
this.stop(
|
|
429
|
+
"Dictation stopped at the 10-minute limit. What you said is kept; press the mic to keep going.",
|
|
430
|
+
),
|
|
431
|
+
MAX_TAKE_MS,
|
|
432
|
+
),
|
|
433
|
+
};
|
|
434
|
+
if (this.deps.preview)
|
|
435
|
+
this.previewTimer = setInterval(() => void this.refreshPreview(take), PREVIEW_EVERY_MS);
|
|
436
|
+
this.set({ phase: "listening" });
|
|
437
|
+
} catch (error) {
|
|
438
|
+
stream.getTracks().forEach((track) => track.stop());
|
|
439
|
+
void context.close().catch(() => {});
|
|
440
|
+
if (take === this.take) {
|
|
441
|
+
this.set({ phase: "idle" });
|
|
442
|
+
this.deps.note(microphoneError(error));
|
|
443
|
+
if (listening === this) listening = null;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Stops the mic. What was said is still sent and written, and the recording handed over. */
|
|
449
|
+
stop(note: string | null = null) {
|
|
450
|
+
this.endTake(true);
|
|
451
|
+
this.set({ phase: "idle" });
|
|
452
|
+
if (note) this.deps.note(note);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
private endTake(keep: boolean) {
|
|
456
|
+
const capture = this.capture;
|
|
457
|
+
this.capture = null;
|
|
458
|
+
if (listening === this) listening = null;
|
|
459
|
+
if (this.previewTimer) clearInterval(this.previewTimer);
|
|
460
|
+
this.previewTimer = null;
|
|
461
|
+
if (capture) {
|
|
462
|
+
clearTimeout(capture.limit);
|
|
463
|
+
if (capture.recording) {
|
|
464
|
+
const deps = this.deps;
|
|
465
|
+
const wanted = keep && deps.audio && (deps.keepAudio?.() ?? true);
|
|
466
|
+
finishRecording(capture.recording, wanted ? (file) => deps.audio?.(file) : null);
|
|
467
|
+
}
|
|
468
|
+
const last = this.segmenter.flush();
|
|
469
|
+
if (last) this.enqueue(last);
|
|
470
|
+
for (const node of capture.nodes) node.disconnect();
|
|
471
|
+
capture.stream.getTracks().forEach((track) => track.stop());
|
|
472
|
+
void capture.context.close().catch(() => {});
|
|
473
|
+
} else if (this.state.phase === "starting") {
|
|
474
|
+
// The permission prompt is still up: abandon the take when it resolves.
|
|
475
|
+
this.take++;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Stops everything and drops what wasn't written yet: the box it was writing into is gone. */
|
|
480
|
+
cancel() {
|
|
481
|
+
this.take++;
|
|
482
|
+
this.endTake(false);
|
|
483
|
+
this.abort.abort();
|
|
484
|
+
this.abort = new AbortController();
|
|
485
|
+
this.queue = [];
|
|
486
|
+
this.live = "";
|
|
487
|
+
this.sending = false;
|
|
488
|
+
this.stalled = false;
|
|
489
|
+
window.removeEventListener("online", this.onOnline);
|
|
490
|
+
this.set({ ...idleDictation });
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Tries the held piece again. */
|
|
494
|
+
retry() {
|
|
495
|
+
if (!this.stalled) return;
|
|
496
|
+
this.stalled = false;
|
|
497
|
+
window.removeEventListener("online", this.onOnline);
|
|
498
|
+
this.set({ stalled: null });
|
|
499
|
+
void this.drain();
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Drops the held piece and carries on with the rest. */
|
|
503
|
+
skip() {
|
|
504
|
+
if (!this.stalled) return;
|
|
505
|
+
this.queue.shift();
|
|
506
|
+
this.retry();
|
|
507
|
+
this.set({ queued: this.queue.length, preview: this.previewText() });
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Queues a cut piece for transcription. The mic tap calls this; so can a test, without a mic. */
|
|
511
|
+
enqueue(piece: Piece) {
|
|
512
|
+
this.queue.push({
|
|
513
|
+
wav: new Blob([encodeWav(piece.samples)], { type: "audio/wav" }),
|
|
514
|
+
seconds: piece.seconds,
|
|
515
|
+
preview: this.live,
|
|
516
|
+
spokenAt: Date.now() - piece.seconds * 1000,
|
|
517
|
+
});
|
|
518
|
+
this.live = "";
|
|
519
|
+
this.previewPiece++;
|
|
520
|
+
this.previewedSamples = 0;
|
|
521
|
+
this.set({ queued: this.queue.length, preview: this.previewText() });
|
|
522
|
+
void this.drain();
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
private async drain() {
|
|
526
|
+
if (this.sending || this.stalled) return;
|
|
527
|
+
this.sending = true;
|
|
528
|
+
const signal = this.abort.signal;
|
|
529
|
+
try {
|
|
530
|
+
while (this.queue.length && !this.stalled && !signal.aborted) {
|
|
531
|
+
const next = this.queue[0]!;
|
|
532
|
+
try {
|
|
533
|
+
const text = await this.send(next, signal);
|
|
534
|
+
if (signal.aborted) return;
|
|
535
|
+
this.queue.shift();
|
|
536
|
+
if (text) {
|
|
537
|
+
this.deps.write(text);
|
|
538
|
+
this.deps.said?.(text, next.spokenAt);
|
|
539
|
+
}
|
|
540
|
+
this.set({ queued: this.queue.length, preview: this.previewText() });
|
|
541
|
+
} catch (error) {
|
|
542
|
+
if (signal.aborted) return;
|
|
543
|
+
this.stalled = true;
|
|
544
|
+
if (fatal(error)) this.stop(failureText(error));
|
|
545
|
+
this.set({ stalled: failureText(error), queued: this.queue.length });
|
|
546
|
+
window.addEventListener("online", this.onOnline);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
} finally {
|
|
550
|
+
if (!signal.aborted) this.sending = false;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
private async send(piece: Waiting, signal: AbortSignal) {
|
|
555
|
+
for (let attempt = 0; ; attempt++) {
|
|
556
|
+
const timeout = new AbortController();
|
|
557
|
+
const timer = setTimeout(() => timeout.abort(), REQUEST_TIMEOUT_MS);
|
|
558
|
+
const abortRequest = () => timeout.abort();
|
|
559
|
+
signal.addEventListener("abort", abortRequest, { once: true });
|
|
560
|
+
try {
|
|
561
|
+
return await this.deps.transcribe(piece.wav, this.deps.prompt(), timeout.signal);
|
|
562
|
+
} catch (error) {
|
|
563
|
+
if (signal.aborted) throw error;
|
|
564
|
+
const delay = RETRY_DELAYS_MS[attempt];
|
|
565
|
+
if (delay === undefined || !retryable(error)) throw error;
|
|
566
|
+
await wait(delay, signal);
|
|
567
|
+
} finally {
|
|
568
|
+
clearTimeout(timer);
|
|
569
|
+
signal.removeEventListener("abort", abortRequest);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
private previewText() {
|
|
575
|
+
return [...this.queue.map((piece) => piece.preview), this.live].filter(Boolean).join(" ");
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Re-reads the piece still being spoken. Best-effort: a failed or late preview is dropped. */
|
|
579
|
+
private async refreshPreview(take: number) {
|
|
580
|
+
const preview = this.deps.preview;
|
|
581
|
+
if (!preview || this.previewing || take !== this.take || !this.capture) return;
|
|
582
|
+
const samples = this.segmenter.pending();
|
|
583
|
+
if (
|
|
584
|
+
!samples ||
|
|
585
|
+
samples.length < PREVIEW_MIN_SAMPLES ||
|
|
586
|
+
samples.length === this.previewedSamples
|
|
587
|
+
)
|
|
588
|
+
return;
|
|
589
|
+
this.previewedSamples = samples.length;
|
|
590
|
+
const piece = this.previewPiece;
|
|
591
|
+
const signal = this.abort.signal;
|
|
592
|
+
const timeout = new AbortController();
|
|
593
|
+
const timer = setTimeout(() => timeout.abort(), REQUEST_TIMEOUT_MS);
|
|
594
|
+
const abortRequest = () => timeout.abort();
|
|
595
|
+
signal.addEventListener("abort", abortRequest, { once: true });
|
|
596
|
+
this.previewing = true;
|
|
597
|
+
try {
|
|
598
|
+
const wav = new Blob([encodeWav(samples)], { type: "audio/wav" });
|
|
599
|
+
const text = await preview(wav, this.deps.prompt(), timeout.signal);
|
|
600
|
+
if (signal.aborted || piece !== this.previewPiece || take !== this.take) return;
|
|
601
|
+
this.live = text.trim();
|
|
602
|
+
this.set({ preview: this.previewText() });
|
|
603
|
+
} catch {
|
|
604
|
+
// The final text still comes from the cut piece.
|
|
605
|
+
} finally {
|
|
606
|
+
this.previewing = false;
|
|
607
|
+
clearTimeout(timer);
|
|
608
|
+
signal.removeEventListener("abort", abortRequest);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
private set(patch: Partial<DictationState>) {
|
|
613
|
+
this.state = { ...this.state, ...patch };
|
|
614
|
+
this.deps.onState(this.state);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dictation's DOM-free parts that native dictation (m4a takes) shares with the web toolbar: the input meter,
|
|
3
|
+
* placing dictated words into the writing box, the context that helps the transcriber, and voice note names.
|
|
4
|
+
* Re-exported from `./dictation.ts` and `./dictation-engine.ts`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Where the dictation point moved to after someone edited the text it lives in. An edit wholly before it
|
|
9
|
+
* shifts it; an edit after it leaves it; an edit across it puts it at the end of the edit.
|
|
10
|
+
*/
|
|
11
|
+
export function rebaseAnchor(previous: string, next: string, anchor: number) {
|
|
12
|
+
if (previous === next) return Math.min(anchor, next.length);
|
|
13
|
+
let prefix = 0;
|
|
14
|
+
const shortest = Math.min(previous.length, next.length);
|
|
15
|
+
while (prefix < shortest && previous[prefix] === next[prefix]) prefix++;
|
|
16
|
+
let suffix = 0;
|
|
17
|
+
while (
|
|
18
|
+
suffix < shortest - prefix &&
|
|
19
|
+
previous[previous.length - 1 - suffix] === next[next.length - 1 - suffix]
|
|
20
|
+
)
|
|
21
|
+
suffix++;
|
|
22
|
+
if (anchor <= prefix) return anchor;
|
|
23
|
+
if (anchor >= previous.length - suffix) return anchor + next.length - previous.length;
|
|
24
|
+
return next.length - suffix;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* `text` written into `value` at `at`, spaced like typing: a space before it unless it starts a line, a
|
|
29
|
+
* space after it unless punctuation follows, and a capital when it starts a sentence.
|
|
30
|
+
*/
|
|
31
|
+
export function insertDictated(value: string, at: number, text: string) {
|
|
32
|
+
const words = text.trim();
|
|
33
|
+
if (!words) return { value, caret: at };
|
|
34
|
+
const before = value.slice(0, at);
|
|
35
|
+
const after = value.slice(at);
|
|
36
|
+
const sentenceStart = !before.trim() || /[.!?]["')\]]?\s*$/.test(before) || /\n\s*$/.test(before);
|
|
37
|
+
const written = sentenceStart ? words.charAt(0).toUpperCase() + words.slice(1) : words;
|
|
38
|
+
const lead = before && !/\s$/.test(before) ? " " : "";
|
|
39
|
+
const trail = after && !/^[\s.,!?;:)]/.test(after) ? " " : "";
|
|
40
|
+
const inserted = `${lead}${written}${trail}`;
|
|
41
|
+
return {
|
|
42
|
+
value: `${before}${inserted}${after}`,
|
|
43
|
+
caret: before.length + lead.length + written.length,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Context for Whisper: the people who might be named, then the writing just before the dictation point.
|
|
49
|
+
* Both help it spell names and keep punctuation consistent across pieces.
|
|
50
|
+
*/
|
|
51
|
+
export function dictationPrompt(before: string, names: readonly string[]) {
|
|
52
|
+
const people = names.length ? `${names.slice(0, 20).join(", ")}.`.slice(0, 250) : "";
|
|
53
|
+
const recent = before.slice(-300).trim();
|
|
54
|
+
return [people, recent].filter(Boolean).join(" ");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function decibels(samples: Float32Array) {
|
|
58
|
+
let sum = 0;
|
|
59
|
+
for (const sample of samples) sum += sample * sample;
|
|
60
|
+
const rms = Math.sqrt(sum / Math.max(1, samples.length));
|
|
61
|
+
return rms > 0 ? 20 * Math.log10(rms) : -100;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 0–1 for a meter: −60 dBFS and below is empty, −12 is full. */
|
|
65
|
+
export function meterLevel(db: number) {
|
|
66
|
+
return Math.max(0, Math.min(1, (db + 60) / 48));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function two(value: number) {
|
|
70
|
+
return String(value).padStart(2, "0");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** `voice-2026-10-31-13-56-37.webm`, after the screen recordings' `recording-…` names. */
|
|
74
|
+
export function voiceRecordingName(at: Date, type: string): string {
|
|
75
|
+
const base = type.split(";")[0]!.trim();
|
|
76
|
+
const extension = base === "audio/mp4" ? "m4a" : base === "audio/ogg" ? "ogg" : "webm";
|
|
77
|
+
const stamp = `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())}-${two(at.getHours())}-${two(at.getMinutes())}-${two(at.getSeconds())}`;
|
|
78
|
+
return `voice-${stamp}.${extension}`;
|
|
79
|
+
}
|