- CheckpointBanner: 7→1 (dynamic accent border-left stays) - DirectionDialog: 5→0 - SetupWizard: 9→3 (dynamic fix-text color stays) - App.jsx: 6→3 (dynamic zoom stays) - CompareModal: 2→1 (dynamic accent color stays) - AudioTrimmer: 1→0 New Misc.css shared file for remaining small-component classes. Total inline style count: 65→43 (cumulative 127→43, 66% reduction) All remaining 43 are genuinely dynamic (CSS vars, computed colors, animation delays, column widths, progress bars).
677 lines
24 KiB
React
677 lines
24 KiB
React
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
|
import { Scissors, Play, Pause, Check, ZoomIn, ZoomOut, Maximize2, Repeat } from 'lucide-react';
|
|
import {
|
|
clamp, encodeWav, computePeaksFromChannel, computePeaksAsync, pickTickInterval,
|
|
xToTime as xToTimeUtil, pickHandle as pickHandleUtil,
|
|
applyDrag as applyDragUtil, zoomAtCursor, zoomCenter, sliceToMono,
|
|
decodeToMonoLowRate, DEFAULT_PEAK_BUCKETS,
|
|
} from '../utils/audioTrim.js';
|
|
import { Dialog, Button } from '../ui';
|
|
import './AudioTrimmer.css';
|
|
|
|
const EDGE_GRAB_PX = 10;
|
|
|
|
const computePeaks = (buffer, buckets = DEFAULT_PEAK_BUCKETS) =>
|
|
computePeaksFromChannel(buffer.getChannelData(0), buckets);
|
|
|
|
function fmtSec(t, precision = 2) {
|
|
if (!isFinite(t)) return '0.00s';
|
|
return `${t.toFixed(precision)}s`;
|
|
}
|
|
|
|
function fmtHMS(t) {
|
|
if (!isFinite(t)) return '0:00.00';
|
|
const h = Math.floor(t / 3600);
|
|
const m = Math.floor((t % 3600) / 60);
|
|
const s = t % 60;
|
|
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${s.toFixed(2).padStart(5, '0')}`;
|
|
return `${m}:${s.toFixed(2).padStart(5, '0')}`;
|
|
}
|
|
|
|
export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCancel }) {
|
|
const waveRef = useRef(null);
|
|
const rulerRef = useRef(null);
|
|
const audioRef = useRef(null);
|
|
const containerRef = useRef(null);
|
|
const bufferRef = useRef(null);
|
|
const peaksRef = useRef(null);
|
|
const drawRafRef = useRef(0);
|
|
const dragRafRef = useRef(0);
|
|
const dragStateRef = useRef(null);
|
|
const pointerRef = useRef(null);
|
|
const stateRef = useRef({ start: 0, end: 0, cursor: 0, viewStart: 0, viewEnd: 0, duration: 0 });
|
|
|
|
const [ready, setReady] = useState(false);
|
|
const [decoding, setDecoding] = useState(true);
|
|
const [peakProgress, setPeakProgress] = useState(0);
|
|
const [error, setError] = useState('');
|
|
const [start, setStart] = useState(0);
|
|
const [end, setEnd] = useState(0);
|
|
const [cursor, setCursor] = useState(0);
|
|
const [viewStart, setViewStart] = useState(0);
|
|
const [viewEnd, setViewEnd] = useState(0);
|
|
const [playing, setPlaying] = useState(false);
|
|
const [loop, setLoop] = useState(true);
|
|
const [startInput, setStartInput] = useState('0.00');
|
|
const [endInput, setEndInput] = useState('0.00');
|
|
|
|
useEffect(() => {
|
|
stateRef.current = {
|
|
start, end, cursor, viewStart, viewEnd,
|
|
duration: bufferRef.current ? bufferRef.current.duration : 0,
|
|
};
|
|
}, [start, end, cursor, viewStart, viewEnd]);
|
|
|
|
useEffect(() => { setStartInput(start.toFixed(2)); }, [start]);
|
|
useEffect(() => { setEndInput(end.toFixed(2)); }, [end]);
|
|
|
|
// Decode (low rate mono) + async peaks — keeps UI responsive for long files.
|
|
useEffect(() => {
|
|
if (!file) return;
|
|
let cancelled = false;
|
|
setDecoding(true);
|
|
setReady(false);
|
|
setPeakProgress(0);
|
|
(async () => {
|
|
try {
|
|
const buf = await decodeToMonoLowRate(file, 22050);
|
|
if (cancelled) return;
|
|
bufferRef.current = buf;
|
|
// Prime with a coarse synchronous pass so waveform shows something instantly.
|
|
peaksRef.current = computePeaksFromChannel(buf.getChannelData(0), 1024);
|
|
const initEnd = Math.min(buf.duration, maxSeconds);
|
|
setStart(0);
|
|
setEnd(initEnd);
|
|
setCursor(0);
|
|
setViewStart(0);
|
|
setViewEnd(buf.duration);
|
|
setReady(true);
|
|
setDecoding(false);
|
|
// Refine peaks asynchronously without blocking UI.
|
|
const refined = await computePeaksAsync(
|
|
buf.getChannelData(0),
|
|
DEFAULT_PEAK_BUCKETS,
|
|
(p) => { if (!cancelled) setPeakProgress(p); },
|
|
);
|
|
if (cancelled) return;
|
|
peaksRef.current = refined;
|
|
setPeakProgress(1);
|
|
} catch (e) {
|
|
if (!cancelled) setError('Decode failed: ' + (e.message || e));
|
|
setDecoding(false);
|
|
}
|
|
})();
|
|
return () => { cancelled = true; };
|
|
}, [file, maxSeconds]);
|
|
|
|
// Bind audio src
|
|
useEffect(() => {
|
|
if (!file) return;
|
|
const url = URL.createObjectURL(file);
|
|
const a = audioRef.current;
|
|
if (a) { a.src = url; a.load(); }
|
|
return () => {
|
|
if (a) { try { a.pause(); a.removeAttribute('src'); a.load(); } catch {} }
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
}, [file]);
|
|
|
|
const sizeCanvas = useCallback((canvas) => {
|
|
if (!canvas) return null;
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const w = Math.max(1, Math.floor(rect.width * dpr));
|
|
const h = Math.max(1, Math.floor(rect.height * dpr));
|
|
if (canvas.width !== w) canvas.width = w;
|
|
if (canvas.height !== h) canvas.height = h;
|
|
return { w, h, dpr };
|
|
}, []);
|
|
|
|
const drawWave = useCallback(() => {
|
|
const buffer = bufferRef.current;
|
|
const peaks = peaksRef.current;
|
|
const canvas = waveRef.current;
|
|
if (!buffer || !peaks || !canvas) return;
|
|
const sized = sizeCanvas(canvas);
|
|
if (!sized) return;
|
|
const { w, h, dpr } = sized;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.clearRect(0, 0, w, h);
|
|
|
|
const { viewStart: vs, viewEnd: ve, start: s, end: e, cursor: c, duration: dur } = stateRef.current;
|
|
const viewDur = Math.max(1e-6, ve - vs);
|
|
const totalBuckets = peaks.length / 2;
|
|
const secPerBucket = dur / totalBuckets;
|
|
const secPerPixel = viewDur / w;
|
|
const useRaw = secPerPixel < secPerBucket;
|
|
const ch = buffer.getChannelData(0);
|
|
const sr = buffer.sampleRate;
|
|
|
|
// Center baseline
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, h / 2);
|
|
ctx.lineTo(w, h / 2);
|
|
ctx.stroke();
|
|
|
|
// Waveform
|
|
ctx.fillStyle = '#7a6f5d';
|
|
for (let x = 0; x < w; x++) {
|
|
const t0 = vs + (x / w) * viewDur;
|
|
const t1 = vs + ((x + 1) / w) * viewDur;
|
|
let mn = 1, mx = -1;
|
|
if (useRaw) {
|
|
const i0 = Math.max(0, Math.floor(t0 * sr));
|
|
const i1 = Math.min(ch.length, Math.ceil(t1 * sr));
|
|
for (let i = i0; i < i1; i++) {
|
|
const v = ch[i];
|
|
if (v < mn) mn = v;
|
|
if (v > mx) mx = v;
|
|
}
|
|
} else {
|
|
const b0 = Math.max(0, Math.floor(t0 / secPerBucket));
|
|
const b1 = Math.min(totalBuckets, Math.ceil(t1 / secPerBucket));
|
|
for (let b = b0; b < b1; b++) {
|
|
const pmn = peaks[b * 2];
|
|
const pmx = peaks[b * 2 + 1];
|
|
if (pmn < mn) mn = pmn;
|
|
if (pmx > mx) mx = pmx;
|
|
}
|
|
}
|
|
if (mx < mn) { mn = 0; mx = 0; }
|
|
const y1 = (1 - mx) * 0.5 * h;
|
|
const y2 = (1 - mn) * 0.5 * h;
|
|
ctx.fillRect(x, y1, 1, Math.max(1, y2 - y1));
|
|
}
|
|
|
|
// Selection
|
|
const tToX = (t) => ((t - vs) / viewDur) * w;
|
|
const sx = tToX(s);
|
|
const ex = tToX(e);
|
|
const selW = ex - sx;
|
|
ctx.fillStyle = 'rgba(211,134,155,0.18)';
|
|
ctx.fillRect(sx, 0, selW, h);
|
|
|
|
// Redraw selection waveform in accent
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.rect(Math.max(0, sx), 0, Math.max(0, Math.min(w, ex) - Math.max(0, sx)), h);
|
|
ctx.clip();
|
|
ctx.fillStyle = '#d3869b';
|
|
for (let x = Math.max(0, Math.floor(sx)); x < Math.min(w, Math.ceil(ex)); x++) {
|
|
const t0 = vs + (x / w) * viewDur;
|
|
const t1 = vs + ((x + 1) / w) * viewDur;
|
|
let mn = 1, mx = -1;
|
|
if (useRaw) {
|
|
const i0 = Math.max(0, Math.floor(t0 * sr));
|
|
const i1 = Math.min(ch.length, Math.ceil(t1 * sr));
|
|
for (let i = i0; i < i1; i++) {
|
|
const v = ch[i];
|
|
if (v < mn) mn = v;
|
|
if (v > mx) mx = v;
|
|
}
|
|
} else {
|
|
const b0 = Math.max(0, Math.floor(t0 / secPerBucket));
|
|
const b1 = Math.min(totalBuckets, Math.ceil(t1 / secPerBucket));
|
|
for (let b = b0; b < b1; b++) {
|
|
const pmn = peaks[b * 2];
|
|
const pmx = peaks[b * 2 + 1];
|
|
if (pmn < mn) mn = pmn;
|
|
if (pmx > mx) mx = pmx;
|
|
}
|
|
}
|
|
if (mx < mn) { mn = 0; mx = 0; }
|
|
const y1 = (1 - mx) * 0.5 * h;
|
|
const y2 = (1 - mn) * 0.5 * h;
|
|
ctx.fillRect(x, y1, 1, Math.max(1, y2 - y1));
|
|
}
|
|
ctx.restore();
|
|
|
|
// Selection border
|
|
ctx.strokeStyle = '#d3869b';
|
|
ctx.lineWidth = 1 * dpr;
|
|
ctx.strokeRect(sx + 0.5, 0.5, selW, h - 1);
|
|
|
|
// Handles (flags)
|
|
const handleW = 6 * dpr;
|
|
const handleH = Math.min(h, 22 * dpr);
|
|
ctx.fillStyle = '#d3869b';
|
|
ctx.fillRect(sx - handleW / 2, 0, handleW, handleH);
|
|
ctx.fillRect(ex - handleW / 2, 0, handleW, handleH);
|
|
ctx.fillRect(sx - handleW / 2, h - handleH, handleW, handleH);
|
|
ctx.fillRect(ex - handleW / 2, h - handleH, handleW, handleH);
|
|
|
|
// Playhead
|
|
if (c >= vs && c <= ve) {
|
|
const cx = tToX(c);
|
|
ctx.fillStyle = '#fabd2f';
|
|
ctx.fillRect(cx, 0, 1.5 * dpr, h);
|
|
}
|
|
}, [sizeCanvas]);
|
|
|
|
const drawRuler = useCallback(() => {
|
|
const canvas = rulerRef.current;
|
|
const buffer = bufferRef.current;
|
|
if (!canvas || !buffer) return;
|
|
const sized = sizeCanvas(canvas);
|
|
if (!sized) return;
|
|
const { w, h, dpr } = sized;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.clearRect(0, 0, w, h);
|
|
const { viewStart: vs, viewEnd: ve } = stateRef.current;
|
|
const viewDur = Math.max(1e-6, ve - vs);
|
|
const tick = pickTickInterval(viewDur);
|
|
const firstTick = Math.ceil(vs / tick) * tick;
|
|
|
|
ctx.font = `${10 * dpr}px -apple-system, system-ui, sans-serif`;
|
|
ctx.fillStyle = '#a89984';
|
|
ctx.strokeStyle = 'rgba(168,153,132,0.3)';
|
|
ctx.lineWidth = 1;
|
|
for (let t = firstTick; t <= ve + 1e-6; t += tick) {
|
|
const x = ((t - vs) / viewDur) * w;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, h - 6 * dpr);
|
|
ctx.lineTo(x, h);
|
|
ctx.stroke();
|
|
const label = tick >= 1 ? fmtHMS(t) : `${t.toFixed(2)}s`;
|
|
ctx.fillText(label, x + 3 * dpr, h - 8 * dpr);
|
|
}
|
|
}, [sizeCanvas]);
|
|
|
|
const scheduleDraw = useCallback(() => {
|
|
if (drawRafRef.current) return;
|
|
drawRafRef.current = requestAnimationFrame(() => {
|
|
drawRafRef.current = 0;
|
|
drawWave();
|
|
drawRuler();
|
|
});
|
|
}, [drawWave, drawRuler]);
|
|
|
|
useEffect(() => { scheduleDraw(); }, [start, end, cursor, viewStart, viewEnd, ready, scheduleDraw]);
|
|
|
|
useEffect(() => {
|
|
const onResize = () => scheduleDraw();
|
|
window.addEventListener('resize', onResize);
|
|
return () => window.removeEventListener('resize', onResize);
|
|
}, [scheduleDraw]);
|
|
|
|
const canvasRect = () => {
|
|
const c = waveRef.current;
|
|
return c ? c.getBoundingClientRect() : null;
|
|
};
|
|
|
|
const xToTime = (clientX) => {
|
|
const rect = canvasRect();
|
|
if (!rect) return 0;
|
|
return xToTimeUtil(clientX - rect.left, rect.width,
|
|
stateRef.current.viewStart, stateRef.current.viewEnd);
|
|
};
|
|
|
|
const pickHandleAt = (clientX) => {
|
|
const rect = canvasRect();
|
|
if (!rect) return null;
|
|
return pickHandleUtil(clientX, rect.left, rect.width, stateRef.current, EDGE_GRAB_PX);
|
|
};
|
|
|
|
const applyPointer = useCallback(() => {
|
|
const pos = pointerRef.current;
|
|
if (!pos) return;
|
|
pointerRef.current = null;
|
|
const buffer = bufferRef.current;
|
|
const rect = canvasRect();
|
|
if (!buffer || !rect) return;
|
|
const drag = dragStateRef.current;
|
|
if (!drag) {
|
|
const t = xToTimeUtil(pos.clientX - rect.left, rect.width,
|
|
stateRef.current.viewStart, stateRef.current.viewEnd);
|
|
setCursor(clamp(t, 0, stateRef.current.duration));
|
|
return;
|
|
}
|
|
const out = applyDragUtil(stateRef.current, pos.clientX, rect.left, rect.width, drag, 0.02);
|
|
if (drag.mode === 'start') setStart(out.start);
|
|
else if (drag.mode === 'end') setEnd(out.end);
|
|
else if (drag.mode === 'region' || drag.mode === 'new') { setStart(out.start); setEnd(out.end); }
|
|
else if (drag.mode === 'pan') { setViewStart(out.viewStart); setViewEnd(out.viewEnd); }
|
|
}, []);
|
|
|
|
const schedulePointer = useCallback(() => {
|
|
if (dragRafRef.current) return;
|
|
dragRafRef.current = requestAnimationFrame(() => {
|
|
dragRafRef.current = 0;
|
|
applyPointer();
|
|
});
|
|
}, [applyPointer]);
|
|
|
|
useEffect(() => {
|
|
const move = (e) => {
|
|
pointerRef.current = { clientX: e.clientX };
|
|
schedulePointer();
|
|
};
|
|
const up = () => { dragStateRef.current = null; pointerRef.current = null; };
|
|
window.addEventListener('mousemove', move);
|
|
window.addEventListener('mouseup', up);
|
|
return () => {
|
|
window.removeEventListener('mousemove', move);
|
|
window.removeEventListener('mouseup', up);
|
|
};
|
|
}, [schedulePointer]);
|
|
|
|
const onCanvasDown = (e) => {
|
|
if (!ready) return;
|
|
e.preventDefault();
|
|
const buffer = bufferRef.current;
|
|
if (!buffer) return;
|
|
if (e.button === 1 || e.altKey || e.metaKey) {
|
|
dragStateRef.current = {
|
|
mode: 'pan',
|
|
startClientX: e.clientX,
|
|
viewStart: stateRef.current.viewStart,
|
|
viewDur: stateRef.current.viewEnd - stateRef.current.viewStart,
|
|
};
|
|
pointerRef.current = { clientX: e.clientX };
|
|
schedulePointer();
|
|
return;
|
|
}
|
|
const handle = pickHandleAt(e.clientX);
|
|
if (handle === 'region') {
|
|
const t = xToTime(e.clientX);
|
|
dragStateRef.current = {
|
|
mode: 'region',
|
|
regionLen: stateRef.current.end - stateRef.current.start,
|
|
offset: t - stateRef.current.start,
|
|
};
|
|
} else if (handle === 'start' || handle === 'end') {
|
|
dragStateRef.current = { mode: handle };
|
|
} else {
|
|
const t = clamp(xToTime(e.clientX), 0, buffer.duration);
|
|
// Start a fresh selection anchored at click point. Drag extends it.
|
|
setStart(t);
|
|
setEnd(t);
|
|
setCursor(t);
|
|
const a = audioRef.current;
|
|
if (a) { try { a.currentTime = t; } catch {} }
|
|
dragStateRef.current = { mode: 'new', anchorT: t };
|
|
}
|
|
pointerRef.current = { clientX: e.clientX };
|
|
schedulePointer();
|
|
};
|
|
|
|
const onWheel = useCallback((e) => {
|
|
const buffer = bufferRef.current;
|
|
if (!buffer) return;
|
|
e.preventDefault();
|
|
const rect = waveRef.current.getBoundingClientRect();
|
|
const { viewStart: vs, viewEnd: ve } = stateRef.current;
|
|
const viewDur = ve - vs;
|
|
if (e.shiftKey) {
|
|
const dir = Math.sign(e.deltaY || e.deltaX);
|
|
const pan = dir * viewDur * 0.15;
|
|
const newVs = clamp(vs + pan, 0, buffer.duration - viewDur);
|
|
setViewStart(newVs);
|
|
setViewEnd(newVs + viewDur);
|
|
} else {
|
|
const xFrac = clamp((e.clientX - rect.left) / rect.width, 0, 1);
|
|
const factor = e.deltaY < 0 ? 0.8 : 1.25;
|
|
const minDur = Math.max(0.01, buffer.sampleRate ? 200 / buffer.sampleRate : 0.01);
|
|
const out = zoomAtCursor(vs, ve, buffer.duration, factor, xFrac, minDur);
|
|
setViewStart(out.viewStart);
|
|
setViewEnd(out.viewEnd);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const canvas = waveRef.current;
|
|
if (!canvas) return;
|
|
const handler = (e) => { if (ready) onWheel(e); };
|
|
canvas.addEventListener('wheel', handler, { passive: false });
|
|
return () => canvas.removeEventListener('wheel', handler);
|
|
}, [onWheel, ready]);
|
|
|
|
const zoomIn = () => {
|
|
const buffer = bufferRef.current; if (!buffer) return;
|
|
const { viewStart: vs, viewEnd: ve } = stateRef.current;
|
|
const out = zoomCenter(vs, ve, buffer.duration, 0.5);
|
|
setViewStart(out.viewStart); setViewEnd(out.viewEnd);
|
|
};
|
|
const zoomOut = () => {
|
|
const buffer = bufferRef.current; if (!buffer) return;
|
|
const { viewStart: vs, viewEnd: ve } = stateRef.current;
|
|
const out = zoomCenter(vs, ve, buffer.duration, 2);
|
|
setViewStart(out.viewStart); setViewEnd(out.viewEnd);
|
|
};
|
|
const fitAll = () => {
|
|
const buffer = bufferRef.current; if (!buffer) return;
|
|
setViewStart(0);
|
|
setViewEnd(buffer.duration);
|
|
};
|
|
const fitSelection = () => {
|
|
const buffer = bufferRef.current; if (!buffer) return;
|
|
const { start: s, end: e } = stateRef.current;
|
|
const pad = Math.max(0.1, (e - s) * 0.2);
|
|
const nvs = clamp(s - pad, 0, buffer.duration);
|
|
const nve = clamp(e + pad, nvs + 0.02, buffer.duration);
|
|
setViewStart(nvs);
|
|
setViewEnd(nve);
|
|
};
|
|
|
|
const togglePlay = () => {
|
|
const a = audioRef.current;
|
|
if (!a) return;
|
|
if (playing) { a.pause(); setPlaying(false); return; }
|
|
const s = stateRef.current.start;
|
|
const doPlay = () => {
|
|
try { a.currentTime = s; } catch (err) { console.warn('currentTime set failed', err); }
|
|
a.play().then(() => setPlaying(true)).catch((err) => {
|
|
setError('Playback failed: ' + (err.message || err));
|
|
});
|
|
};
|
|
// HAVE_METADATA = 1 is enough to set currentTime on most browsers.
|
|
if (a.readyState >= 1) {
|
|
doPlay();
|
|
} else {
|
|
a.addEventListener('loadedmetadata', doPlay, { once: true });
|
|
a.addEventListener('error', () => setError('Audio load failed'), { once: true });
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
const a = audioRef.current;
|
|
if (!a) return;
|
|
let raf;
|
|
const tick = () => {
|
|
if (!a.paused) {
|
|
setCursor(a.currentTime);
|
|
const { start: s, end: e } = stateRef.current;
|
|
if (a.currentTime >= e) {
|
|
if (loop) {
|
|
try { a.currentTime = s; } catch {}
|
|
} else {
|
|
a.pause();
|
|
setPlaying(false);
|
|
}
|
|
}
|
|
}
|
|
raf = requestAnimationFrame(tick);
|
|
};
|
|
raf = requestAnimationFrame(tick);
|
|
return () => cancelAnimationFrame(raf);
|
|
}, [loop]);
|
|
|
|
const duration = end - start;
|
|
const tooLong = duration > maxSeconds;
|
|
const tooShort = duration < 0.1;
|
|
|
|
const commitStartInput = () => {
|
|
const v = parseFloat(startInput);
|
|
if (isFinite(v)) setStart(clamp(v, 0, Math.max(0, end - 0.02)));
|
|
else setStartInput(start.toFixed(2));
|
|
};
|
|
const commitEndInput = () => {
|
|
const buffer = bufferRef.current;
|
|
const v = parseFloat(endInput);
|
|
if (isFinite(v) && buffer) setEnd(clamp(v, start + 0.02, buffer.duration));
|
|
else setEndInput(end.toFixed(2));
|
|
};
|
|
|
|
const onKeyDown = (e) => {
|
|
if (!ready) return;
|
|
const buffer = bufferRef.current;
|
|
if (!buffer) return;
|
|
const tgt = e.target;
|
|
if (tgt && (tgt.tagName === 'INPUT' || tgt.tagName === 'TEXTAREA')) return;
|
|
const fine = e.shiftKey ? 0.01 : e.altKey ? 1.0 : 0.1;
|
|
if (e.key === ' ' || e.code === 'Space') { e.preventDefault(); togglePlay(); return; }
|
|
if (e.key === 'ArrowLeft') {
|
|
e.preventDefault();
|
|
if (e.ctrlKey || e.metaKey) setEnd(clamp(end - fine, start + 0.02, buffer.duration));
|
|
else setStart(clamp(start - fine, 0, Math.max(0, end - 0.02)));
|
|
} else if (e.key === 'ArrowRight') {
|
|
e.preventDefault();
|
|
if (e.ctrlKey || e.metaKey) setEnd(clamp(end + fine, start + 0.02, buffer.duration));
|
|
else setStart(clamp(start + fine, 0, Math.max(0, end - 0.02)));
|
|
} else if (e.key === '+' || e.key === '=') { e.preventDefault(); zoomIn(); }
|
|
else if (e.key === '-' || e.key === '_') { e.preventDefault(); zoomOut(); }
|
|
else if (e.key === 'Home') { e.preventDefault(); fitAll(); }
|
|
else if (e.key === 'End') { e.preventDefault(); fitSelection(); }
|
|
else if (e.key === 'Escape') { e.preventDefault(); onCancel(); }
|
|
else if (e.key === 'Enter') { if (!tooLong && !tooShort) handleConfirm(); }
|
|
};
|
|
|
|
const keyHandlerRef = useRef(onKeyDown);
|
|
keyHandlerRef.current = onKeyDown;
|
|
|
|
useEffect(() => {
|
|
const node = containerRef.current;
|
|
if (!node) return;
|
|
const handler = (e) => keyHandlerRef.current(e);
|
|
node.addEventListener('keydown', handler);
|
|
node.focus();
|
|
return () => node.removeEventListener('keydown', handler);
|
|
}, []);
|
|
|
|
const handleConfirm = () => {
|
|
const buffer = bufferRef.current;
|
|
if (!buffer || tooLong || tooShort) return;
|
|
const mono = sliceToMono(buffer, start, end);
|
|
const wav = encodeWav(mono, buffer.sampleRate);
|
|
const base = (file.name || 'trimmed').replace(/\.[^.]+$/, '');
|
|
const trimmed = new File([wav], `${base}_trim.wav`, { type: 'audio/wav' });
|
|
onConfirm(trimmed);
|
|
};
|
|
|
|
const duration_ms = Math.max(0, (end - start) * 1000);
|
|
|
|
return (
|
|
<Dialog
|
|
open
|
|
onClose={onCancel}
|
|
size="xl"
|
|
title={<><Scissors size={15} color="var(--color-brand)" /> Trim reference audio</>}
|
|
>
|
|
<div ref={containerRef} tabIndex={-1} className="audio-trimmer">
|
|
<div className="audio-trimmer__meta">
|
|
<span>{decoding
|
|
? 'Decoding audio…'
|
|
: (bufferRef.current
|
|
? `Length ${fmtHMS(bufferRef.current.duration)} · ${bufferRef.current.sampleRate} Hz${peakProgress > 0 && peakProgress < 1 ? ` · rendering waveform ${Math.round(peakProgress * 100)}%` : ''}`
|
|
: '…')
|
|
}</span>
|
|
<span className="audio-trimmer__hint">
|
|
scroll = zoom · shift+scroll = pan · alt+drag = pan · ⏐ ⟵ ⟶ ⏐ keys adjust handles
|
|
</span>
|
|
</div>
|
|
|
|
{error && <div className="audio-trimmer__error">{error}</div>}
|
|
|
|
{/* Zoom controls */}
|
|
<div className="audio-trimmer__toolbar">
|
|
<Button variant="subtle" iconSize="md" onClick={zoomIn} disabled={!ready} title="Zoom in (+)"><ZoomIn size={12}/></Button>
|
|
<Button variant="subtle" iconSize="md" onClick={zoomOut} disabled={!ready} title="Zoom out (-)"><ZoomOut size={12}/></Button>
|
|
<Button variant="subtle" iconSize="md" onClick={fitAll} disabled={!ready} title="Fit all (Home)"><Maximize2 size={12}/></Button>
|
|
<Button variant="chip" size="sm" onClick={fitSelection} disabled={!ready} title="Fit selection (End)">FIT SEL</Button>
|
|
<div className="audio-trimmer__view-info">
|
|
View {fmtHMS(viewStart)} → {fmtHMS(viewEnd)} ({fmtSec(viewEnd - viewStart, viewEnd - viewStart < 10 ? 2 : 0)})
|
|
</div>
|
|
</div>
|
|
|
|
{/* Ruler */}
|
|
<canvas ref={rulerRef} className="audio-trimmer__ruler" />
|
|
|
|
{/* Waveform */}
|
|
<canvas ref={waveRef} onMouseDown={onCanvasDown} className="audio-trimmer__wave" />
|
|
|
|
{/* Numeric fields */}
|
|
<div className="audio-trimmer__fields">
|
|
<label className="trim-field">
|
|
<span className="trim-field__label">Start</span>
|
|
<input
|
|
type="text" inputMode="decimal" value={startInput}
|
|
onChange={(e) => setStartInput(e.target.value)}
|
|
onBlur={commitStartInput}
|
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitStartInput(); } }}
|
|
className="trim-field__input"
|
|
/>
|
|
<span className="trim-field__unit">s</span>
|
|
</label>
|
|
<label className="trim-field">
|
|
<span className="trim-field__label">End</span>
|
|
<input
|
|
type="text" inputMode="decimal" value={endInput}
|
|
onChange={(e) => setEndInput(e.target.value)}
|
|
onBlur={commitEndInput}
|
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitEndInput(); } }}
|
|
className="trim-field__input"
|
|
/>
|
|
<span className="trim-field__unit">s</span>
|
|
</label>
|
|
<div className="trim-field trim-field--readonly">
|
|
<span className="trim-field__label">Length</span>
|
|
<span className={`trim-field__value ${tooLong ? 'is-err' : ''}`}>
|
|
{(duration_ms / 1000).toFixed(2)}s
|
|
</span>
|
|
<span className="trim-field__unit">{tooLong ? `>${maxSeconds}s` : tooShort ? 'too short' : 'ok'}</span>
|
|
</div>
|
|
<Button
|
|
variant="icon"
|
|
iconSize="md"
|
|
active={loop}
|
|
onClick={() => setLoop((v) => !v)}
|
|
title="Loop preview"
|
|
>
|
|
<Repeat size={12} />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Play / Action row */}
|
|
<div className="audio-trimmer__actions">
|
|
<Button
|
|
variant="subtle"
|
|
onClick={togglePlay}
|
|
disabled={!ready}
|
|
leading={playing ? <Pause size={12} /> : <Play size={12} />}
|
|
className="audio-trimmer__play-btn"
|
|
>
|
|
{playing ? 'Pause' : 'Preview selection'}
|
|
</Button>
|
|
<span className="audio-trimmer__kbd-hint">Space to play · Enter to confirm · Esc to cancel</span>
|
|
|
|
<div className="audio-trimmer__actions-right">
|
|
<Button variant="ghost" onClick={onCancel}>Cancel</Button>
|
|
<Button
|
|
variant={(tooLong || tooShort) ? 'danger' : 'primary'}
|
|
disabled={!ready || tooLong || tooShort}
|
|
onClick={handleConfirm}
|
|
leading={<Check size={12} />}
|
|
>
|
|
Use trimmed
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<audio ref={audioRef} preload="auto" onEnded={() => setPlaying(false)} className="audio-trimmer__audio" />
|
|
</div>
|
|
</Dialog>
|
|
);
|
|
}
|