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); + }); +});