mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
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:
@@ -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