387 lines
12 KiB
TypeScript
387 lines
12 KiB
TypeScript
import { useRef, useEffect, useState, useCallback } from 'react'
|
|
import { Music } from 'lucide-react'
|
|
import { logger } from '../lib/logger'
|
|
|
|
interface AudioClipInfo {
|
|
url: string
|
|
name: string
|
|
startTime: number
|
|
duration: number
|
|
}
|
|
|
|
interface AudioWaveformProps {
|
|
audioClips: AudioClipInfo[]
|
|
currentTime: number
|
|
isPlaying: boolean
|
|
}
|
|
|
|
// Global waveform cache: URL → Float32Array of peak amplitudes (one per pixel-bucket)
|
|
export const waveformCache = new Map<string, Float32Array>()
|
|
const pendingDecodes = new Set<string>()
|
|
|
|
// Convert a base64 string to an ArrayBuffer
|
|
function base64ToArrayBuffer(base64: string): ArrayBuffer {
|
|
const binaryString = atob(base64)
|
|
const bytes = new Uint8Array(binaryString.length)
|
|
for (let i = 0; i < binaryString.length; i++) {
|
|
bytes[i] = binaryString.charCodeAt(i)
|
|
}
|
|
return bytes.buffer
|
|
}
|
|
|
|
// Decode audio file and extract amplitude envelope
|
|
export async function computeWaveform(url: string, buckets: number = 800): Promise<Float32Array> {
|
|
if (waveformCache.has(url)) return waveformCache.get(url)!
|
|
|
|
if (pendingDecodes.has(url)) {
|
|
while (pendingDecodes.has(url)) {
|
|
await new Promise(r => setTimeout(r, 50))
|
|
}
|
|
if (waveformCache.has(url)) return waveformCache.get(url)!
|
|
}
|
|
|
|
pendingDecodes.add(url)
|
|
try {
|
|
let arrayBuffer: ArrayBuffer
|
|
|
|
if (url.startsWith('file://') && (window as any).electronAPI?.readLocalFile) {
|
|
const { data } = await (window as any).electronAPI.readLocalFile({ filePath: url })
|
|
arrayBuffer = base64ToArrayBuffer(data)
|
|
} else {
|
|
const response = await fetch(url)
|
|
arrayBuffer = await response.arrayBuffer()
|
|
}
|
|
|
|
const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)()
|
|
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer)
|
|
audioCtx.close()
|
|
|
|
const channelData = audioBuffer.getChannelData(0)
|
|
const samplesPerBucket = Math.floor(channelData.length / buckets)
|
|
const peaks = new Float32Array(buckets)
|
|
|
|
for (let i = 0; i < buckets; i++) {
|
|
let max = 0
|
|
const start = i * samplesPerBucket
|
|
const end = Math.min(start + samplesPerBucket, channelData.length)
|
|
for (let j = start; j < end; j++) {
|
|
const abs = Math.abs(channelData[j])
|
|
if (abs > max) max = abs
|
|
}
|
|
peaks[i] = max
|
|
}
|
|
|
|
waveformCache.set(url, peaks)
|
|
return peaks
|
|
} finally {
|
|
pendingDecodes.delete(url)
|
|
}
|
|
}
|
|
|
|
export function AudioWaveform({ audioClips, currentTime, isPlaying }: AudioWaveformProps) {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const [waveforms, setWaveforms] = useState<Map<string, Float32Array>>(new Map())
|
|
const animRef = useRef<number>(0)
|
|
|
|
// Load waveform data for all clips
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
const loadAll = async () => {
|
|
const newMap = new Map<string, Float32Array>()
|
|
for (const clip of audioClips) {
|
|
if (!clip.url) continue
|
|
try {
|
|
const peaks = await computeWaveform(clip.url)
|
|
if (cancelled) return
|
|
newMap.set(clip.url, peaks)
|
|
} catch (e) {
|
|
logger.warn(`Failed to decode audio waveform: ${clip.url} ${e}`)
|
|
}
|
|
}
|
|
if (!cancelled) setWaveforms(newMap)
|
|
}
|
|
loadAll()
|
|
return () => { cancelled = true }
|
|
}, [audioClips.map(c => c.url).join(',')])
|
|
|
|
// Draw waveform on canvas
|
|
const draw = useCallback(() => {
|
|
const canvas = canvasRef.current
|
|
const container = containerRef.current
|
|
if (!canvas || !container) return
|
|
|
|
const dpr = window.devicePixelRatio || 1
|
|
const rect = container.getBoundingClientRect()
|
|
const w = rect.width
|
|
const h = rect.height
|
|
|
|
canvas.width = w * dpr
|
|
canvas.height = h * dpr
|
|
canvas.style.width = `${w}px`
|
|
canvas.style.height = `${h}px`
|
|
|
|
const ctx = canvas.getContext('2d')
|
|
if (!ctx) return
|
|
ctx.scale(dpr, dpr)
|
|
|
|
// Background
|
|
ctx.fillStyle = '#0a0a0a'
|
|
ctx.fillRect(0, 0, w, h)
|
|
|
|
// Grid lines (subtle)
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.04)'
|
|
ctx.lineWidth = 1
|
|
const centerY = h / 2
|
|
// Horizontal center line
|
|
ctx.beginPath()
|
|
ctx.moveTo(0, centerY)
|
|
ctx.lineTo(w, centerY)
|
|
ctx.stroke()
|
|
// Quarter lines
|
|
for (const frac of [0.25, 0.75]) {
|
|
ctx.beginPath()
|
|
ctx.moveTo(0, h * frac)
|
|
ctx.lineTo(w, h * frac)
|
|
ctx.stroke()
|
|
}
|
|
|
|
if (audioClips.length === 0) return
|
|
|
|
// For simplicity, render the first (or longest) audio clip's waveform
|
|
// filling the entire monitor width. If multiple clips, overlay them.
|
|
const maxAmplitude = h * 0.4 // 40% of height above and below center
|
|
|
|
for (let ci = 0; ci < audioClips.length; ci++) {
|
|
const clip = audioClips[ci]
|
|
const peaks = waveforms.get(clip.url)
|
|
if (!peaks || peaks.length === 0) continue
|
|
|
|
// Map clip's time range to screen
|
|
const clipProgress = (currentTime - clip.startTime) / clip.duration
|
|
|
|
// Color: emerald with some alpha for overlapping
|
|
const alpha = audioClips.length > 1 ? 0.6 : 0.9
|
|
const gradient = ctx.createLinearGradient(0, centerY - maxAmplitude, 0, centerY + maxAmplitude)
|
|
gradient.addColorStop(0, `rgba(52, 211, 153, ${alpha})`) // emerald-400
|
|
gradient.addColorStop(0.5, `rgba(16, 185, 129, ${alpha})`) // emerald-500
|
|
gradient.addColorStop(1, `rgba(52, 211, 153, ${alpha})`)
|
|
|
|
// Draw filled waveform (mirrored around center)
|
|
ctx.fillStyle = gradient
|
|
ctx.beginPath()
|
|
|
|
// Top half (positive)
|
|
for (let i = 0; i < w; i++) {
|
|
const peakIdx = Math.floor((i / w) * peaks.length)
|
|
const amp = peaks[Math.min(peakIdx, peaks.length - 1)]
|
|
const y = centerY - amp * maxAmplitude
|
|
if (i === 0) ctx.moveTo(i, y)
|
|
else ctx.lineTo(i, y)
|
|
}
|
|
|
|
// Bottom half (negative, traced backwards)
|
|
for (let i = w - 1; i >= 0; i--) {
|
|
const peakIdx = Math.floor((i / w) * peaks.length)
|
|
const amp = peaks[Math.min(peakIdx, peaks.length - 1)]
|
|
const y = centerY + amp * maxAmplitude
|
|
ctx.lineTo(i, y)
|
|
}
|
|
|
|
ctx.closePath()
|
|
ctx.fill()
|
|
|
|
// Played region: brighter overlay
|
|
if (clipProgress > 0 && clipProgress <= 1) {
|
|
const playedX = clipProgress * w
|
|
ctx.save()
|
|
ctx.beginPath()
|
|
ctx.rect(0, 0, playedX, h)
|
|
ctx.clip()
|
|
|
|
const brightGradient = ctx.createLinearGradient(0, centerY - maxAmplitude, 0, centerY + maxAmplitude)
|
|
brightGradient.addColorStop(0, 'rgba(52, 211, 153, 0.3)')
|
|
brightGradient.addColorStop(0.5, 'rgba(16, 185, 129, 0.3)')
|
|
brightGradient.addColorStop(1, 'rgba(52, 211, 153, 0.3)')
|
|
|
|
ctx.fillStyle = brightGradient
|
|
ctx.beginPath()
|
|
for (let i = 0; i < w; i++) {
|
|
const peakIdx = Math.floor((i / w) * peaks.length)
|
|
const amp = peaks[Math.min(peakIdx, peaks.length - 1)]
|
|
const y = centerY - amp * maxAmplitude
|
|
if (i === 0) ctx.moveTo(i, y)
|
|
else ctx.lineTo(i, y)
|
|
}
|
|
for (let i = w - 1; i >= 0; i--) {
|
|
const peakIdx = Math.floor((i / w) * peaks.length)
|
|
const amp = peaks[Math.min(peakIdx, peaks.length - 1)]
|
|
const y = centerY + amp * maxAmplitude
|
|
ctx.lineTo(i, y)
|
|
}
|
|
ctx.closePath()
|
|
ctx.fill()
|
|
ctx.restore()
|
|
}
|
|
|
|
// Playhead line
|
|
if (clipProgress >= 0 && clipProgress <= 1) {
|
|
const px = clipProgress * w
|
|
ctx.strokeStyle = '#ffffff'
|
|
ctx.lineWidth = 1.5
|
|
ctx.beginPath()
|
|
ctx.moveTo(px, 4)
|
|
ctx.lineTo(px, h - 4)
|
|
ctx.stroke()
|
|
|
|
// Small triangle at top
|
|
ctx.fillStyle = '#ffffff'
|
|
ctx.beginPath()
|
|
ctx.moveTo(px, 2)
|
|
ctx.lineTo(px - 4, 8)
|
|
ctx.lineTo(px + 4, 8)
|
|
ctx.closePath()
|
|
ctx.fill()
|
|
}
|
|
}
|
|
|
|
// If no waveform data loaded yet, show loading indicator
|
|
if (waveforms.size === 0) {
|
|
ctx.fillStyle = 'rgba(255,255,255,0.3)'
|
|
ctx.font = '12px system-ui, sans-serif'
|
|
ctx.textAlign = 'center'
|
|
ctx.fillText('Loading waveform...', w / 2, centerY)
|
|
}
|
|
}, [audioClips, currentTime, waveforms])
|
|
|
|
// Animate during playback
|
|
useEffect(() => {
|
|
if (isPlaying) {
|
|
const animate = () => {
|
|
draw()
|
|
animRef.current = requestAnimationFrame(animate)
|
|
}
|
|
animRef.current = requestAnimationFrame(animate)
|
|
return () => cancelAnimationFrame(animRef.current)
|
|
} else {
|
|
draw()
|
|
}
|
|
}, [isPlaying, draw])
|
|
|
|
// Redraw on resize
|
|
useEffect(() => {
|
|
const observer = new ResizeObserver(() => draw())
|
|
if (containerRef.current) observer.observe(containerRef.current)
|
|
return () => observer.disconnect()
|
|
}, [draw])
|
|
|
|
return (
|
|
<div ref={containerRef} className="w-full h-full flex flex-col">
|
|
{/* Canvas fills available space */}
|
|
<div className="flex-1 relative min-h-0">
|
|
<canvas
|
|
ref={canvasRef}
|
|
className="absolute inset-0 w-full h-full"
|
|
/>
|
|
{/* Small music icon badge */}
|
|
<div className="absolute top-3 left-3 flex items-center gap-2 px-2 py-1 rounded bg-black/60">
|
|
<Music className="h-3 w-3 text-emerald-400" />
|
|
<span className="text-[10px] text-emerald-400 font-medium">Audio</span>
|
|
</div>
|
|
</div>
|
|
{/* Clip names */}
|
|
{audioClips.length > 0 && (
|
|
<div className="flex-shrink-0 px-3 py-1.5 bg-zinc-950 border-t border-zinc-800">
|
|
{audioClips.map((clip, i) => (
|
|
<p key={i} className="text-[10px] text-zinc-500 truncate">
|
|
{clip.name}
|
|
</p>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// --- Compact inline waveform for timeline audio clips ---
|
|
|
|
interface ClipWaveformProps {
|
|
url: string
|
|
className?: string
|
|
color?: string
|
|
}
|
|
|
|
export function ClipWaveform({ url, className = '', color = 'rgba(52, 211, 153, 0.7)' }: ClipWaveformProps) {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const [peaks, setPeaks] = useState<Float32Array | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (!url) return
|
|
let cancelled = false
|
|
computeWaveform(url, 200).then(p => {
|
|
if (!cancelled) setPeaks(p)
|
|
}).catch(() => {})
|
|
return () => { cancelled = true }
|
|
}, [url])
|
|
|
|
const draw = useCallback(() => {
|
|
const canvas = canvasRef.current
|
|
const container = containerRef.current
|
|
if (!canvas || !container || !peaks) return
|
|
|
|
const dpr = window.devicePixelRatio || 1
|
|
const rect = container.getBoundingClientRect()
|
|
const w = rect.width
|
|
const h = rect.height
|
|
if (w === 0 || h === 0) return
|
|
|
|
canvas.width = w * dpr
|
|
canvas.height = h * dpr
|
|
canvas.style.width = `${w}px`
|
|
canvas.style.height = `${h}px`
|
|
|
|
const ctx = canvas.getContext('2d')
|
|
if (!ctx) return
|
|
ctx.scale(dpr, dpr)
|
|
ctx.clearRect(0, 0, w, h)
|
|
|
|
const centerY = h / 2
|
|
const maxAmp = h * 0.45
|
|
|
|
ctx.fillStyle = color
|
|
ctx.beginPath()
|
|
for (let i = 0; i < w; i++) {
|
|
const peakIdx = Math.floor((i / w) * peaks.length)
|
|
const amp = peaks[Math.min(peakIdx, peaks.length - 1)]
|
|
const y = centerY - amp * maxAmp
|
|
if (i === 0) ctx.moveTo(i, y)
|
|
else ctx.lineTo(i, y)
|
|
}
|
|
for (let i = w - 1; i >= 0; i--) {
|
|
const peakIdx = Math.floor((i / w) * peaks.length)
|
|
const amp = peaks[Math.min(peakIdx, peaks.length - 1)]
|
|
const y = centerY + amp * maxAmp
|
|
ctx.lineTo(i, y)
|
|
}
|
|
ctx.closePath()
|
|
ctx.fill()
|
|
}, [peaks, color])
|
|
|
|
useEffect(() => {
|
|
draw()
|
|
}, [draw])
|
|
|
|
useEffect(() => {
|
|
const observer = new ResizeObserver(() => draw())
|
|
if (containerRef.current) observer.observe(containerRef.current)
|
|
return () => observer.disconnect()
|
|
}, [draw])
|
|
|
|
return (
|
|
<div ref={containerRef} className={`absolute inset-0 ${className}`}>
|
|
<canvas ref={canvasRef} className="absolute inset-0 w-full h-full" />
|
|
</div>
|
|
)
|
|
}
|