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 */
1930export 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