mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
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:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user