Skip to content

Commit 4fbdbba

Browse files
committed
fix(transmitFile): fix audio silence with look-ahead oscillator scheduler
1 parent b3d25e9 commit 4fbdbba

8 files changed

Lines changed: 539 additions & 235 deletions

File tree

src/hooks/useReceiver.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
import { useState, useCallback, useRef, useEffect } from 'react';
1212
import { startReceiver } from '../services/fskDecoder';
13-
import type { RxStatus } from '../services/fskDecoder';
13+
import type { RxStatus, FileResult } from '../services/fskDecoder';
1414
import { FFT_SIZE } from '../services/protocol';
1515

1616
export interface ReceiverLog {
@@ -37,8 +37,10 @@ export interface UseReceiverReturn {
3737
isDecoding: boolean;
3838
/** True when decoding finished successfully. */
3939
isComplete: boolean;
40-
/** The decoded text (available when isComplete = true). */
40+
/** The decoded text (available when isComplete = true and fileResult = null). */
4141
decodedText: string;
42+
/** Decoded file result (available when isComplete = true and fileResult != null). */
43+
fileResult: FileResult | null;
4244
/**
4345
* Live FFT magnitude spectrum [0..255] for the visualiser bar chart.
4446
* Updated at RX_POLL_INTERVAL_MS cadence.
@@ -52,16 +54,20 @@ export function useReceiver(): UseReceiverReturn {
5254
const [logs, setLogs] = useState<ReceiverLog[]>([]);
5355
const [progress, setProgress] = useState(0);
5456
const [decodedText, setDecodedText] = useState('');
57+
const [fileResult, setFileResult] = useState<FileResult | null>(null);
5558
const [spectrumData, setSpectrumData] = useState<Uint8Array>(new Uint8Array(FFT_SIZE / 2));
5659

5760
const stopFnRef = useRef<(() => void) | null>(null);
5861
const spectrumTickerRef = useRef<ReturnType<typeof setInterval> | null>(null);
62+
// Track blob URL so we can revoke it on cleanup / new transmission.
63+
const blobUrlRef = useRef<string | null>(null);
5964

6065
// Cleanup on unmount.
6166
useEffect(() => {
6267
return () => {
6368
stopFnRef.current?.();
6469
if (spectrumTickerRef.current) clearInterval(spectrumTickerRef.current);
70+
if (blobUrlRef.current) URL.revokeObjectURL(blobUrlRef.current);
6571
};
6672
}, []);
6773

@@ -94,8 +100,14 @@ export function useReceiver(): UseReceiverReturn {
94100
break;
95101
case 'complete':
96102
setProgress(100);
97-
setDecodedText(s.text);
98-
addLog(`Payload fully reconstructed. Decoded: "${s.text.slice(0, 60)}${s.text.length > 60 ? '...' : ''}"`, 'success');
103+
if (s.fileResult) {
104+
blobUrlRef.current = s.fileResult.url;
105+
setFileResult(s.fileResult);
106+
addLog(`File received: “${s.fileResult.name}” (${(s.fileResult.size / 1024).toFixed(1)} KB, ${s.fileResult.mime}). Ready to download.`, 'success');
107+
} else if (s.text) {
108+
setDecodedText(s.text);
109+
addLog(`Payload fully reconstructed. Decoded: "${s.text.slice(0, 60)}${s.text.length > 60 ? '...' : ''}"`, 'success');
110+
}
99111
break;
100112
case 'error':
101113
addLog(`Error: ${s.message}`, 'error');
@@ -107,6 +119,12 @@ export function useReceiver(): UseReceiverReturn {
107119
setLogs([]);
108120
setProgress(0);
109121
setDecodedText('');
122+
setFileResult(null);
123+
// Revoke any lingering blob URL from a previous transfer.
124+
if (blobUrlRef.current) {
125+
URL.revokeObjectURL(blobUrlRef.current);
126+
blobUrlRef.current = null;
127+
}
110128
setSpectrumData(new Uint8Array(FFT_SIZE / 2));
111129
addLog('Requesting microphone permission from browser...', 'info');
112130

@@ -136,6 +154,7 @@ export function useReceiver(): UseReceiverReturn {
136154
isDecoding,
137155
isComplete,
138156
decodedText,
157+
fileResult,
139158
spectrumData,
140159
};
141160
}

src/hooks/useTransmitter.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import { useState, useCallback, useRef, useEffect } from 'react';
10-
import { transmitText } from '../services/fskEncoder';
10+
import { transmitText, transmitFile } from '../services/fskEncoder';
1111
import type { TxStatus } from '../services/fskEncoder';
1212
import {
1313
MAX_PAYLOAD_BYTES,
@@ -17,6 +17,7 @@ import {
1717
HANDSHAKE_TONE_DURATION_S,
1818
HANDSHAKE_SILENCE_S,
1919
EOT_DURATION_S,
20+
MAX_FILE_BYTES,
2021
} from '../services/protocol';
2122

2223
export interface TransmitterLog {
@@ -27,8 +28,10 @@ export interface TransmitterLog {
2728
}
2829

2930
export interface UseTransmitterReturn {
30-
/** Start transmitting the given text. */
31+
/** Start transmitting the given text (Simple Mode). */
3132
start: (text: string) => void;
33+
/** Start transmitting a binary file (Advanced Mode). */
34+
startFile: (file: File) => void;
3235
/** Abort an in-progress transmission. */
3336
stop: () => void;
3437
/** Current status from the FSK engine. */
@@ -102,6 +105,20 @@ export function useTransmitter(): UseTransmitterReturn {
102105
abortRef.current = stopFn;
103106
}, [handleStatus, addLog]);
104107

108+
const startFile = useCallback((file: File) => {
109+
if (file.size > MAX_FILE_BYTES) {
110+
addLog(`File too large: ${(file.size / 1024).toFixed(1)} KB. Max is ${MAX_FILE_BYTES / 1024} KB.`, 'error');
111+
return;
112+
}
113+
setLogs([]);
114+
setProgress(0);
115+
const totalChunks = Math.ceil(file.size / MAX_PAYLOAD_BYTES);
116+
addLog(`File: “${file.name}” (${(file.size / 1024).toFixed(1)} KB, ${file.type || 'binary'}).`, 'info');
117+
addLog(`Chunking into ${totalChunks} packets of ≤${MAX_PAYLOAD_BYTES} bytes + 1 metadata packet.`, 'info');
118+
const stopFn = transmitFile(file, handleStatus);
119+
abortRef.current = stopFn;
120+
}, [handleStatus, addLog]);
121+
105122
const stop = useCallback(() => {
106123
abortRef.current?.();
107124
abortRef.current = null;
@@ -126,6 +143,7 @@ export function useTransmitter(): UseTransmitterReturn {
126143

127144
return {
128145
start,
146+
startFile,
129147
stop,
130148
status,
131149
logs,

src/pages/Receiver.tsx

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export default function Receiver() {
2222
isDecoding,
2323
isComplete,
2424
decodedText,
25+
fileResult,
2526
} = useReceiver();
2627

2728
const toggleListen = () => {
@@ -46,6 +47,15 @@ export default function Receiver() {
4647
URL.revokeObjectURL(url);
4748
};
4849

50+
const downloadAsFile = () => {
51+
if (!fileResult) return;
52+
const a = document.createElement('a');
53+
a.href = fileResult.url;
54+
// Don't revoke the URL here: the image preview still needs it
55+
a.download = fileResult.name || `acoustic-file-${Date.now()}`;
56+
a.click();
57+
};
58+
4959
// Current microphone permission / status label text
5060
const getStatusLabel = () => {
5161
switch (status.type) {
@@ -118,7 +128,7 @@ export default function Receiver() {
118128
</motion.div>
119129
)}
120130

121-
{/* Success banner with decoded payload */}
131+
{/* Success banner — adapts for text vs binary file */}
122132
{isComplete && (
123133
<motion.div
124134
initial={{ opacity: 0, scale: 0.95 }}
@@ -131,32 +141,52 @@ export default function Receiver() {
131141
<CheckCircle size={24} />
132142
</div>
133143
<div>
134-
<h2 className="text-lg font-bold text-white">Payload Reconstructed</h2>
135-
<p className="text-sm text-textMuted">CRC32 integrity verified. {decodedText.length} characters decoded.</p>
144+
<h2 className="text-lg font-bold text-white">
145+
{fileResult ? 'File Reconstructed' : 'Payload Reconstructed'}
146+
</h2>
147+
<p className="text-sm text-textMuted">
148+
{fileResult
149+
? `CRC32 verified · ${(fileResult.size / 1024).toFixed(1)} KB · ${fileResult.mime}`
150+
: `CRC32 integrity verified. ${decodedText.length} characters decoded.`}
151+
</p>
136152
</div>
137153
</div>
138154
<div className="flex gap-2">
155+
{!fileResult && (
156+
<button
157+
onClick={copyToClipboard}
158+
className="glass-button text-xs py-1.5 px-3 flex items-center gap-1"
159+
>
160+
<Copy size={14} /> Copy
161+
</button>
162+
)}
139163
<button
140-
onClick={copyToClipboard}
141-
className="glass-button text-xs py-1.5 px-3 flex items-center gap-1"
142-
>
143-
<Copy size={14} /> Copy
144-
</button>
145-
<button
146-
onClick={downloadAsText}
164+
onClick={fileResult ? downloadAsFile : downloadAsText}
147165
className="glass-button bg-white text-black hover:bg-white/90 flex items-center gap-2 font-bold text-xs"
148166
>
149167
<Download size={14} /> Download
150168
</button>
151169
</div>
152170
</div>
153-
{/* Decoded text preview */}
154-
<div className="bg-black/40 rounded-xl border border-white/10 p-4 font-mono text-sm text-primary/90 max-h-32 overflow-y-auto">
155-
{decodedText}
156-
</div>
171+
{fileResult ? (
172+
fileResult.mime.startsWith('image/') ? (
173+
<img
174+
src={fileResult.url}
175+
alt={fileResult.name}
176+
className="max-h-64 max-w-full rounded-xl border border-white/10 mx-auto block object-contain"
177+
/>
178+
) : (
179+
<div className="bg-black/40 rounded-xl border border-white/10 p-4 font-mono text-sm text-textMuted text-center">
180+
{fileResult.name} · {(fileResult.size / 1024).toFixed(1)} KB
181+
</div>
182+
)
183+
) : (
184+
<div className="bg-black/40 rounded-xl border border-white/10 p-4 font-mono text-sm text-primary/90 max-h-32 overflow-y-auto">
185+
{decodedText}
186+
</div>
187+
)}
157188
</motion.div>
158189
)}
159-
160190
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
161191
{/* Left Column: Spectrum Visualizer */}
162192
<div className="lg:col-span-2 space-y-6">
@@ -236,9 +266,13 @@ export default function Receiver() {
236266
{isComplete ? (
237267
<>
238268
<FileText size={40} className="text-primary" />
239-
<p className="text-lg font-medium">Payload Ready</p>
269+
<p className="text-lg font-medium">
270+
{fileResult ? 'File Ready' : 'Payload Ready'}
271+
</p>
240272
<p className="text-xs text-textMuted font-mono">
241-
{decodedText.length} chars · text/plain · CRC32 OK
273+
{fileResult
274+
? `${fileResult.name} · ${(fileResult.size / 1024).toFixed(1)} KB · CRC32 OK`
275+
: `${decodedText.length} chars · text/plain · CRC32 OK`}
242276
</p>
243277
</>
244278
) : (

0 commit comments

Comments
 (0)