@swiftbrowser/web 0.17.8 → 0.18.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swiftbrowser/web",
3
- "version": "0.17.8",
3
+ "version": "0.18.1",
4
4
  "type": "module",
5
5
  "description": "SwiftBrowser web renderer and dev server: applies render ops from a Swift/Wasm app to the DOM inside an iPhone frame.",
6
6
  "repository": {
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) this.commit(pass, animation, frames);
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[] = []): void {
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
- const s = clone.style;
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
- clone.style.transformOrigin = `${from.left - parseFloat(clone.style.left || '0')}px ${from.top - parseFloat(clone.style.top || '0')}px`;
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
- const s = clone.style;
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
- interface EntryRow {
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
- function request<T>(req: IDBRequest<T>): Promise<T> {
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
+ }
@@ -5,6 +5,8 @@
5
5
  // TelemetryDeck signals): the app keeps working, those requests do not go out.
6
6
 
7
7
  /** Whether `url` is on one of `hosts` (or a subdomain of one). */
8
+ import type { FetchLike } from './hostRequests';
9
+
8
10
  export function isBlocked(url: string, hosts: readonly string[]): boolean {
9
11
  let host: string;
10
12
  try {
@@ -19,7 +21,7 @@ export function isBlocked(url: string, hosts: readonly string[]): boolean {
19
21
  }
20
22
 
21
23
  /** `fetchImpl`, failing requests to `hosts` as a network error does. */
22
- export function withBlockedHosts(fetchImpl: typeof fetch, hosts: readonly string[]): typeof fetch {
24
+ export function withBlockedHosts(fetchImpl: FetchLike, hosts: readonly string[]): FetchLike {
23
25
  if (hosts.length === 0) return fetchImpl;
24
26
  return async (input, init) => {
25
27
  const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
package/src/corsProxy.ts CHANGED
@@ -8,6 +8,8 @@
8
8
  // request that may change something (POST, PUT…) is never sent twice.
9
9
 
10
10
  /** Whether `url` (absolute) is http(s) on another origin than `pageOrigin`. */
11
+ import type { FetchLike } from './hostRequests';
12
+
11
13
  export function isCrossOrigin(url: string, pageOrigin: string): boolean {
12
14
  try {
13
15
  const target = new URL(url);
@@ -30,7 +32,7 @@ export function proxiedURL(proxy: string, target: string): string {
30
32
  * to a page); an answer with any status is the server's and is kept. An
31
33
  * aborted request is not retried.
32
34
  */
33
- export function withCORSProxy(fetchImpl: typeof fetch, proxy: string, pageOrigin: string): typeof fetch {
35
+ export function withCORSProxy(fetchImpl: FetchLike, proxy: string, pageOrigin: string): FetchLike {
34
36
  return async (input, init) => {
35
37
  try {
36
38
  return await fetchImpl(input, init);