Spaces:
Sleeping
Sleeping
| 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> | |
| ); | |
| } | |