Skip to content

Commit cae9043

Browse files
committed
Improve large audio waveform loading
1 parent 4dfaef2 commit cae9043

6 files changed

Lines changed: 212 additions & 25 deletions

File tree

src/components/waveform/WaveformVisualizer.tsx

Lines changed: 169 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { usePlayerStore } from "../../stores/playerStore";
33
import { useMediaQuery } from "@/hooks/useMediaQuery";
44
import { useShadowingStore } from "../../stores/shadowingStore";
55
import {
6+
CachedWaveformData,
67
getCachedWaveform,
78
retrieveMediaFile,
89
setCachedWaveform,
@@ -34,6 +35,7 @@ import {
3435
createPlaceholderWaveform,
3536
shouldUseAdaptiveWaveform,
3637
shouldUseDetailedWaveform,
38+
shouldUseProgressiveWaveform,
3739
} from "../../utils/waveformAnalysis";
3840

3941
// Constant toast ID to ensure only one bookmark notification is shown at a time
@@ -44,6 +46,20 @@ const BOOKMARK_TOAST_ID = "bookmark-action-toast";
4446
const EMPTY_BOOKMARKS: readonly any[] = Object.freeze([]);
4547
const EMPTY_SEGMENTS: readonly any[] = Object.freeze([]);
4648

49+
const normalizeCachedWaveform = (
50+
waveform: CachedWaveformData
51+
): CachedWaveformData => ({
52+
...waveform,
53+
status:
54+
waveform.status ?? (waveform.strategy === "placeholder" ? "placeholder" : "ready"),
55+
progress:
56+
typeof waveform.progress === "number"
57+
? Math.max(0, Math.min(100, waveform.progress))
58+
: waveform.strategy === "placeholder"
59+
? 0
60+
: 100,
61+
});
62+
4763
type ShadowWaveform = {
4864
start: number;
4965
data: Float32Array;
@@ -79,6 +95,10 @@ export const WaveformVisualizer = () => {
7995
{ id: string; x1: number; x2: number; y1: number; y2: number }[]
8096
>([]);
8197
const [waveformData, setWaveformData] = useState<Float32Array | null>(null);
98+
const [waveformLoadState, setWaveformLoadState] = useState<{
99+
status: "idle" | "placeholder" | "analyzing" | "ready" | "error";
100+
progress: number;
101+
}>({ status: "idle", progress: 0 });
82102
const [isDragging, setIsDragging] = useState(false);
83103
const [dragStart, setDragStart] = useState<number | null>(null);
84104
const [dragEnd, setDragEnd] = useState<number | null>(null);
@@ -324,18 +344,43 @@ export const WaveformVisualizer = () => {
324344
!currentFile.type.includes("video"))
325345
) {
326346
setWaveformData(null);
347+
setWaveformLoadState({ status: "idle", progress: 0 });
327348
return;
328349
}
329350

330351
let cancelled = false;
352+
let idleId: number | null = null;
353+
let timeoutId: ReturnType<typeof setTimeout> | null = null;
354+
355+
const setWaveformPreview = (waveform: CachedWaveformData) => {
356+
if (cancelled) return;
357+
358+
const normalized = normalizeCachedWaveform(waveform);
359+
setWaveformData(Float32Array.from(normalized.peaks));
360+
setWaveformLoadState({
361+
status: normalized.status ?? "ready",
362+
progress: normalized.progress ?? 0,
363+
});
364+
};
365+
366+
const scheduleBackgroundAnalysis = (task: () => void) => {
367+
if (typeof window !== "undefined" && "requestIdleCallback" in window) {
368+
idleId = (
369+
window as Window & {
370+
requestIdleCallback: (callback: IdleRequestCallback) => number;
371+
}
372+
).requestIdleCallback(() => task());
373+
return;
374+
}
375+
376+
timeoutId = globalThis.setTimeout(task, 0);
377+
};
331378

332379
const loadAudio = async () => {
333380
try {
334381
if (currentYouTube) {
335382
if (!cancelled) {
336-
setWaveformData(
337-
Float32Array.from(createPlaceholderWaveform(duration || 0, 1200).peaks)
338-
);
383+
setWaveformPreview(createPlaceholderWaveform(duration || 0, 1200));
339384
}
340385
return;
341386
}
@@ -346,17 +391,15 @@ export const WaveformVisualizer = () => {
346391

347392
const mediaKey = buildWaveformMediaKey(currentFile);
348393
const cached = await getCachedWaveform(mediaKey);
349-
if (cached && !cancelled) {
350-
setWaveformData(Float32Array.from(cached.peaks));
351-
return;
352-
}
353394

354395
if (currentFile.type.includes("video")) {
355-
const placeholder = createPlaceholderWaveform(duration || 0, 1200);
396+
const placeholder: CachedWaveformData = {
397+
...createPlaceholderWaveform(duration || 0, 1200),
398+
status: "ready",
399+
progress: 100,
400+
};
356401
await setCachedWaveform(mediaKey, placeholder);
357-
if (!cancelled) {
358-
setWaveformData(Float32Array.from(placeholder.peaks));
359-
}
402+
setWaveformPreview(placeholder);
360403
return;
361404
}
362405

@@ -371,22 +414,105 @@ export const WaveformVisualizer = () => {
371414
throw new Error("Unable to load file for waveform analysis");
372415
}
373416

374-
const analysis =
375-
shouldUseDetailedWaveform(file) || shouldUseAdaptiveWaveform(file)
376-
? await analyzeAudioFileWaveform(file)
377-
: createPlaceholderWaveform(duration || 0, 800);
417+
const normalizedCached = cached ? normalizeCachedWaveform(cached) : null;
418+
const isDetailed = shouldUseDetailedWaveform(file);
419+
const isAdaptive = shouldUseAdaptiveWaveform(file);
420+
const isProgressive = shouldUseProgressiveWaveform(file);
421+
const canAnalyze = isDetailed || isAdaptive;
378422

379-
await setCachedWaveform(mediaKey, analysis);
423+
if (normalizedCached) {
424+
setWaveformPreview(normalizedCached);
425+
if (normalizedCached.status === "ready" || !canAnalyze) {
426+
return;
427+
}
428+
}
380429

381-
if (!cancelled) {
382-
setWaveformData(Float32Array.from(analysis.peaks));
430+
if (!canAnalyze) {
431+
const placeholder: CachedWaveformData = {
432+
...(normalizedCached ?? createPlaceholderWaveform(duration || 0, 800)),
433+
status: "placeholder",
434+
progress: 0,
435+
updatedAt: Date.now(),
436+
};
437+
await setCachedWaveform(mediaKey, placeholder);
438+
setWaveformPreview(placeholder);
439+
return;
383440
}
441+
442+
if (isProgressive) {
443+
const placeholder: CachedWaveformData = {
444+
...(normalizedCached ?? createPlaceholderWaveform(duration || 0, 1000)),
445+
status: "analyzing",
446+
progress: Math.max(5, normalizedCached?.progress ?? 0),
447+
updatedAt: Date.now(),
448+
};
449+
450+
await setCachedWaveform(mediaKey, placeholder);
451+
setWaveformPreview(placeholder);
452+
453+
scheduleBackgroundAnalysis(() => {
454+
void (async () => {
455+
try {
456+
const analysis = await analyzeAudioFileWaveform(file, (update) => {
457+
if (cancelled) return;
458+
459+
const previewWaveform: CachedWaveformData = {
460+
peaks: placeholder.peaks,
461+
resolution: placeholder.resolution,
462+
duration: duration || placeholder.duration,
463+
strategy: placeholder.strategy,
464+
status: update.status,
465+
progress: update.progress,
466+
updatedAt: Date.now(),
467+
};
468+
469+
setWaveformLoadState({
470+
status: update.status,
471+
progress: update.progress,
472+
});
473+
void setCachedWaveform(mediaKey, previewWaveform);
474+
});
475+
476+
await setCachedWaveform(mediaKey, analysis);
477+
setWaveformPreview(analysis);
478+
} catch (error) {
479+
console.error("Error analyzing waveform in background:", error);
480+
const failedWaveform: CachedWaveformData = {
481+
peaks: placeholder.peaks,
482+
resolution: placeholder.resolution,
483+
duration: duration || placeholder.duration,
484+
strategy: placeholder.strategy,
485+
status: "error",
486+
progress: 0,
487+
updatedAt: Date.now(),
488+
};
489+
await setCachedWaveform(mediaKey, failedWaveform);
490+
setWaveformPreview(failedWaveform);
491+
}
492+
})();
493+
});
494+
495+
return;
496+
}
497+
498+
const analysis = await analyzeAudioFileWaveform(file, (update) => {
499+
if (cancelled) return;
500+
setWaveformLoadState({
501+
status: update.status,
502+
progress: update.progress,
503+
});
504+
});
505+
506+
await setCachedWaveform(mediaKey, analysis);
507+
setWaveformPreview(analysis);
384508
} catch (error) {
385509
console.error("Error loading audio for waveform:", error);
386510
if (!cancelled) {
387-
setWaveformData(
388-
Float32Array.from(createPlaceholderWaveform(duration || 0, 800).peaks)
389-
);
511+
setWaveformPreview({
512+
...createPlaceholderWaveform(duration || 0, 800),
513+
status: "error",
514+
progress: 0,
515+
});
390516
}
391517
}
392518
};
@@ -395,6 +521,16 @@ export const WaveformVisualizer = () => {
395521

396522
return () => {
397523
cancelled = true;
524+
if (idleId !== null && typeof window !== "undefined" && "cancelIdleCallback" in window) {
525+
(
526+
window as Window & {
527+
cancelIdleCallback: (id: number) => void;
528+
}
529+
).cancelIdleCallback(idleId);
530+
}
531+
if (timeoutId !== null) {
532+
globalThis.clearTimeout(timeoutId);
533+
}
398534
};
399535
}, [currentFile, currentYouTube, duration]);
400536

@@ -1766,6 +1902,18 @@ export const WaveformVisualizer = () => {
17661902
</div>
17671903
)}
17681904

1905+
{!currentYouTube &&
1906+
(waveformLoadState.status === "analyzing" ||
1907+
waveformLoadState.status === "error") && (
1908+
<div className="absolute top-2 right-2 z-20 flex items-center gap-2 px-3 py-1.5 bg-black/55 backdrop-blur-sm rounded border border-white/10 shadow-sm">
1909+
<span className="text-white/80 text-xs font-medium">
1910+
{waveformLoadState.status === "error"
1911+
? t("waveform.analysisError")
1912+
: `${t("waveform.analyzing")} ${waveformLoadState.progress}%`}
1913+
</span>
1914+
</div>
1915+
)}
1916+
17691917
{/* Tooltip for current hover/touch position */}
17701918
{hoverTime !== null && (
17711919
<div

src/i18n/locales/en.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,8 @@
571571
"loopSelection": "Loop Selection",
572572
"touchSelectHint": "Tap and drag to select loop",
573573
"desktopSelectHint": "Drag on waveform to select loop area",
574-
"youtubePlaceholder": "Placeholder waveform displayed. Real waveform coming soon."
574+
"youtubePlaceholder": "Placeholder waveform displayed. Real waveform coming soon.",
575+
"analyzing": "Analyzing waveform...",
576+
"analysisError": "Waveform analysis failed. Using preview."
575577
}
576578
}

src/i18n/locales/ja.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,8 @@
556556
"loopSelection": "ループ選択",
557557
"touchSelectHint": "タップしてドラッグするとループを選択できます",
558558
"desktopSelectHint": "波形をドラッグしてループ範囲を選択します",
559-
"youtubePlaceholder": "プレースホルダー波形を表示中。リアル波形機能は近日公開予定"
559+
"youtubePlaceholder": "プレースホルダー波形を表示中。リアル波形機能は近日公開予定",
560+
"analyzing": "波形を解析中...",
561+
"analysisError": "波形の解析に失敗しました。プレビューを表示しています。"
560562
}
561563
}

