diff --git a/components/video-page-content.tsx b/components/video-page-content.tsx
index 75a943b..11a72d8 100644
--- a/components/video-page-content.tsx
+++ b/components/video-page-content.tsx
@@ -104,8 +104,10 @@ export function VideoPageContent({
voiceProgress,
voiceCurrentTime,
voicePlaybackRate,
+ downloadingVoiceIds,
playVoice,
toggleVoiceSpeed,
+ downloadVoice,
} = useCommentMedia();
const [showResolved, setShowResolved] = useState(false);
const [activeSidePane, setActiveSidePane] = useState<'comments' | 'assets'>('comments');
@@ -926,6 +928,9 @@ export function VideoPageContent({
handleEditComment={commentsActions.onEditComment}
handleDeleteComment={commentsActions.onDeleteComment}
playVoice={playVoice}
+ downloadVoice={downloadVoice}
+ downloadingVoiceIds={downloadingVoiceIds}
+ canDownloadVoiceNotes={canDownloadAssets}
playingVoiceId={playingVoiceId}
voiceProgress={voiceProgress}
voiceCurrentTime={voiceCurrentTime}
diff --git a/components/video-page/asset-list-section.tsx b/components/video-page/asset-list-section.tsx
index 1994fc5..2b33424 100644
--- a/components/video-page/asset-list-section.tsx
+++ b/components/video-page/asset-list-section.tsx
@@ -11,7 +11,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
-import type { VideoAsset } from '@/components/video-page/types';
+import type { AssetDownloadPreference, VideoAsset } from '@/components/video-page/types';
interface AssetListSectionProps {
assets: VideoAsset[];
@@ -25,12 +25,88 @@ interface AssetListSectionProps {
hasMoreAssets: boolean;
isLoadingMoreAssets: boolean;
onViewAsset: (asset: VideoAsset) => void;
- onDownloadAsset: (asset: VideoAsset, preference?: 'original' | 'compressed') => void;
+ onDownloadAsset: (asset: VideoAsset, preference?: AssetDownloadPreference) => void;
onDeleteAsset: (assetId: string) => void;
onLoadMoreAssets: () => void;
renderAssetPreview: (asset: VideoAsset) => ReactNode;
}
+/**
+ * Three shapes of download. A Bunny video offers the original or the compressed
+ * rendition; a voice note offers WAV (converted in the browser, because no
+ * editing suite opens the WebM/Opus we store) or the file as recorded;
+ * everything else is a single button.
+ */
+function AssetDownloadControl({
+ asset,
+ isBusy,
+ onDownloadAsset,
+}: {
+ asset: VideoAsset;
+ isBusy: boolean;
+ onDownloadAsset: (asset: VideoAsset, preference?: AssetDownloadPreference) => void;
+}) {
+ const options: { preference: AssetDownloadPreference; label: string; hint?: string }[] =
+ asset.provider === 'BUNNY' && asset.kind !== 'AUDIO'
+ ? [
+ { preference: 'original', label: 'Original' },
+ { preference: 'compressed', label: 'Compressed' },
+ ]
+ : asset.provider === 'R2_AUDIO'
+ ? [
+ { preference: 'wav', label: 'WAV', hint: 'for editing software' },
+ { preference: 'original', label: 'Original' },
+ ]
+ : [];
+
+ if (options.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {options.map((option) => (
+ onDownloadAsset(asset, option.preference)}
+ >
+
+ {option.label}
+ {option.hint && (
+ {option.hint}
+ )}
+
+ ))}
+
+
+ );
+}
+
export const AssetListSection = memo(function AssetListSection({
assets,
isLoadingAssets,
@@ -130,54 +206,13 @@ export const AssetListSection = memo(function AssetListSection({
)}
- {canDownloadAssets &&
- asset.provider !== 'YOUTUBE' &&
- (asset.provider === 'BUNNY' && asset.kind !== 'AUDIO' ? (
-
-
-
-
-
- onDownloadAsset(asset, 'original')}>
-
- Original
-
- onDownloadAsset(asset, 'compressed')}>
-
- Compressed
-
-
-
- ) : (
-
- ))}
+ {canDownloadAssets && asset.provider !== 'YOUTUBE' && (
+
+ )}
{asset.canDelete && (
)}
+ {canDownloadVoiceNotes && (
+
+ )}
)}
@@ -904,6 +947,29 @@ export const CommentsPane = memo(function CommentsPane({
{voicePlaybackRate}x
)}
+ {canDownloadVoiceNotes && (
+
+ )}
)}
diff --git a/components/video-page/hooks/use-comment-media.ts b/components/video-page/hooks/use-comment-media.ts
index 01f179a..65e0405 100644
--- a/components/video-page/hooks/use-comment-media.ts
+++ b/components/video-page/hooks/use-comment-media.ts
@@ -1,12 +1,19 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
+import { toast } from 'sonner';
+import { downloadAudioAsWav } from '@/lib/client/download-file';
export function useCommentMedia() {
const [playingVoiceId, setPlayingVoiceId] = useState(null);
const [voiceProgress, setVoiceProgress] = useState(0);
const [voiceCurrentTime, setVoiceCurrentTime] = useState(0);
const [voicePlaybackRate, setVoicePlaybackRate] = useState(1);
+ // A set, not a single id: two downloads can be in flight at once, and one
+ // finishing must not clear the other's spinner and re-enable its button.
+ const [downloadingVoiceIds, setDownloadingVoiceIds] = useState>(
+ () => new Set()
+ );
const audioPlayerRef = useRef(null);
const voiceRafRef = useRef(null);
@@ -121,13 +128,41 @@ export function useCommentMedia() {
};
}, [stopVoiceTracking]);
+ /**
+ * Voice notes are stored the way MediaRecorder wrote them, and an editor
+ * cannot import WebM/Opus. Hand over a WAV instead, converted in the browser
+ * from the file it already knows how to decode.
+ */
+ const downloadVoice = useCallback(
+ async (commentId: string, voiceUrl: string, baseName: string) => {
+ setDownloadingVoiceIds((prev) => new Set(prev).add(commentId));
+ try {
+ const result = await downloadAudioAsWav(voiceUrl, baseName);
+ if (result === 'failed') {
+ toast.error('Failed to download voice note');
+ } else if (result === 'conversion-unsupported') {
+ toast.warning('This browser cannot convert the recording. Downloaded the original.');
+ }
+ } finally {
+ setDownloadingVoiceIds((prev) => {
+ const next = new Set(prev);
+ next.delete(commentId);
+ return next;
+ });
+ }
+ },
+ []
+ );
+
return {
playingVoiceId,
voiceProgress,
voiceCurrentTime,
voicePlaybackRate,
+ downloadingVoiceIds,
playVoice,
stopVoice,
toggleVoiceSpeed,
+ downloadVoice,
};
}
diff --git a/components/video-page/hooks/use-download-actions.ts b/components/video-page/hooks/use-download-actions.ts
index caf2fb8..3094b57 100644
--- a/components/video-page/hooks/use-download-actions.ts
+++ b/components/video-page/hooks/use-download-actions.ts
@@ -17,6 +17,7 @@ import {
downloadProgressPercent,
extensionFromUrl,
navigateDownload,
+ sanitizeDownloadFileName,
} from '@/lib/client/download-file';
import {
createDownloadProgressToast,
@@ -24,13 +25,6 @@ import {
} from '@/components/download-progress-toast';
import { beginUnloadGuard } from '@/lib/client/unload-guard';
-function sanitizeDownloadFileName(value: string): string {
- return value
- .replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-')
- .replace(/\s+/g, ' ')
- .trim();
-}
-
function getAllowedHosts() {
const bunnyCdnHostname = resolvePublicBunnyCdnHostname();
return [
diff --git a/components/video-page/hooks/use-video-assets.ts b/components/video-page/hooks/use-video-assets.ts
index f5d4317..1da9c8d 100644
--- a/components/video-page/hooks/use-video-assets.ts
+++ b/components/video-page/hooks/use-video-assets.ts
@@ -2,10 +2,9 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
-import type { VideoAsset } from '@/components/video-page/types';
+import type { AssetDownloadPreference, VideoAsset } from '@/components/video-page/types';
import { apiRequestError, toastApiError } from '@/lib/client/api-error';
-
-type BunnyDownloadPreference = 'original' | 'compressed';
+import { downloadAudioAsWav } from '@/lib/client/download-file';
type CreateAssetPayload = {
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY' | 'R2_AUDIO' | 'R2_VIDEO';
@@ -234,7 +233,7 @@ export function useVideoAssets({
);
const downloadAsset = useCallback(
- async (asset: VideoAsset, preference: BunnyDownloadPreference = 'compressed') => {
+ async (asset: VideoAsset, preference: AssetDownloadPreference = 'compressed') => {
if (!canDownloadAssets) {
toast.error('Asset downloads require an authenticated account');
return;
@@ -248,6 +247,20 @@ export function useVideoAssets({
try {
let downloadUrl = `/api/videos/${videoId}/assets/${asset.id}/download`;
+ // A voice note is stored as MediaRecorder wrote it, and no editing suite
+ // reads WebM/Opus. Convert it in the browser so the download opens in the
+ // timeline it was recorded for; 'original' is there for anyone who wants
+ // the stored bytes instead.
+ if (asset.provider === 'R2_AUDIO' && preference !== 'original') {
+ const result = await downloadAudioAsWav(downloadUrl, asset.displayName);
+ if (result === 'failed') {
+ toast.error('Failed to download voice note');
+ } else if (result === 'conversion-unsupported') {
+ toast.warning('This browser cannot convert the recording. Downloaded the original.');
+ }
+ return;
+ }
+
if (asset.provider === 'BUNNY') {
const prepareRes = await fetch(`${downloadUrl}?source=${preference}&prepare=1`, {
cache: 'no-store',
diff --git a/components/video-page/types.ts b/components/video-page/types.ts
index fc0552d..22e1e69 100644
--- a/components/video-page/types.ts
+++ b/components/video-page/types.ts
@@ -170,6 +170,8 @@ export interface BunnyQualityOption {
export type BunnyPlaybackState = 'none' | 'processing' | 'error';
export type BunnyDownloadPreference = 'original' | 'compressed';
export type DownloadTarget = BunnyDownloadPreference | 'direct';
+/** Voice notes add one more option: converted to WAV in the browser on the way out. */
+export type AssetDownloadPreference = BunnyDownloadPreference | 'wav';
export interface CommentMarker {
id: string;
diff --git a/lib/audio-to-wav.ts b/lib/audio-to-wav.ts
new file mode 100644
index 0000000..f398a81
--- /dev/null
+++ b/lib/audio-to-wav.ts
@@ -0,0 +1,121 @@
+/**
+ * MediaRecorder hands us WebM/Opus (MP4/AAC on Safari). Browsers and desktop
+ * players read both; editing suites read neither. DaVinci Resolve, Premiere and
+ * Final Cut all refuse the container outright, so a voice note downloaded
+ * byte-for-byte is useless to the editor it was recorded for.
+ *
+ * The browser already decodes these formats in order to play them, so the whole
+ * conversion costs us is a RIFF header: decode to PCM through the Web Audio API,
+ * then write the samples back out as a WAV. Nothing is transcoded server side
+ * and the stored object stays the small Opus file, which is why this runs at
+ * download time rather than at record time.
+ */
+
+const WAV_HEADER_BYTES = 44;
+const BYTES_PER_SAMPLE = 2; // 16-bit PCM
+const PCM_FORMAT_TAG = 1;
+
+/**
+ * Decoding holds the float PCM and the encoded copy in memory at once, roughly
+ * three times the size of the WAV. A 10MB Opus upload is over half an hour of
+ * speech, so cap the output rather than let a long recording take the tab down.
+ */
+export const MAX_WAV_OUTPUT_BYTES = 400 * 1024 * 1024;
+
+function writeAscii(view: DataView, offset: number, text: string): void {
+ for (let i = 0; i < text.length; i++) view.setUint8(offset + i, text.charCodeAt(i));
+}
+
+export function wavByteLength(frameCount: number, channelCount: number): number {
+ return WAV_HEADER_BYTES + frameCount * channelCount * BYTES_PER_SAMPLE;
+}
+
+/**
+ * Interleaved 16-bit PCM in a RIFF container: the one audio format every NLE on
+ * the market imports without an argument. `channels` holds one Float32Array of
+ * samples per channel, all the same length.
+ */
+export function encodeWav(channels: Float32Array[], sampleRate: number): Blob {
+ const channelCount = channels.length;
+ if (channelCount === 0 || !Number.isFinite(sampleRate) || sampleRate <= 0) {
+ throw new Error('encodeWav needs at least one channel and a positive sample rate');
+ }
+
+ const frameCount = channels[0].length;
+ const blockAlign = channelCount * BYTES_PER_SAMPLE;
+ const dataBytes = frameCount * blockAlign;
+ const buffer = new ArrayBuffer(WAV_HEADER_BYTES + dataBytes);
+ const view = new DataView(buffer);
+
+ writeAscii(view, 0, 'RIFF');
+ // Everything after this field, i.e. the file minus the 8-byte RIFF preamble.
+ view.setUint32(4, 36 + dataBytes, true);
+ writeAscii(view, 8, 'WAVE');
+ writeAscii(view, 12, 'fmt ');
+ view.setUint32(16, 16, true); // fmt chunk payload size for PCM
+ view.setUint16(20, PCM_FORMAT_TAG, true);
+ view.setUint16(22, channelCount, true);
+ view.setUint32(24, sampleRate, true);
+ view.setUint32(28, sampleRate * blockAlign, true); // byte rate
+ view.setUint16(32, blockAlign, true);
+ view.setUint16(34, BYTES_PER_SAMPLE * 8, true);
+ writeAscii(view, 36, 'data');
+ view.setUint32(40, dataBytes, true);
+
+ let offset = WAV_HEADER_BYTES;
+ for (let frame = 0; frame < frameCount; frame++) {
+ for (let channel = 0; channel < channelCount; channel++) {
+ // Decoded samples can overshoot ±1. Scaled unclamped they wrap to the
+ // opposite rail, and a loud passage comes out as a burst of noise.
+ const sample = Math.max(-1, Math.min(1, channels[channel][frame] ?? 0));
+ // The negative rail reaches one step further than the positive one, so the
+ // two directions take different scale factors to stay symmetric.
+ view.setInt16(offset, Math.round(sample < 0 ? sample * 0x8000 : sample * 0x7fff), true);
+ offset += BYTES_PER_SAMPLE;
+ }
+ }
+
+ return new Blob([buffer], { type: 'audio/wav' });
+}
+
+type OfflineAudioContextConstructor = new (
+ channels: number,
+ length: number,
+ sampleRate: number
+) => OfflineAudioContext;
+
+function getOfflineAudioContext(): OfflineAudioContextConstructor | null {
+ if (typeof window === 'undefined') return null;
+ const scope = window as unknown as {
+ OfflineAudioContext?: OfflineAudioContextConstructor;
+ webkitOfflineAudioContext?: OfflineAudioContextConstructor;
+ };
+ return scope.OfflineAudioContext ?? scope.webkitOfflineAudioContext ?? null;
+}
+
+/**
+ * Decodes any audio blob the browser can play and returns it as a WAV, or null
+ * when this browser cannot decode that format (older Safari has no WebM/Opus
+ * decoder) or the result would be too large to hold. Callers fall back to the
+ * original file, so a failure costs the download nothing but the extension.
+ */
+export async function convertAudioBlobToWav(blob: Blob): Promise {
+ const OfflineCtx = getOfflineAudioContext();
+ if (!OfflineCtx) return null;
+
+ try {
+ // decodeAudioData resamples to the context's rate, and 48 kHz is what both
+ // Opus and AAC recordings already run at, so this decodes them untouched.
+ // We read the rate back off the result anyway in case a browser ignores it.
+ const context = new OfflineCtx(1, 1, 48000);
+ const decoded = await context.decodeAudioData(await blob.arrayBuffer());
+ if (!decoded || decoded.length === 0) return null;
+ if (wavByteLength(decoded.length, decoded.numberOfChannels) > MAX_WAV_OUTPUT_BYTES) return null;
+
+ const channels: Float32Array[] = [];
+ for (let i = 0; i < decoded.numberOfChannels; i++) channels.push(decoded.getChannelData(i));
+ return encodeWav(channels, decoded.sampleRate);
+ } catch {
+ return null;
+ }
+}
diff --git a/lib/client/download-file.ts b/lib/client/download-file.ts
index bd0c190..f930bcc 100644
--- a/lib/client/download-file.ts
+++ b/lib/client/download-file.ts
@@ -1,5 +1,7 @@
'use client';
+import { convertAudioBlobToWav } from '@/lib/audio-to-wav';
+
// Above this size we don't buffer the file in memory to rename it — the caller
// falls back to a plain navigation so the browser streams it straight to disk
// (with the CDN's own filename). 10 GiB.
@@ -27,12 +29,20 @@ export function extensionFromUrl(url: string): string {
return ext.length >= 1 && ext.length <= 5 ? ext : '';
}
-function replaceExtension(fileName: string, ext: string): string {
+export function replaceExtension(fileName: string, ext: string): string {
const dot = fileName.lastIndexOf('.');
const stem = dot > 0 ? fileName.slice(0, dot) : fileName;
return `${stem}.${ext}`;
}
+/** Strips the characters Windows and macOS reject in a file name. */
+export function sanitizeDownloadFileName(value: string): string {
+ return value
+ .replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-')
+ .replace(/\s+/g, ' ')
+ .trim();
+}
+
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 MB';
const mb = bytes / (1024 * 1024);
@@ -146,6 +156,88 @@ export async function downloadNamedFile(
return true;
}
+const AUDIO_MIME_EXTENSION_MAP: Record = {
+ 'audio/webm': 'webm',
+ 'audio/ogg': 'ogg',
+ 'audio/opus': 'opus',
+ 'audio/mp4': 'm4a',
+ 'audio/mpeg': 'mp3',
+ 'audio/wav': 'wav',
+};
+
+const AUDIO_EXTENSIONS = new Set(Object.values(AUDIO_MIME_EXTENSION_MAP).concat('mp4', 'oga'));
+
+/** Display names are often the recorded file name, extension and all, and
+ * `recording.webm.wav` helps nobody. */
+function stripAudioExtension(name: string): string {
+ const ext = extensionFromUrl(name);
+ return ext && AUDIO_EXTENSIONS.has(ext) ? name.slice(0, -(ext.length + 1)) : name;
+}
+
+/**
+ * Containers an editing suite already opens. Audio assets are not only voice
+ * recordings, anyone can upload an audio file, and decoding one of these back out
+ * through the Web Audio API would resample it to 48 kHz and requantise it to 16
+ * bit for no gain. A file in this set is handed over exactly as stored.
+ */
+const EDITOR_READY_MIME_TYPES = new Set(['audio/wav', 'audio/mpeg', 'audio/mp4']);
+
+export type AudioDownloadResult =
+ | 'wav'
+ | 'no-conversion-needed'
+ | 'conversion-unsupported'
+ | 'failed';
+
+/**
+ * Voice notes are stored exactly as MediaRecorder produced them: WebM/Opus,
+ * which browsers play and no editing suite imports. Convert on the way out so
+ * the file lands in the timeline it was recorded for.
+ *
+ * Saves the stored file untouched when it is already in an editable container,
+ * or when this browser cannot decode it, so the download always works. The
+ * return value says which of the three happened, or 'failed' when the file
+ * could not be fetched at all.
+ */
+export async function downloadAudioAsWav(
+ url: string,
+ baseName: string
+): Promise {
+ let res: Response;
+ try {
+ res = await fetch(url, { cache: 'no-store' });
+ } catch {
+ return 'failed';
+ }
+ if (!res.ok) return 'failed';
+
+ let source: Blob;
+ try {
+ source = await res.blob();
+ } catch {
+ return 'failed';
+ }
+
+ const stem = stripAudioExtension(sanitizeDownloadFileName(baseName)) || 'voice-note';
+ // The proxy route names the real container; the URL usually carries no
+ // extension, so the content type is the better source for both decisions.
+ const contentType = (res.headers.get('content-type') || '').split(';')[0]?.trim() ?? '';
+ const ext = AUDIO_MIME_EXTENSION_MAP[contentType] || extensionFromUrl(url) || 'webm';
+
+ if (EDITOR_READY_MIME_TYPES.has(contentType)) {
+ saveBlobAs(source, `${stem}.${ext}`);
+ return 'no-conversion-needed';
+ }
+
+ const wav = await convertAudioBlobToWav(source);
+ if (wav) {
+ saveBlobAs(wav, `${stem}.wav`);
+ return 'wav';
+ }
+
+ saveBlobAs(source, `${stem}.${ext}`);
+ return 'conversion-unsupported';
+}
+
/** Plain navigation download (streams to disk; filename controlled only for
* same-origin URLs via the download attribute). */
export function navigateDownload(url: string, sameOriginFileName?: string): void {
diff --git a/tests/unit/lib/audio-to-wav.test.ts b/tests/unit/lib/audio-to-wav.test.ts
new file mode 100644
index 0000000..56c2697
--- /dev/null
+++ b/tests/unit/lib/audio-to-wav.test.ts
@@ -0,0 +1,107 @@
+// The assertions parse the encoded bytes back out of the RIFF header, because
+// the failure mode that matters is a file an editor opens and plays wrong:
+// half speed, one channel, or a burst of noise where a loud passage was.
+
+import { describe, expect, it } from 'vitest';
+import { encodeWav, wavByteLength, MAX_WAV_OUTPUT_BYTES } from '@/lib/audio-to-wav';
+
+const HEADER_BYTES = 44;
+
+async function viewOf(blob: Blob): Promise {
+ return new DataView(await blob.arrayBuffer());
+}
+
+function ascii(view: DataView, offset: number, length: number): string {
+ let out = '';
+ for (let i = 0; i < length; i++) out += String.fromCharCode(view.getUint8(offset + i));
+ return out;
+}
+
+/** Reads back the interleaved samples as the signed 16-bit values on disk. */
+function samples(view: DataView): number[] {
+ const out: number[] = [];
+ for (let offset = HEADER_BYTES; offset < view.byteLength; offset += 2) {
+ out.push(view.getInt16(offset, true));
+ }
+ return out;
+}
+
+describe('encodeWav', () => {
+ it('writes a RIFF/WAVE header describing the audio it was given', async () => {
+ const view = await viewOf(encodeWav([new Float32Array(480), new Float32Array(480)], 48000));
+
+ expect(ascii(view, 0, 4)).toBe('RIFF');
+ expect(ascii(view, 8, 4)).toBe('WAVE');
+ expect(ascii(view, 12, 4)).toBe('fmt ');
+ expect(view.getUint32(16, true)).toBe(16); // PCM fmt payload
+ expect(view.getUint16(20, true)).toBe(1); // format tag: PCM
+ expect(view.getUint16(22, true)).toBe(2); // channels
+ expect(view.getUint32(24, true)).toBe(48000); // sample rate
+ expect(view.getUint32(28, true)).toBe(48000 * 2 * 2); // byte rate
+ expect(view.getUint16(32, true)).toBe(4); // block align
+ expect(view.getUint16(34, true)).toBe(16); // bits per sample
+ expect(ascii(view, 36, 4)).toBe('data');
+ });
+
+ it('declares sizes that match the bytes actually written', async () => {
+ const blob = encodeWav([new Float32Array(100), new Float32Array(100)], 44100);
+ const view = await viewOf(blob);
+
+ const dataBytes = 100 * 2 * 2;
+ expect(blob.size).toBe(HEADER_BYTES + dataBytes);
+ expect(view.getUint32(4, true)).toBe(blob.size - 8);
+ expect(view.getUint32(40, true)).toBe(dataBytes);
+ expect(wavByteLength(100, 2)).toBe(blob.size);
+ });
+
+ it('interleaves the channels frame by frame', async () => {
+ const left = Float32Array.from([1, 1, 1]);
+ const right = Float32Array.from([-1, -1, -1]);
+
+ const view = await viewOf(encodeWav([left, right], 48000));
+
+ // L R L R L R, not LLL RRR: a planar layout plays as a channel of speech
+ // followed by a channel of silence.
+ expect(samples(view)).toEqual([32767, -32768, 32767, -32768, 32767, -32768]);
+ });
+
+ it('clamps samples that overshoot the float range instead of wrapping them', async () => {
+ // Decoders routinely hand back values slightly outside ±1. Scaled unclamped
+ // these wrap to the opposite rail and the clip crackles.
+ const view = await viewOf(encodeWav([Float32Array.from([1.4, -1.4, 0])], 48000));
+
+ expect(samples(view)).toEqual([32767, -32768, 0]);
+ });
+
+ it('keeps a mono recording mono', async () => {
+ const blob = encodeWav([new Float32Array(240)], 48000);
+ const view = await viewOf(blob);
+
+ expect(view.getUint16(22, true)).toBe(1);
+ expect(view.getUint16(32, true)).toBe(2); // block align: one 16-bit sample
+ expect(blob.size).toBe(HEADER_BYTES + 240 * 2);
+ });
+
+ it('encodes an empty recording as a valid, empty WAV', async () => {
+ const blob = encodeWav([new Float32Array(0)], 48000);
+ const view = await viewOf(blob);
+
+ expect(blob.size).toBe(HEADER_BYTES);
+ expect(view.getUint32(40, true)).toBe(0);
+ });
+
+ it('rejects input it cannot describe in the header', () => {
+ expect(() => encodeWav([], 48000)).toThrow();
+ expect(() => encodeWav([new Float32Array(10)], 0)).toThrow();
+ });
+});
+
+describe('wavByteLength', () => {
+ it('puts the output cap beyond any plausible voice note', () => {
+ // The cap sits around 35 minutes of 48 kHz stereo. A 10MB Opus upload can
+ // just about exceed that, which is the case it exists for; an hour-long
+ // voice note is not a thing anyone records into a review comment.
+ expect(wavByteLength(48000 * 60 * 10, 2)).toBeLessThan(MAX_WAV_OUTPUT_BYTES);
+ expect(wavByteLength(48000 * 60 * 45, 2)).toBeGreaterThan(MAX_WAV_OUTPUT_BYTES);
+ });
+});