Skip to content

Commit 1b7207d

Browse files
authored
Merge pull request #13 from melbinjp/copilot/plan-audio-data-transfer-implementation
Fix OOM crash on file send: lazy chunk reads, single transmitter session, correct modem profile
2 parents 35d55f0 + b178ebc commit 1b7207d

3 files changed

Lines changed: 193 additions & 43 deletions

File tree

src/dsp/quiet-modem.ts

Lines changed: 109 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,15 @@ export function primeAudio(): void {
5656
/**
5757
* Profile used for file-data frames (sender → receiver).
5858
*
59-
* 'audible-fsk-robust' uses FSK8 modulation with a v29 convolutional outer FEC,
60-
* centred at 8 kHz with 250 samples/symbol. The long integration window makes
61-
* it resilient to speaker/microphone gain variations across a real air gap.
59+
* 'audible-fsk' uses FSK8 modulation with a v29 convolutional outer FEC,
60+
* centred at 8 kHz with 50 samples/symbol (~250 bytes/sec at typical sample
61+
* rates). This is 5× faster than the former 'audible-fsk-robust' profile
62+
* (250 samples/symbol, ~50 bytes/sec), which caused every 4096-byte frame to
63+
* require ~81 s of audio — far exceeding the 30 s transmission timeout and
64+
* making all transfers fail after the first frame. 'audible-fsk' still uses
65+
* the same FSK8/v29-FEC modem and is reliable for typical desktop/phone use.
6266
*/
63-
export const DATA_MODEM_PROFILE = 'audible-fsk-robust';
67+
export const DATA_MODEM_PROFILE = 'audible-fsk';
6468

6569
/**
6670
* Profile used for ACK frames (receiver → sender).
@@ -185,8 +189,15 @@ doInit().catch((err) => {
185189
initPromise = null;
186190
});
187191

188-
/** Maximum time (ms) to wait for a single frame transmission to finish. */
189-
const SEND_TIMEOUT_MS = 30_000;
192+
/** Maximum time (ms) to wait for a single frame transmission to finish.
193+
*
194+
* With the 'audible-fsk' profile (50 samples/symbol, FSK8) the effective
195+
* application-layer throughput is approximately 250 bytes/sec. A 4096-byte
196+
* application frame therefore takes roughly 4096 / 250 ≈ 16 s to play out at
197+
* typical sample rates. 60 s provides a 3.7× safety margin to accommodate
198+
* slower devices and non-standard sample rates (e.g. 44.1 kHz).
199+
*/
200+
const SEND_TIMEOUT_MS = 60_000;
190201

191202
/**
192203
* Transmit an ArrayBuffer as audio.
@@ -232,6 +243,98 @@ export async function sendData(data: ArrayBuffer, profile = DATA_MODEM_PROFILE):
232243
});
233244
}
234245

246+
/**
247+
* A reusable transmitter session that creates a single `Quiet.transmitter`
248+
* (and therefore a single `ScriptProcessorNode`) for an entire file transfer,
249+
* rather than creating and tearing down one per frame.
250+
*
251+
* Each `send()` call updates a mutable callback reference that the shared
252+
* `onFinish` handler delegates to, so the quiet.js transmitter can be reused
253+
* across multiple sequential `transmit()` invocations without being destroyed
254+
* and recreated between frames.
255+
*
256+
* Usage:
257+
* const session = new TransmitterSession();
258+
* await session.init();
259+
* await session.send(frame1);
260+
* await session.send(frame2);
261+
* session.destroy();
262+
*/
263+
export class TransmitterSession {
264+
private transmitter: { transmit: (data: ArrayBuffer) => void; destroy: () => void } | null = null;
265+
/** Resolves the Promise returned by the current in-flight `send()` call. */
266+
private onFinishRef: (() => void) | null = null;
267+
private isDestroyed = false;
268+
269+
constructor(private readonly profile: string = DATA_MODEM_PROFILE) {}
270+
271+
/**
272+
* Initialises Quiet.js and creates the underlying transmitter.
273+
* Must be called once before any `send()` calls.
274+
*/
275+
async init(): Promise<void> {
276+
await initQuiet();
277+
this.transmitter = Quiet.transmitter({
278+
profile: this.profile,
279+
onFinish: () => {
280+
console.log('Transmission finished.');
281+
// Delegate to whichever frame's resolve callback is currently set.
282+
this.onFinishRef?.();
283+
},
284+
clampFrame: false,
285+
});
286+
}
287+
288+
/**
289+
* Transmits `data` as audio and resolves when playback is complete.
290+
* Calls must be awaited sequentially — do not overlap concurrent sends.
291+
*/
292+
send(data: ArrayBuffer): Promise<void> {
293+
return new Promise((resolve, reject) => {
294+
const transmitter = this.transmitter;
295+
if (this.isDestroyed || !transmitter) {
296+
reject(new Error('TransmitterSession has been destroyed'));
297+
return;
298+
}
299+
300+
const timeout = setTimeout(() => {
301+
this.onFinishRef = null;
302+
reject(new Error(
303+
'Transmission timed out: the browser AudioContext may be suspended. ' +
304+
'Ensure primeAudio() was called synchronously in the click handler ' +
305+
'before any async operations.',
306+
));
307+
}, SEND_TIMEOUT_MS);
308+
309+
this.onFinishRef = () => {
310+
clearTimeout(timeout);
311+
this.onFinishRef = null;
312+
resolve();
313+
};
314+
315+
try {
316+
transmitter.transmit(data);
317+
} catch (err) {
318+
clearTimeout(timeout);
319+
this.onFinishRef = null;
320+
reject(err);
321+
}
322+
});
323+
}
324+
325+
/**
326+
* Immediately stops audio playback and releases the `ScriptProcessorNode`.
327+
* Safe to call multiple times.
328+
*/
329+
destroy(): void {
330+
if (!this.isDestroyed) {
331+
this.isDestroyed = true;
332+
this.transmitter?.destroy();
333+
this.transmitter = null;
334+
}
335+
}
336+
}
337+
235338
/**
236339
* Start listening for audio data via the microphone.
237340
* Returns an AnalyserNode for spectrogram visualization and a `stop` function

src/transport/framing.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import CRC32 from 'crc-32';
1111
* balances memory efficiency with frame-count reduction (~64× fewer frames
1212
* compared to the previous 64-byte size).
1313
*/
14-
const PAYLOAD_SIZE = 4096;
14+
export const PAYLOAD_SIZE = 4096;
1515

1616
/**
1717
* Defines the different types of frames used in the protocol.
@@ -156,6 +156,34 @@ export function createFileDataFrame(
156156
return createFrame(header, payload);
157157
}
158158

159+
/**
160+
* Creates a single `file-data` frame from an already-sliced payload chunk.
161+
* Use this instead of {@link createFileDataFrame} when the caller reads each
162+
* chunk lazily (e.g. via `File.slice().arrayBuffer()`) to avoid holding the
163+
* entire file in memory simultaneously.
164+
*
165+
* @param payload The raw bytes for this chunk (must be exactly the slice for frameIndex).
166+
* @param fileId The ID for this transfer session.
167+
* @param frameIndex The zero-based index of the frame.
168+
* @param totalFrames The total number of data frames for this file.
169+
* @returns An ArrayBuffer representing the single `file-data` frame.
170+
*/
171+
export function createFileDataFrameFromPayload(
172+
payload: ArrayBuffer,
173+
fileId: string,
174+
frameIndex: number,
175+
totalFrames: number,
176+
): ArrayBuffer {
177+
const header: FrameHeader = {
178+
type: 'file-data',
179+
fileId,
180+
frameIndex,
181+
totalFrames,
182+
crc32: CRC32.buf(new Uint8Array(payload)),
183+
};
184+
return createFrame(header, payload);
185+
}
186+
159187
/**
160188
* Returns the total number of `file-data` frames required for a given buffer.
161189
* @param fileBuffer The file content as an ArrayBuffer.

src/ui/sender-sm.ts

Lines changed: 55 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { sendData } from '../dsp/quiet-modem';
2-
import { createFileDataFrame, createFileStartFrame, getTotalFrames } from '../transport/framing';
1+
import { TransmitterSession } from '../dsp/quiet-modem';
2+
import { PAYLOAD_SIZE, createFileDataFrameFromPayload, createFileStartFrame } from '../transport/framing';
33

44
/**
55
* Defines the possible states of the sender state machine.
@@ -9,12 +9,23 @@ export type SenderState = 'idle' | 'sending' | 'complete' | 'error';
99
/**
1010
* Sends a file as audio frames sequentially without requiring ACKs.
1111
*
12-
* Removing the bidirectional ACK protocol means the sender no longer needs
13-
* to run a microphone receiver (ScriptProcessorNode) concurrently with the
14-
* transmitter (another ScriptProcessorNode). Having two ScriptProcessorNodes
15-
* simultaneously was the primary cause of the main-thread freeze and the
16-
* out-of-memory crash: quiet.js runs Emscripten DSP synchronously inside each
17-
* node's onaudioprocess callback, and two concurrent nodes starved the browser.
12+
* Key design decisions that prevent out-of-memory crashes and transmission
13+
* timeouts:
14+
*
15+
* 1. **Lazy chunk reading** — `File.arrayBuffer()` is never called on the
16+
* entire file. Instead, each 4 KB chunk is read with `File.slice().arrayBuffer()`
17+
* immediately before it is transmitted and discarded afterwards. Peak RAM
18+
* usage is therefore O(1 frame) rather than O(file size).
19+
*
20+
* 2. **Single `TransmitterSession`** — one `Quiet.transmitter` (and one
21+
* `ScriptProcessorNode`) is created for the whole transfer and reused across
22+
* all frames. Creating/destroying a node per frame was causing unnecessary
23+
* DSP churn on the main thread.
24+
*
25+
* 3. **No concurrent ACK receiver** — removing the bidirectional ACK protocol
26+
* means only one `ScriptProcessorNode` is active at any time. Running two
27+
* concurrent nodes (transmitter + ACK receiver) previously starved the
28+
* browser's audio thread.
1829
*/
1930
export class SenderSM {
2031
private state: SenderState = 'idle';
@@ -38,9 +49,9 @@ export class SenderSM {
3849
public start() {
3950
this.fileId = crypto.randomUUID();
4051
this.setState('sending', 'Preparing to send...');
41-
this.file.arrayBuffer().then(fileBuffer => this.sendAll(fileBuffer)).catch(err => {
52+
this.sendAll().catch(err => {
4253
const msg = err instanceof Error ? err.message : String(err);
43-
this.setState('error', `Failed to read file: ${msg}`);
54+
this.setState('error', `Transmission error: ${msg}`);
4455
});
4556
}
4657

@@ -51,41 +62,49 @@ export class SenderSM {
5162
}
5263
}
5364

54-
private async sendAll(fileBuffer: ArrayBuffer) {
55-
const totalFrames = getTotalFrames(fileBuffer);
65+
private async sendAll() {
66+
// Compute the total frame count from file metadata — no need to read
67+
// the file contents up front, which would cause an OOM for large files.
68+
const totalFrames = Math.ceil(this.file.size / PAYLOAD_SIZE);
5669
this.onProgress(0, totalFrames);
5770

58-
// Transmit the handshake frame first so the receiver can prepare its
59-
// reassembly buffer before any data frames arrive.
60-
const startFrame = createFileStartFrame(this.file, this.fileId);
61-
this.setState('sending', 'Sending handshake frame...');
71+
const session = new TransmitterSession();
6272
try {
63-
await sendData(startFrame);
73+
await session.init();
6474
} catch (err) {
6575
const msg = err instanceof Error ? err.message : String(err);
66-
this.setState('error', `Transmission error: ${msg}`);
76+
this.setState('error', `Failed to initialize transmitter: ${msg}`);
6777
return;
6878
}
6979

70-
// Send every data frame in order. Frames are created one at a time so
71-
// that only the current frame is held in memory alongside fileBuffer,
72-
// rather than pre-allocating the full set (which would double peak RAM
73-
// usage and cause out-of-memory crashes for large files).
74-
// await ensures each frame's audio has fully played out before the next
75-
// one begins, preventing the quiet.js transmit queue from growing unboundedly.
76-
for (let i = 0; i < totalFrames; i++) {
77-
this.setState('sending', `Sending frame ${i + 1}/${totalFrames}...`);
78-
const frame = createFileDataFrame(fileBuffer, this.fileId, i, totalFrames);
79-
try {
80-
await sendData(frame);
81-
} catch (err) {
82-
const msg = err instanceof Error ? err.message : String(err);
83-
this.setState('error', `Transmission error: ${msg}`);
84-
return;
80+
try {
81+
// Transmit the handshake frame first so the receiver can prepare its
82+
// reassembly buffer before any data frames arrive.
83+
const startFrame = createFileStartFrame(this.file, this.fileId);
84+
this.setState('sending', 'Sending handshake frame...');
85+
await session.send(startFrame);
86+
87+
// Send every data frame in order. Each chunk is read lazily with
88+
// File.slice() so that only ~4 KB is held in memory at a time,
89+
// regardless of the total file size. Awaiting session.send() ensures
90+
// each frame's audio has fully played out before the next one begins,
91+
// preventing the quiet.js transmit queue from growing unboundedly.
92+
for (let i = 0; i < totalFrames; i++) {
93+
this.setState('sending', `Sending frame ${i + 1}/${totalFrames}...`);
94+
const start = i * PAYLOAD_SIZE;
95+
const end = start + PAYLOAD_SIZE;
96+
const chunkBuffer = await this.file.slice(start, end).arrayBuffer();
97+
const frame = createFileDataFrameFromPayload(chunkBuffer, this.fileId, i, totalFrames);
98+
await session.send(frame);
99+
this.onProgress(i + 1, totalFrames);
85100
}
86-
this.onProgress(i + 1, totalFrames);
87-
}
88101

89-
this.setState('complete', 'File sent successfully.');
102+
this.setState('complete', 'File sent successfully.');
103+
} catch (err) {
104+
const msg = err instanceof Error ? err.message : String(err);
105+
this.setState('error', `Transmission error: ${msg}`);
106+
} finally {
107+
session.destroy();
108+
}
90109
}
91110
}

0 commit comments

Comments
 (0)