OmniVoice-Studio / frontend /src /components /AudioTrimmer.jsx
Lê Phi Nam
Deploy to HF Space
94004a2
Raw
History Blame Contribute Delete
24.8 kB
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');
const [audioMeta, setAudioMeta] = useState(null);
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;
setAudioMeta({ duration: buf.duration, sampleRate: buf.sampleRate });
// Prime with a coarse synchronous pass so waveform shows something instantly.
peaksRef.current = computePeaksFromChannel(buf.getChannelData(0), 1024);
setViewEnd(buf.duration);
setEnd(Math.min(buf.duration, maxSeconds));
setStart(0);
setCursor(0);
setViewStart(0);
setDecoding(false);
setReady(true);
// 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);
useEffect(() => { keyHandlerRef.current = onKeyDown; }, [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…'
: (audioMeta
? `Length ${fmtHMS(audioMeta.duration)} · ${audioMeta.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>
);
}