@swiftbrowser/web 0.17.8 → 0.18.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/package.json +1 -1
- package/src/animation.ts +60 -27
- package/src/appFiles.ts +21 -16
- package/src/audioRecorder.ts +392 -0
- package/src/editorSelection.ts +80 -0
- package/src/files.ts +442 -0
- package/src/gestureComposition.ts +92 -0
- package/src/hostRequests.ts +38 -4
- package/src/interaction.ts +108 -27
- package/src/layout/engine.ts +295 -19
- package/src/layout/types.ts +6 -0
- package/src/layoutDom.ts +13 -5
- package/src/main.ts +4 -0
- package/src/pinch.ts +9 -1
- package/src/pinnedViews.ts +131 -0
- package/src/plainEditor.ts +79 -0
- package/src/protocol.ts +304 -13
- package/src/renderer.ts +354 -43
- package/src/richEditor.ts +330 -0
- package/src/scrollGeometry.ts +79 -0
- package/src/scrollTransition.ts +145 -0
- package/src/styles.css +142 -11
- package/src/symbols.ts +22 -0
- package/src/touchPaging.ts +23 -2
- package/src/undo.ts +126 -0
- package/src/video.ts +59 -8
package/package.json
CHANGED
package/src/animation.ts
CHANGED
|
@@ -458,7 +458,7 @@ export interface AnimatorOptions {
|
|
|
458
458
|
}
|
|
459
459
|
|
|
460
460
|
export class Animator {
|
|
461
|
-
/** The element clones of removed elements are appended to (the screen)
|
|
461
|
+
/** The element clones of removed elements are appended to (the screen), unless they were in a sheet: `placeClone`. */
|
|
462
462
|
private readonly root: HTMLElement;
|
|
463
463
|
private readonly selfAnimating: ReadonlySet<string>;
|
|
464
464
|
private readonly playOwnFrames: AnimatorOptions['playOwnFrames'];
|
|
@@ -470,7 +470,7 @@ export class Animator {
|
|
|
470
470
|
}
|
|
471
471
|
|
|
472
472
|
/** Commits waiting for the next animation frame (`commitNextFrame`), in order. */
|
|
473
|
-
private pending: { pass: PassRecord; animation: Animation | undefined; frames: readonly FrameChange[] }[] = [];
|
|
473
|
+
private pending: { pass: PassRecord; animation: Animation | undefined; frames: readonly FrameChange[]; onCommitted?: ((started: globalThis.Animation[]) => void) | undefined }[] = [];
|
|
474
474
|
private pendingFrame: number | null = null;
|
|
475
475
|
|
|
476
476
|
/**
|
|
@@ -481,13 +481,14 @@ export class Animator {
|
|
|
481
481
|
* callback, they cost the frame its one layout and the tweens still start
|
|
482
482
|
* on it. In a hidden page nothing paints, so the commit runs now.
|
|
483
483
|
*/
|
|
484
|
-
commitNextFrame(pass: PassRecord, animation: Animation | undefined, frames: readonly FrameChange[] = []): void {
|
|
484
|
+
commitNextFrame(pass: PassRecord, animation: Animation | undefined, frames: readonly FrameChange[] = [], onCommitted?: (started: globalThis.Animation[]) => void): void {
|
|
485
485
|
if (typeof requestAnimationFrame !== 'function' || (typeof document !== 'undefined' && document.hidden)) {
|
|
486
486
|
this.flushCommits();
|
|
487
|
-
this.commit(pass, animation, frames);
|
|
487
|
+
const started = this.commit(pass, animation, frames, onCommitted !== undefined);
|
|
488
|
+
onCommitted?.(started);
|
|
488
489
|
return;
|
|
489
490
|
}
|
|
490
|
-
this.pending.push({ pass, animation, frames });
|
|
491
|
+
this.pending.push({ pass, animation, frames, onCommitted });
|
|
491
492
|
this.pendingFrame ??= requestAnimationFrame(() => this.flushCommits());
|
|
492
493
|
}
|
|
493
494
|
|
|
@@ -499,7 +500,16 @@ export class Animator {
|
|
|
499
500
|
}
|
|
500
501
|
const pending = this.pending;
|
|
501
502
|
this.pending = [];
|
|
502
|
-
for (const { pass, animation, frames } of pending)
|
|
503
|
+
for (const { pass, animation, frames, onCommitted } of pending) {
|
|
504
|
+
const started = this.commit(pass, animation, frames, onCommitted !== undefined);
|
|
505
|
+
onCommitted?.(started);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** Every animation running under the root (the exit clones' included), when the browser can list them. */
|
|
510
|
+
private runningAnimations(): globalThis.Animation[] {
|
|
511
|
+
const root = this.root as HTMLElement & { getAnimations?: (options?: { subtree?: boolean }) => globalThis.Animation[] };
|
|
512
|
+
return typeof root.getAnimations === 'function' ? root.getAnimations({ subtree: true }) : [];
|
|
503
513
|
}
|
|
504
514
|
|
|
505
515
|
/** Computed values of the animated properties, or null when not rendered. */
|
|
@@ -536,9 +546,17 @@ export class Animator {
|
|
|
536
546
|
* (`withAnimation`); subtrees whose `animationToken` changed use their own
|
|
537
547
|
* animation instead, as `.animation(_:value:)` overrides the transaction.
|
|
538
548
|
* An `implicit` change (an inspector shown or hidden) animates even without
|
|
539
|
-
* one, with the commit's animation or `default`.
|
|
549
|
+
* one, with the commit's animation or `default`. With `collect`, returns
|
|
550
|
+
* the animations the commit started (a `withAnimation` completion waits
|
|
551
|
+
* for them); else an empty array.
|
|
540
552
|
*/
|
|
541
|
-
commit(pass: PassRecord, commitAnimation: Animation | undefined, frames: readonly FrameChange[] = []):
|
|
553
|
+
commit(pass: PassRecord, commitAnimation: Animation | undefined, frames: readonly FrameChange[] = [], collect = false): globalThis.Animation[] {
|
|
554
|
+
const before = collect ? new Set(this.runningAnimations()) : null;
|
|
555
|
+
this.animate(pass, commitAnimation, frames);
|
|
556
|
+
return before ? this.runningAnimations().filter((animation) => !before.has(animation)) : [];
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
private animate(pass: PassRecord, commitAnimation: Animation | undefined, frames: readonly FrameChange[]): void {
|
|
542
560
|
const tokenRoots = new Map<HTMLElement, Animation | undefined>();
|
|
543
561
|
for (const [el, rec] of pass.updated) {
|
|
544
562
|
if (rec.tokenChanged) tokenRoots.set(el, rec.implicit ? commitAnimation : rec.animation);
|
|
@@ -760,15 +778,7 @@ export class Animator {
|
|
|
760
778
|
clone.classList.add(EXIT_CLONE_CLASS);
|
|
761
779
|
clone.setAttribute('aria-hidden', 'true');
|
|
762
780
|
clone.toggleAttribute('inert', true);
|
|
763
|
-
|
|
764
|
-
s.position = 'absolute';
|
|
765
|
-
s.left = `${change.rect.left}px`;
|
|
766
|
-
s.top = `${change.rect.top}px`;
|
|
767
|
-
s.width = `${change.rect.width}px`;
|
|
768
|
-
s.height = `${change.rect.height}px`;
|
|
769
|
-
s.margin = '0';
|
|
770
|
-
s.pointerEvents = 'none';
|
|
771
|
-
this.root.append(clone);
|
|
781
|
+
this.placeClone(clone, change.rect, change.el);
|
|
772
782
|
const timing = baseTiming(timingFor(animation));
|
|
773
783
|
const rolls = change.kind === 'numericText';
|
|
774
784
|
const away = change.countsDown ? '0 45%' : '0 -45%';
|
|
@@ -813,7 +823,8 @@ export class Animator {
|
|
|
813
823
|
const sx = from.width > 0 ? to.width / from.width : 1;
|
|
814
824
|
const sy = from.height > 0 ? to.height / from.height : 1;
|
|
815
825
|
const timing = baseTiming(timingFor(animation));
|
|
816
|
-
|
|
826
|
+
const shift = CLONE_SHIFT.get(clone) ?? { x: 0, y: 0 };
|
|
827
|
+
clone.style.transformOrigin = `${from.left - shift.x - parseFloat(clone.style.left || '0')}px ${from.top - shift.y - parseFloat(clone.style.top || '0')}px`;
|
|
817
828
|
clone.animate(
|
|
818
829
|
[{ transform: 'none' }, { transform: `translate(${to.left - from.left}px, ${to.top - from.top}px) scale(${sx}, ${sy})` }],
|
|
819
830
|
{ ...timing, fill: 'forwards', composite: 'add' },
|
|
@@ -982,6 +993,33 @@ export class Animator {
|
|
|
982
993
|
}
|
|
983
994
|
}
|
|
984
995
|
|
|
996
|
+
/**
|
|
997
|
+
* Put a clone at `rect` (relative to the root) in the layer `from` is in:
|
|
998
|
+
* the root, or the sheet it is in (a full-screen cover's slides), which is
|
|
999
|
+
* drawn over the presenting tree; at the root, a clone of something in a
|
|
1000
|
+
* sheet would play under the sheet, unseen.
|
|
1001
|
+
*/
|
|
1002
|
+
private placeClone(clone: HTMLElement, rect: DOMRect, from: HTMLElement | null): void {
|
|
1003
|
+
const sheet = from?.closest<HTMLElement>('.sb-sheet');
|
|
1004
|
+
const layer = sheet && this.root.contains(sheet) ? sheet : this.root;
|
|
1005
|
+
let shift = { x: 0, y: 0 };
|
|
1006
|
+
if (layer !== this.root) {
|
|
1007
|
+
const base = this.rootRect();
|
|
1008
|
+
const box = layer.getBoundingClientRect();
|
|
1009
|
+
shift = { x: box.left - base.left, y: box.top - base.top };
|
|
1010
|
+
CLONE_SHIFT.set(clone, shift);
|
|
1011
|
+
}
|
|
1012
|
+
const s = clone.style;
|
|
1013
|
+
s.position = 'absolute';
|
|
1014
|
+
s.left = `${rect.left - shift.x}px`;
|
|
1015
|
+
s.top = `${rect.top - shift.y}px`;
|
|
1016
|
+
s.width = `${rect.width}px`;
|
|
1017
|
+
s.height = `${rect.height}px`;
|
|
1018
|
+
s.margin = '0';
|
|
1019
|
+
s.pointerEvents = 'none';
|
|
1020
|
+
layer.append(clone);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
985
1023
|
/**
|
|
986
1024
|
* Re-insert a visual clone of a removed element at its old position and
|
|
987
1025
|
* play its transition backwards; the clone is dropped when done.
|
|
@@ -1000,15 +1038,7 @@ export class Animator {
|
|
|
1000
1038
|
if (rec.el !== parent && isBarToolbar(parent)) clone.dataset['sbBarItem'] = parent.dataset['sbPlacement'] ?? '';
|
|
1001
1039
|
clone.setAttribute('aria-hidden', 'true');
|
|
1002
1040
|
clone.toggleAttribute('inert', true);
|
|
1003
|
-
|
|
1004
|
-
s.position = 'absolute';
|
|
1005
|
-
s.left = `${rec.rect.left}px`;
|
|
1006
|
-
s.top = `${rec.rect.top}px`;
|
|
1007
|
-
s.width = `${rec.rect.width}px`;
|
|
1008
|
-
s.height = `${rec.rect.height}px`;
|
|
1009
|
-
s.margin = '0';
|
|
1010
|
-
s.pointerEvents = 'none';
|
|
1011
|
-
this.root.append(clone);
|
|
1041
|
+
this.placeClone(clone, rec.rect, parent);
|
|
1012
1042
|
|
|
1013
1043
|
const from: Keyframe = {};
|
|
1014
1044
|
const to: Keyframe = {};
|
|
@@ -1046,6 +1076,9 @@ function isDrawn(el: HTMLElement): boolean {
|
|
|
1046
1076
|
return true;
|
|
1047
1077
|
}
|
|
1048
1078
|
|
|
1079
|
+
/** How far an exit clone's layer (a sheet) is from the animation root, when it is not the root: `placeClone`. */
|
|
1080
|
+
const CLONE_SHIFT = new WeakMap<HTMLElement, { x: number; y: number }>();
|
|
1081
|
+
|
|
1049
1082
|
/** Transitions are stored by the renderer per element (see `setTransition`). */
|
|
1050
1083
|
const TRANSITIONS = new WeakMap<HTMLElement, Transition>();
|
|
1051
1084
|
|
package/src/appFiles.ts
CHANGED
|
@@ -125,26 +125,42 @@ export interface FilesPersistence {
|
|
|
125
125
|
clear(): Promise<void>;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
|
|
128
|
+
/** A row of the `entries` store: a file's bytes or a directory, with when it was last written where the writer said. */
|
|
129
|
+
export interface EntryRow {
|
|
129
130
|
path: string;
|
|
130
131
|
data?: Uint8Array;
|
|
131
132
|
directory?: boolean;
|
|
133
|
+
/** Milliseconds since the epoch; rows the module wrote (and older rows) have none. */
|
|
134
|
+
modified?: number;
|
|
132
135
|
}
|
|
133
136
|
|
|
134
|
-
const ENTRIES = 'entries';
|
|
137
|
+
export const ENTRIES = 'entries';
|
|
135
138
|
|
|
136
139
|
export function filesDatabaseName(app: string): string {
|
|
137
140
|
return `sb-files:${app}`;
|
|
138
141
|
}
|
|
139
142
|
|
|
140
|
-
|
|
143
|
+
/** The app's files database, opened (and made) once per `app`; `factory` is the browser's unless a test passes one. */
|
|
144
|
+
export function openFilesDatabase(app: string, factory: IDBFactory = indexedDB): Promise<IDBDatabase> {
|
|
145
|
+
return new Promise((resolve, reject) => {
|
|
146
|
+
const open = factory.open(filesDatabaseName(app), 1);
|
|
147
|
+
open.onupgradeneeded = () => {
|
|
148
|
+
if (!open.result.objectStoreNames.contains(ENTRIES)) open.result.createObjectStore(ENTRIES, { keyPath: 'path' });
|
|
149
|
+
};
|
|
150
|
+
open.onsuccess = () => resolve(open.result);
|
|
151
|
+
open.onerror = () => reject(open.error ?? new Error(`could not open ${filesDatabaseName(app)}`));
|
|
152
|
+
open.onblocked = () => reject(new Error(`${filesDatabaseName(app)} is blocked by another tab`));
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function request<T>(req: IDBRequest<T>): Promise<T> {
|
|
141
157
|
return new Promise((resolve, reject) => {
|
|
142
158
|
req.onsuccess = () => resolve(req.result);
|
|
143
159
|
req.onerror = () => reject(req.error ?? new Error('IndexedDB request failed'));
|
|
144
160
|
});
|
|
145
161
|
}
|
|
146
162
|
|
|
147
|
-
function done(transaction: IDBTransaction): Promise<void> {
|
|
163
|
+
export function done(transaction: IDBTransaction): Promise<void> {
|
|
148
164
|
return new Promise((resolve, reject) => {
|
|
149
165
|
transaction.oncomplete = () => resolve();
|
|
150
166
|
transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB transaction aborted'));
|
|
@@ -155,18 +171,7 @@ function done(transaction: IDBTransaction): Promise<void> {
|
|
|
155
171
|
/** The app's home in IndexedDB (`factory` is the browser's unless a test passes one). */
|
|
156
172
|
export function indexedDBFiles(app: string, factory: IDBFactory = indexedDB): FilesPersistence {
|
|
157
173
|
let opened: Promise<IDBDatabase> | null = null;
|
|
158
|
-
const database = (): Promise<IDBDatabase> =>
|
|
159
|
-
opened ??= new Promise((resolve, reject) => {
|
|
160
|
-
const open = factory.open(filesDatabaseName(app), 1);
|
|
161
|
-
open.onupgradeneeded = () => {
|
|
162
|
-
if (!open.result.objectStoreNames.contains(ENTRIES)) open.result.createObjectStore(ENTRIES, { keyPath: 'path' });
|
|
163
|
-
};
|
|
164
|
-
open.onsuccess = () => resolve(open.result);
|
|
165
|
-
open.onerror = () => reject(open.error ?? new Error(`could not open ${filesDatabaseName(app)}`));
|
|
166
|
-
open.onblocked = () => reject(new Error(`${filesDatabaseName(app)} is blocked by another tab`));
|
|
167
|
-
});
|
|
168
|
-
return opened;
|
|
169
|
-
};
|
|
174
|
+
const database = (): Promise<IDBDatabase> => (opened ??= openFilesDatabase(app, factory));
|
|
170
175
|
let queue: Promise<void> = Promise.resolve();
|
|
171
176
|
|
|
172
177
|
return {
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recording from the microphone, as AVFoundation's `AVAudioRecorder` does it
|
|
3
|
+
* for an iOS app: the page side of `useAudioRecorder` (@swiftbrowser/react).
|
|
4
|
+
* The browser's `getUserMedia` and `MediaRecorder` record; an `AnalyserNode`
|
|
5
|
+
* meters the input as `averagePower(forChannel:)` would. A recording ends as
|
|
6
|
+
* a file in the app's `Documents/Recordings` (files.ts: the app's IndexedDB
|
|
7
|
+
* home), a `file:` URL that plays in a `video` element and that `discard`
|
|
8
|
+
* deletes, as the native app's does; a page that keeps no files answers a
|
|
9
|
+
* `data:` URL instead, which goes with its string. `trim` cuts a recording
|
|
10
|
+
* with the Web Audio API (decoded, the kept parts copied, written as WAV, to
|
|
11
|
+
* a new file). The native app answers the same calls with `AVAudioRecorder`
|
|
12
|
+
* and `AVAssetExportSession` (packages/native, AudioRecorderBridge.swift).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { fileURL, type FilesHost } from './files';
|
|
16
|
+
|
|
17
|
+
/** A finished recording: a URL that plays it (a `file:` URL in `Documents/Recordings`, or a `data:` URL where the page keeps no files) and its length. */
|
|
18
|
+
export interface AudioRecording {
|
|
19
|
+
url: string;
|
|
20
|
+
/** Seconds. */
|
|
21
|
+
duration: number;
|
|
22
|
+
/** The MIME type it was recorded in. */
|
|
23
|
+
type: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The input now: its level (0 silent … 1 loud) and the seconds recorded so far. */
|
|
27
|
+
export interface AudioMeter {
|
|
28
|
+
level: number;
|
|
29
|
+
time: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A stretch of a recording, in seconds from its start. */
|
|
33
|
+
export interface AudioRange {
|
|
34
|
+
start: number;
|
|
35
|
+
end: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* One recording at a time. `start` asks for the microphone (rejecting with
|
|
40
|
+
* `permission: …` when it is refused); `stop` ends it and resolves with the
|
|
41
|
+
* recording; `cancel` ends it and keeps nothing; `discard` deletes one made
|
|
42
|
+
* before; `trim` makes a new recording out of parts of one.
|
|
43
|
+
*/
|
|
44
|
+
export interface AudioRecorderHost {
|
|
45
|
+
start(): Promise<void>;
|
|
46
|
+
pause(): Promise<void>;
|
|
47
|
+
resume(): Promise<void>;
|
|
48
|
+
stop(): Promise<AudioRecording>;
|
|
49
|
+
cancel(): Promise<void>;
|
|
50
|
+
meter(): Promise<AudioMeter>;
|
|
51
|
+
/** Deletes a recording `stop` made (its file; a `data:` URL is nothing to delete). */
|
|
52
|
+
discard(url: string): Promise<void>;
|
|
53
|
+
/**
|
|
54
|
+
* A new recording made of the parts of `url` in `keep` (seconds, in
|
|
55
|
+
* order, concatenated): `[{ start, end }]` trims to that range, two parts
|
|
56
|
+
* around a range delete it. The original is left as it was. Rejects with
|
|
57
|
+
* `unsupported: …` where the host cannot cut audio, `empty: …` when
|
|
58
|
+
* nothing of the recording falls in `keep`.
|
|
59
|
+
*/
|
|
60
|
+
trim(url: string, keep: readonly AudioRange[]): Promise<AudioRecording>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The formats to ask for, best first: AAC in MP4 (what Voice Memos keeps; Safari), else Opus in WebM. */
|
|
64
|
+
const TYPES = ['audio/mp4', 'audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus'];
|
|
65
|
+
|
|
66
|
+
/** A level from an RMS amplitude: -50 dB and below is silence, 0 dB full, as Voice Memos' bars scale. */
|
|
67
|
+
export function levelOf(rms: number): number {
|
|
68
|
+
if (rms <= 0) return 0;
|
|
69
|
+
const db = 20 * Math.log10(rms);
|
|
70
|
+
return Math.max(0, Math.min(1, (db + 50) / 50));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface Session {
|
|
74
|
+
stream: MediaStream;
|
|
75
|
+
recorder: MediaRecorder;
|
|
76
|
+
chunks: Blob[];
|
|
77
|
+
context: AudioContext | null;
|
|
78
|
+
analyser: AnalyserNode | null;
|
|
79
|
+
samples: Float32Array<ArrayBuffer> | null;
|
|
80
|
+
/** Seconds recorded before the current stretch. */
|
|
81
|
+
recorded: number;
|
|
82
|
+
/** When the current stretch began (ms), or null while paused. */
|
|
83
|
+
since: number | null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The file extension a recording of `type` is named with. */
|
|
87
|
+
export function extensionOf(type: string): string {
|
|
88
|
+
const base = (type.split(';')[0] ?? type).trim().toLowerCase();
|
|
89
|
+
switch (base) {
|
|
90
|
+
case 'audio/mp4': case 'audio/x-m4a': case 'audio/aac': return 'm4a';
|
|
91
|
+
case 'audio/webm': return 'webm';
|
|
92
|
+
case 'audio/ogg': return 'ogg';
|
|
93
|
+
case 'audio/wav': case 'audio/wave': case 'audio/x-wav': return 'wav';
|
|
94
|
+
case 'audio/mpeg': return 'mp3';
|
|
95
|
+
default: return 'bin';
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The app's `Documents/Recordings`, made if need be. */
|
|
100
|
+
async function recordingsFolder(files: FilesHost): Promise<string> {
|
|
101
|
+
const folder = fileURL(files.directories.documents, 'Recordings');
|
|
102
|
+
await files.createDirectory(folder);
|
|
103
|
+
return folder;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Writes `bytes` as a new file in `Documents/Recordings`, named by a UUID and the type's extension: its URL. */
|
|
107
|
+
export async function recordingFile(files: FilesHost, bytes: Uint8Array, type: string): Promise<string> {
|
|
108
|
+
const folder = await recordingsFolder(files);
|
|
109
|
+
const name = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' ? crypto.randomUUID().toUpperCase() : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
110
|
+
const url = fileURL(folder, `${name}.${extensionOf(type)}`);
|
|
111
|
+
await files.write(url, bytes);
|
|
112
|
+
return url;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export class BrowserAudioRecorder implements AudioRecorderHost {
|
|
116
|
+
private session: Session | null = null;
|
|
117
|
+
|
|
118
|
+
/** `files` is the app's files host, looked up at each call (the environment fills it in after this is made). */
|
|
119
|
+
constructor(private readonly files: () => FilesHost | undefined = () => undefined) {}
|
|
120
|
+
|
|
121
|
+
/** The recording's URL: a file in `Documents/Recordings` where the page keeps files, else a `data:` URL. */
|
|
122
|
+
private async keep(blob: Blob, type: string): Promise<string> {
|
|
123
|
+
const files = this.files();
|
|
124
|
+
if (files) {
|
|
125
|
+
try {
|
|
126
|
+
return await recordingFile(files, new Uint8Array(await blob.arrayBuffer()), type);
|
|
127
|
+
} catch {
|
|
128
|
+
// The file could not be written (the page's storage refused it): the sound stays with its URL.
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return dataURL(blob);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The bytes of a recording: its file, or what its `data:` URL holds. */
|
|
135
|
+
private async bytesOf(url: string): Promise<ArrayBuffer> {
|
|
136
|
+
const files = this.files();
|
|
137
|
+
if (url.startsWith('file:') && files) {
|
|
138
|
+
const bytes = await files.read(url);
|
|
139
|
+
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
|
140
|
+
}
|
|
141
|
+
return (await fetch(url)).arrayBuffer();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async start(): Promise<void> {
|
|
145
|
+
if (this.session) throw new Error('busy: already recording');
|
|
146
|
+
// The context first, in the tap that asked to record: Safari starts one
|
|
147
|
+
// made after the microphone prompt (outside the gesture) suspended, and
|
|
148
|
+
// its analyser then reads silence, a flat waveform for a loud recording.
|
|
149
|
+
let context: AudioContext | null = null;
|
|
150
|
+
try {
|
|
151
|
+
context = new AudioContext();
|
|
152
|
+
void context.resume();
|
|
153
|
+
} catch {
|
|
154
|
+
// No Web Audio: the recording goes on, its level stays 0.
|
|
155
|
+
}
|
|
156
|
+
let stream: MediaStream;
|
|
157
|
+
try {
|
|
158
|
+
stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: true } });
|
|
159
|
+
} catch (error) {
|
|
160
|
+
void context?.close();
|
|
161
|
+
const name = (error as { name?: string }).name;
|
|
162
|
+
if (name === 'NotAllowedError' || name === 'SecurityError') throw new Error('permission: the microphone was not allowed');
|
|
163
|
+
throw new Error(`unavailable: no microphone (${String(error)})`);
|
|
164
|
+
}
|
|
165
|
+
const mimeType = TYPES.find((type) => MediaRecorder.isTypeSupported(type));
|
|
166
|
+
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
|
|
167
|
+
const session: Session = { stream, recorder, chunks: [], context: null, analyser: null, samples: null, recorded: 0, since: performance.now() };
|
|
168
|
+
recorder.addEventListener('dataavailable', (event) => {
|
|
169
|
+
if (event.data.size > 0) session.chunks.push(event.data);
|
|
170
|
+
});
|
|
171
|
+
if (context) {
|
|
172
|
+
try {
|
|
173
|
+
const analyser = context.createAnalyser();
|
|
174
|
+
analyser.fftSize = 1024;
|
|
175
|
+
context.createMediaStreamSource(stream).connect(analyser);
|
|
176
|
+
session.context = context;
|
|
177
|
+
session.analyser = analyser;
|
|
178
|
+
session.samples = new Float32Array(analyser.fftSize);
|
|
179
|
+
} catch {
|
|
180
|
+
void context.close();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
recorder.start(250);
|
|
184
|
+
this.session = session;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async pause(): Promise<void> {
|
|
188
|
+
const session = this.active();
|
|
189
|
+
if (session.since === null) return;
|
|
190
|
+
session.recorder.pause();
|
|
191
|
+
session.recorded += (performance.now() - session.since) / 1000;
|
|
192
|
+
session.since = null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async resume(): Promise<void> {
|
|
196
|
+
const session = this.active();
|
|
197
|
+
if (session.since !== null) return;
|
|
198
|
+
session.recorder.resume();
|
|
199
|
+
session.since = performance.now();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async stop(): Promise<AudioRecording> {
|
|
203
|
+
const session = this.active();
|
|
204
|
+
const duration = this.time(session);
|
|
205
|
+
const stopped = new Promise<void>((resolve) => session.recorder.addEventListener('stop', () => resolve(), { once: true }));
|
|
206
|
+
session.recorder.stop();
|
|
207
|
+
await stopped;
|
|
208
|
+
this.end(session);
|
|
209
|
+
const type = session.recorder.mimeType || session.chunks[0]?.type || 'audio/webm';
|
|
210
|
+
const blob = new Blob(session.chunks, { type });
|
|
211
|
+
const plain = type.split(';')[0] ?? type;
|
|
212
|
+
return { url: await this.keep(blob, plain), duration, type: plain };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async cancel(): Promise<void> {
|
|
216
|
+
const session = this.session;
|
|
217
|
+
if (!session) return;
|
|
218
|
+
if (session.recorder.state !== 'inactive') session.recorder.stop();
|
|
219
|
+
this.end(session);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async meter(): Promise<AudioMeter> {
|
|
223
|
+
const session = this.active();
|
|
224
|
+
let level = 0;
|
|
225
|
+
if (session.analyser && session.samples && session.since !== null) {
|
|
226
|
+
// A context the browser suspended anyway (a long permission prompt) is asked again.
|
|
227
|
+
if (session.context?.state === 'suspended') void session.context.resume();
|
|
228
|
+
session.analyser.getFloatTimeDomainData(session.samples);
|
|
229
|
+
let sum = 0;
|
|
230
|
+
for (const sample of session.samples) sum += sample * sample;
|
|
231
|
+
level = levelOf(Math.sqrt(sum / session.samples.length));
|
|
232
|
+
}
|
|
233
|
+
return { level, time: this.time(session) };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async discard(url: string): Promise<void> {
|
|
237
|
+
// A `data:` URL's sound goes with the URL; a file in Recordings is deleted (one gone already is nothing to delete).
|
|
238
|
+
const files = this.files();
|
|
239
|
+
if (!url.startsWith('file:') || !files) return;
|
|
240
|
+
await files.remove(url).catch((error: unknown) => {
|
|
241
|
+
if (!(error instanceof Error && error.message.startsWith('notFound:'))) throw error;
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async trim(url: string, keep: readonly AudioRange[]): Promise<AudioRecording> {
|
|
246
|
+
const decoder = audioDecoder();
|
|
247
|
+
if (!decoder) throw new Error('unsupported: this page cannot cut audio');
|
|
248
|
+
let decoded: AudioBuffer;
|
|
249
|
+
try {
|
|
250
|
+
decoded = await decoder.context.decodeAudioData(await this.bytesOf(url));
|
|
251
|
+
} catch (error) {
|
|
252
|
+
throw new Error(`decode: the recording could not be read (${String(error)})`);
|
|
253
|
+
} finally {
|
|
254
|
+
decoder.done();
|
|
255
|
+
}
|
|
256
|
+
const channels: Float32Array[] = [];
|
|
257
|
+
for (let channel = 0; channel < decoded.numberOfChannels; channel += 1) channels.push(decoded.getChannelData(channel));
|
|
258
|
+
const kept = sliceSamples(channels, decoded.sampleRate, keptRanges(keep, decoded.duration));
|
|
259
|
+
const frames = kept[0]?.length ?? 0;
|
|
260
|
+
if (frames === 0) throw new Error('empty: nothing of the recording was kept');
|
|
261
|
+
const wav = encodeWAV(kept, decoded.sampleRate);
|
|
262
|
+
return { url: await this.keep(new Blob([wav], { type: 'audio/wav' }), 'audio/wav'), duration: frames / decoded.sampleRate, type: 'audio/wav' };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private active(): Session {
|
|
266
|
+
if (!this.session) throw new Error('idle: not recording');
|
|
267
|
+
return this.session;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private time(session: Session): number {
|
|
271
|
+
return session.recorded + (session.since === null ? 0 : (performance.now() - session.since) / 1000);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private end(session: Session): void {
|
|
275
|
+
for (const track of session.stream.getTracks()) track.stop();
|
|
276
|
+
void session.context?.close().catch(() => undefined);
|
|
277
|
+
if (this.session === session) this.session = null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The ranges of a `duration`-second recording that `trim` keeps: each clamped
|
|
283
|
+
* to the recording, the empty ones dropped, in time order, and overlapping
|
|
284
|
+
* ones merged, so the kept parts never repeat a sound.
|
|
285
|
+
*/
|
|
286
|
+
export function keptRanges(keep: readonly AudioRange[], duration: number): AudioRange[] {
|
|
287
|
+
const clamped = keep
|
|
288
|
+
.map((range) => ({ start: Math.max(0, Math.min(duration, range.start)), end: Math.max(0, Math.min(duration, range.end)) }))
|
|
289
|
+
.filter((range) => Number.isFinite(range.start) && Number.isFinite(range.end) && range.end > range.start)
|
|
290
|
+
.sort((a, b) => a.start - b.start);
|
|
291
|
+
const merged: AudioRange[] = [];
|
|
292
|
+
for (const range of clamped) {
|
|
293
|
+
const last = merged[merged.length - 1];
|
|
294
|
+
if (last && range.start <= last.end) last.end = Math.max(last.end, range.end);
|
|
295
|
+
else merged.push({ ...range });
|
|
296
|
+
}
|
|
297
|
+
return merged;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The frames of `channels` (one array of samples per channel, at
|
|
302
|
+
* `sampleRate`) that fall in `ranges` (seconds), concatenated in order: one
|
|
303
|
+
* new array per channel, all the same length.
|
|
304
|
+
*/
|
|
305
|
+
export function sliceSamples(channels: readonly Float32Array[], sampleRate: number, ranges: readonly AudioRange[]): Float32Array[] {
|
|
306
|
+
const length = channels[0]?.length ?? 0;
|
|
307
|
+
const frames = ranges.map((range) => {
|
|
308
|
+
const start = Math.min(length, Math.max(0, Math.round(range.start * sampleRate)));
|
|
309
|
+
const end = Math.min(length, Math.max(start, Math.round(range.end * sampleRate)));
|
|
310
|
+
return { start, end };
|
|
311
|
+
});
|
|
312
|
+
const total = frames.reduce((sum, range) => sum + (range.end - range.start), 0);
|
|
313
|
+
return channels.map((samples) => {
|
|
314
|
+
const kept = new Float32Array(total);
|
|
315
|
+
let at = 0;
|
|
316
|
+
for (const range of frames) {
|
|
317
|
+
kept.set(samples.subarray(range.start, range.end), at);
|
|
318
|
+
at += range.end - range.start;
|
|
319
|
+
}
|
|
320
|
+
return kept;
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* `channels` (one array of -1…1 samples per channel, all the same length) as
|
|
326
|
+
* a WAV file: a RIFF header and the samples interleaved as 16-bit PCM, which
|
|
327
|
+
* every media element plays.
|
|
328
|
+
*/
|
|
329
|
+
export function encodeWAV(channels: readonly Float32Array[], sampleRate: number): ArrayBuffer {
|
|
330
|
+
const channelCount = channels.length;
|
|
331
|
+
const frames = channels[0]?.length ?? 0;
|
|
332
|
+
const blockAlign = channelCount * 2;
|
|
333
|
+
const dataSize = frames * blockAlign;
|
|
334
|
+
const buffer = new ArrayBuffer(44 + dataSize);
|
|
335
|
+
const view = new DataView(buffer);
|
|
336
|
+
const ascii = (offset: number, text: string): void => {
|
|
337
|
+
for (let i = 0; i < text.length; i += 1) view.setUint8(offset + i, text.charCodeAt(i));
|
|
338
|
+
};
|
|
339
|
+
ascii(0, 'RIFF');
|
|
340
|
+
view.setUint32(4, 36 + dataSize, true);
|
|
341
|
+
ascii(8, 'WAVE');
|
|
342
|
+
ascii(12, 'fmt ');
|
|
343
|
+
view.setUint32(16, 16, true); // The fmt chunk's size.
|
|
344
|
+
view.setUint16(20, 1, true); // PCM.
|
|
345
|
+
view.setUint16(22, channelCount, true);
|
|
346
|
+
view.setUint32(24, sampleRate, true);
|
|
347
|
+
view.setUint32(28, sampleRate * blockAlign, true);
|
|
348
|
+
view.setUint16(32, blockAlign, true);
|
|
349
|
+
view.setUint16(34, 16, true); // Bits per sample.
|
|
350
|
+
ascii(36, 'data');
|
|
351
|
+
view.setUint32(40, dataSize, true);
|
|
352
|
+
let offset = 44;
|
|
353
|
+
for (let frame = 0; frame < frames; frame += 1) {
|
|
354
|
+
for (const samples of channels) {
|
|
355
|
+
const sample = Math.max(-1, Math.min(1, samples[frame] ?? 0));
|
|
356
|
+
view.setInt16(offset, sample < 0 ? Math.round(sample * 0x8000) : Math.round(sample * 0x7fff), true);
|
|
357
|
+
offset += 2;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return buffer;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* A context to decode with, and how to let it go: an `OfflineAudioContext`
|
|
365
|
+
* where there is one (it touches no hardware and needs no closing), else an
|
|
366
|
+
* `AudioContext`, closed once it has decoded (it holds the hardware, and
|
|
367
|
+
* Safari allows a page few); nothing where the page has no Web Audio.
|
|
368
|
+
*/
|
|
369
|
+
function audioDecoder(): { context: BaseAudioContext; done(): void } | undefined {
|
|
370
|
+
if (typeof OfflineAudioContext !== 'undefined') return { context: new OfflineAudioContext(1, 1, 44_100), done: () => undefined };
|
|
371
|
+
if (typeof AudioContext !== 'undefined') {
|
|
372
|
+
const context = new AudioContext();
|
|
373
|
+
return { context, done: () => void context.close().catch(() => undefined) };
|
|
374
|
+
}
|
|
375
|
+
return undefined;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function dataURL(blob: Blob): Promise<string> {
|
|
379
|
+
return new Promise((resolve, reject) => {
|
|
380
|
+
const reader = new FileReader();
|
|
381
|
+
reader.onload = () => resolve(String(reader.result));
|
|
382
|
+
reader.onerror = () => reject(reader.error ?? new Error('read: the recording could not be read'));
|
|
383
|
+
reader.readAsDataURL(blob);
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** The browser's recorder, where it has a microphone API and `MediaRecorder`; `files` is where recordings go (the environment's files host). */
|
|
388
|
+
export function browserAudioRecorder(files: () => FilesHost | undefined = () => undefined): AudioRecorderHost | undefined {
|
|
389
|
+
if (typeof navigator === 'undefined' || typeof navigator.mediaDevices?.getUserMedia !== 'function') return undefined;
|
|
390
|
+
if (typeof MediaRecorder === 'undefined') return undefined;
|
|
391
|
+
return new BrowserAudioRecorder(files);
|
|
392
|
+
}
|