src/i18n/locales/zh.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,8 @@
555555
"loopSelection": "循环选择",
556556
"touchSelectHint": "轻触并拖动以选择循环",
557557
"desktopSelectHint": "在波形上拖动以选择循环区域",
558-
"youtubePlaceholder": "此处为占位波形,真实波形功能即将上线"
558+
"youtubePlaceholder": "此处为占位波形,真实波形功能即将上线",
559+
"analyzing": "正在分析波形...",
560+
"analysisError": "波形分析失败,正在使用预览。"
559561
}
560562
}

src/utils/mediaStorage.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ export interface CachedWaveformData {
3232
resolution: number;
3333
duration?: number;
3434
strategy: "detailed" | "adaptive" | "placeholder";
35+
status?: "placeholder" | "analyzing" | "ready" | "error";
36+
progress?: number;
3537
updatedAt: number;
3638
}
3739

src/utils/waveformAnalysis.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ import { CachedWaveformData } from "./mediaStorage";
33
const DETAILED_FILE_LIMIT = 20 * 1024 * 1024;
44
const ADAPTIVE_FILE_LIMIT = 80 * 1024 * 1024;
55

6+
type WaveformAnalysisProgress = {
7+
progress: number;
8+
status: NonNullable<CachedWaveformData["status"]>;
9+
};
10+
611
export const buildWaveformMediaKey = (media: {
712
storageId?: string;
813
id?: string;
@@ -24,6 +29,14 @@ export const shouldUseAdaptiveWaveform = (file: {
2429
size: number;
2530
}) => file.type.includes("audio") && file.size <= ADAPTIVE_FILE_LIMIT;
2631

32+
export const shouldUseProgressiveWaveform = (file: {
33+
type: string;
34+
size: number;
35+
}) =>
36+
file.type.includes("audio") &&
37+
file.size > DETAILED_FILE_LIMIT &&
38+
file.size <= ADAPTIVE_FILE_LIMIT;
39+
2740
export const createPlaceholderWaveform = (
2841
duration = 0,
2942
resolution = 512
@@ -39,6 +52,8 @@ export const createPlaceholderWaveform = (
3952
resolution,
4053
duration,
4154
strategy: "placeholder",
55+
status: "placeholder",
56+
progress: 0,
4257
updatedAt: Date.now(),
4358
};
4459
};
@@ -70,7 +85,8 @@ const downsampleChannelData = (
7085
};
7186

7287
export const analyzeAudioFileWaveform = async (
73-
file: File
88+
file: File,
89+
onProgress?: (update: WaveformAnalysisProgress) => void
7490
): Promise<CachedWaveformData> => {
7591
const AudioContextClass =
7692
window.AudioContext || (window as Window & typeof globalThis & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
@@ -80,18 +96,33 @@ export const analyzeAudioFileWaveform = async (
8096
}
8197

8298
const audioContext = new AudioContextClass();
99+
const reportProgress = (
100+
progress: number,
101+
status: WaveformAnalysisProgress["status"]
102+
) => {
103+
onProgress?.({
104+
progress: Math.max(0, Math.min(100, Math.round(progress))),
105+
status,
106+
});
107+
};
83108

84109
try {
110+
reportProgress(10, "analyzing");
85111
const buffer = await file.arrayBuffer();
112+
reportProgress(55, "analyzing");
86113
const decoded = await audioContext.decodeAudioData(buffer.slice(0));
87114
const resolution = shouldUseDetailedWaveform(file) ? 2000 : 1000;
115+
reportProgress(85, "analyzing");
88116
const peaks = downsampleChannelData(decoded.getChannelData(0), resolution);
117+
reportProgress(100, "ready");
89118

90119
return {
91120
peaks: Array.from(peaks),
92121
resolution,
93122
duration: decoded.duration,
94123
strategy: shouldUseDetailedWaveform(file) ? "detailed" : "adaptive",
124+
status: "ready",
125+
progress: 100,
95126
updatedAt: Date.now(),
96127
};
97128
} finally {

0 commit comments

Comments
 (0)