feat(voice-notes): convert recordings to WAV on download

MediaRecorder gives us WebM/Opus, and that is exactly what we stored and served back. Browsers and desktop players read it, but no editing suite does: DaVinci Resolve, Premiere and Final Cut all refuse the container outright, so a voice note downloaded byte-for-byte was useless to the editor it was recorded for.

The browser already decodes these formats in order to play them, so the conversion costs nothing but a RIFF header. lib/audio-to-wav.ts decodes through an OfflineAudioContext and writes interleaved 16-bit PCM. This runs at download time rather than at record time, so the stored object stays the small Opus file, uploads keep their 10MB limit, and self-hosted installs gain no server-side ffmpeg dependency.

Voice comments had no download control at all, only a play button, so reviewers were saving files straight off the audio element and getting a bare UUID. They now get a download button on both comments and replies, named after the reviewer and the frame they were talking about, gated on the same download permission as the video and asset downloads. Audio assets get a WAV / Original menu.

Files already in an editable container (wav, mp3, m4a) are handed over untouched: audio assets are not only recordings, and decoding an uploaded master back out would resample it to 48 kHz and requantise it to 16 bit for no gain. When a browser cannot decode the stored format at all, the original is saved and the user is told.
This commit is contained in:
2026-09-08 13:15:28 +03:00
parent 79bba5e7a1
commit 142dee0c06
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;