Merge pull request #76 from yusufipk/claude/webm-to-wav-conversion-0169ff

feat(voice-notes): convert recordings to WAV on download
This commit is contained in:
Yusuf İpek
2026-09-08 13:20:12 +03:00
committed by GitHub
11 changed files with 538 additions and 64 deletions
+5
View File
@@ -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}
+85 -50
View File
@@ -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 (
<Button
size="icon"
variant="outline"
className="h-7 w-7"
title="Download asset"
aria-label="Download asset"
disabled={isBusy}
onClick={() => onDownloadAsset(asset)}
>
{isBusy ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
</Button>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="outline"
className="h-7 w-7"
title="Download asset"
aria-label="Download asset"
disabled={isBusy}
>
{isBusy ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{options.map((option) => (
<DropdownMenuItem
key={option.preference}
onClick={() => onDownloadAsset(asset, option.preference)}
>
<Download className="h-3 w-3 mr-2" />
{option.label}
{option.hint && (
<span className="ml-1 text-xs text-muted-foreground">{option.hint}</span>
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
export const AssetListSection = memo(function AssetListSection({
assets,
isLoadingAssets,
@@ -130,54 +206,13 @@ export const AssetListSection = memo(function AssetListSection({
)}
</Button>
{canDownloadAssets &&
asset.provider !== 'YOUTUBE' &&
(asset.provider === 'BUNNY' && asset.kind !== 'AUDIO' ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="outline"
className="h-7 w-7"
title="Download asset"
aria-label="Download asset"
disabled={activeDownloadAssetId === asset.id || isBunnyProcessing}
>
{activeDownloadAssetId === asset.id ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Download className="h-3 w-3" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => onDownloadAsset(asset, 'original')}>
<Download className="h-3 w-3 mr-2" />
Original
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onDownloadAsset(asset, 'compressed')}>
<Download className="h-3 w-3 mr-2" />
Compressed
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button
size="icon"
variant="outline"
className="h-7 w-7"
title="Download asset"
aria-label="Download asset"
disabled={activeDownloadAssetId === asset.id || isBunnyProcessing}
onClick={() => onDownloadAsset(asset)}
>
{activeDownloadAssetId === asset.id ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Download className="h-3 w-3" />
)}
</Button>
))}
{canDownloadAssets && asset.provider !== 'YOUTUBE' && (
<AssetDownloadControl
asset={asset}
isBusy={activeDownloadAssetId === asset.id || isBunnyProcessing}
onDownloadAsset={onDownloadAsset}
/>
)}
{asset.canDelete && (
<Button
+6 -2
View File
@@ -35,7 +35,11 @@ import {
type BunnyPreviewPlayerHandle,
} from '@/components/video-page/bunny-preview-player';
import { AssetListSection } from '@/components/video-page/asset-list-section';
import type { DirectUploadProvider, VideoAsset } from '@/components/video-page/types';
import type {
AssetDownloadPreference,
DirectUploadProvider,
VideoAsset,
} from '@/components/video-page/types';
import { uploadAssetVideoToR2 } from '@/lib/client/r2-asset-video-upload';
import {
extractPastedImageFiles,
@@ -101,7 +105,7 @@ interface AssetsPaneProps {
reservationId?: string | null;
}) => Promise<VideoAsset | null>;
deleteAsset: (assetId: string) => Promise<boolean>;
downloadAsset: (asset: VideoAsset, preference?: 'original' | 'compressed') => Promise<void>;
downloadAsset: (asset: VideoAsset, preference?: AssetDownloadPreference) => Promise<void>;
hasMoreAssets: boolean;
isLoadingMoreAssets: boolean;
loadMoreAssets: () => Promise<void>;
+66
View File
@@ -92,6 +92,11 @@ interface CommentsPaneProps {
handleEditComment: (commentId: string) => void;
handleDeleteComment: (commentId: string) => void;
playVoice: (commentId: string, voiceUrl: string, knownDuration?: number) => void;
downloadVoice: (commentId: string, voiceUrl: string, baseName: string) => void;
downloadingVoiceIds: ReadonlySet<string>;
/** Same gate as the video and asset downloads: a project or share link with
* downloads disabled must not offer to save voice notes either. */
canDownloadVoiceNotes: boolean;
playingVoiceId: string | null;
voiceProgress: number;
voiceCurrentTime: number;
@@ -136,6 +141,18 @@ interface CommentsPaneProps {
assetsPane: ReactNode;
}
/**
* Names the download after the reviewer and the frame they were talking about,
* so a folder of voice notes still makes sense next to the cut.
*/
function voiceNoteFileName(
entry: { timestamp: number; author: { name: string | null } | null; guestName: string | null },
formatTime: (seconds: number) => string
): string {
const who = entry.author?.name || entry.guestName || 'guest';
return `voice-${who}-${formatTime(entry.timestamp).replace(/:/g, '-')}`;
}
export const CommentsPane = memo(function CommentsPane({
isMobileCommentsOpen,
setIsMobileCommentsOpen,
@@ -174,6 +191,9 @@ export const CommentsPane = memo(function CommentsPane({
handleEditComment,
handleDeleteComment,
playVoice,
downloadVoice,
downloadingVoiceIds,
canDownloadVoiceNotes,
playingVoiceId,
voiceProgress,
voiceCurrentTime,
@@ -680,6 +700,29 @@ export const CommentsPane = memo(function CommentsPane({
{voicePlaybackRate}x
</button>
)}
{canDownloadVoiceNotes && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
title="Download as WAV"
aria-label="Download voice note as WAV"
disabled={downloadingVoiceIds.has(comment.id)}
onClick={() =>
downloadVoice(
comment.id,
comment.voiceUrl!,
voiceNoteFileName(comment, formatTime)
)
}
>
{downloadingVoiceIds.has(comment.id) ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
</Button>
)}
</div>
)}
@@ -904,6 +947,29 @@ export const CommentsPane = memo(function CommentsPane({
{voicePlaybackRate}x
</button>
)}
{canDownloadVoiceNotes && (
<Button
size="icon"
variant="ghost"
className="h-6 w-6 shrink-0"
title="Download as WAV"
aria-label="Download voice note as WAV"
disabled={downloadingVoiceIds.has(reply.id)}
onClick={() =>
downloadVoice(
reply.id,
reply.voiceUrl!,
voiceNoteFileName(reply, formatTime)
)
}
>
{downloadingVoiceIds.has(reply.id) ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Download className="h-3 w-3" />
)}
</Button>
)}
</div>
)}
</div>
@@ -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<string | null>(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<ReadonlySet<string>>(
() => new Set()
);
const audioPlayerRef = useRef<HTMLAudioElement | null>(null);
const voiceRafRef = useRef<number | null>(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,
};
}
@@ -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 [
@@ -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',
+2
View File
@@ -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;
+121
View File
@@ -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<Blob | null> {
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;
}
}
+93 -1
View File
@@ -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<string, string> = {
'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<AudioDownloadResult> {
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 {
+107
View File
@@ -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<DataView> {
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);
});
});