From 63ded34a6b9a43e9b9ff5cbc1c2d40553bc3bef0 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Fri, 6 Feb 2026 09:57:33 -0500 Subject: [PATCH] basic clean --- src/components/MergedWaveform.tsx | 78 +++++++++-------- src/utils/audio-waveform.ts | 138 ++++++++++-------------------- 2 files changed, 87 insertions(+), 129 deletions(-) diff --git a/src/components/MergedWaveform.tsx b/src/components/MergedWaveform.tsx index 725c5a0..3a64dbb 100644 --- a/src/components/MergedWaveform.tsx +++ b/src/components/MergedWaveform.tsx @@ -6,88 +6,92 @@ * separate progress bar. Click-to-seek is supported. */ -import { createSignal, createEffect, onCleanup } from "solid-js" -import { getWaveformData, getWaveformDataSync } from "../utils/audio-waveform" +import { createSignal, createEffect, onCleanup } from "solid-js"; +import { getWaveformData } from "../utils/audio-waveform"; type MergedWaveformProps = { /** Audio URL — used to generate or retrieve waveform data */ - audioUrl: string + audioUrl: string; /** Current playback position in seconds */ - position: number + position: number; /** Total duration in seconds */ - duration: number + duration: number; /** Whether audio is currently playing */ - isPlaying: boolean + isPlaying: boolean; /** Number of data points / columns */ - resolution?: number + resolution?: number; /** Callback when user clicks to seek */ - onSeek?: (seconds: number) => void -} + onSeek?: (seconds: number) => void; +}; /** Block characters for waveform amplitude levels */ -const BARS = [".", "-", "~", "=", "#"] +const BARS = [".", "-", "~", "=", "#"]; export function MergedWaveform(props: MergedWaveformProps) { - const resolution = () => props.resolution ?? 64 + const resolution = () => props.resolution ?? 64; // Waveform data — start with sync/cached, kick off async extraction - const [data, setData] = createSignal( - getWaveformDataSync(props.audioUrl, resolution()), - ) + const [data, setData] = createSignal(); // When the audioUrl changes, attempt async extraction for real data createEffect(() => { - const url = props.audioUrl - const res = resolution() - if (!url) return + const url = props.audioUrl; + const res = resolution(); + if (!url) return; - let cancelled = false + let cancelled = false; getWaveformData(url, res).then((result) => { - if (!cancelled) setData(result) - }) - onCleanup(() => { cancelled = true }) - }) + if (!cancelled) setData(result); + }); + onCleanup(() => { + cancelled = true; + }); + }); const playedRatio = () => - props.duration <= 0 ? 0 : Math.min(1, props.position / props.duration) + props.duration <= 0 ? 0 : Math.min(1, props.position / props.duration); const renderLine = () => { - const d = data() - const played = Math.floor(d.length * playedRatio()) - const playedColor = props.isPlaying ? "#6fa8ff" : "#7d8590" - const futureColor = "#3b4252" + const d = data(); + if (!d) { + console.error("no data recieved"); + return; + } + const played = Math.floor(d.length * playedRatio()); + const playedColor = props.isPlaying ? "#6fa8ff" : "#7d8590"; + const futureColor = "#3b4252"; const playedChars = d .slice(0, played) .map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))]) - .join("") + .join(""); const futureChars = d .slice(played) .map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))]) - .join("") + .join(""); return ( {playedChars || " "} {futureChars || " "} - ) - } + ); + }; const handleClick = (event: { x: number }) => { - const d = data() - const ratio = d.length === 0 ? 0 : event.x / d.length + const d = data(); + const ratio = !d || d.length === 0 ? 0 : event.x / d.length; const next = Math.max( 0, Math.min(props.duration, Math.round(props.duration * ratio)), - ) - props.onSeek?.(next) - } + ); + props.onSeek?.(next); + }; return ( {renderLine()} - ) + ); } diff --git a/src/utils/audio-waveform.ts b/src/utils/audio-waveform.ts index 52a63fb..9a0e349 100644 --- a/src/utils/audio-waveform.ts +++ b/src/utils/audio-waveform.ts @@ -2,148 +2,102 @@ * Audio waveform analysis for PodTUI * * Extracts amplitude data from audio files using ffmpeg (when available) - * or generates procedural waveform data as a fallback. Results are cached - * in-memory keyed by audio URL. + * Results are cache in-memory keyed by audio URL. */ /** Number of amplitude data points to generate */ -const DEFAULT_RESOLUTION = 128 +const DEFAULT_RESOLUTION = 128; /** In-memory cache: audioUrl -> amplitude data */ -const waveformCache = new Map() +const waveformCache = new Map(); /** * Try to extract real waveform data from an audio URL using ffmpeg. * Returns null if ffmpeg is not available or the extraction fails. */ -async function extractWithFfmpeg(audioUrl: string, resolution: number): Promise { +async function extractWithFfmpeg( + audioUrl: string, + resolution: number, +): Promise { try { - if (!Bun.which("ffmpeg")) return null + if (!Bun.which("ffmpeg")) return null; // Use ffmpeg to output raw PCM samples, then downsample to `resolution` points. // -t 300: read at most 5 minutes (enough data to fill the waveform) const proc = Bun.spawn( [ "ffmpeg", - "-i", audioUrl, - "-t", "300", - "-ac", "1", // mono - "-ar", "8000", // low sample rate to keep data small - "-f", "s16le", // raw signed 16-bit PCM - "-v", "quiet", + "-i", + audioUrl, + "-t", + "300", + "-ac", + "1", // mono + "-ar", + "8000", // low sample rate to keep data small + "-f", + "s16le", // raw signed 16-bit PCM + "-v", + "quiet", "-", ], { stdout: "pipe", stderr: "ignore" }, - ) + ); - const output = await new Response(proc.stdout).arrayBuffer() - await proc.exited + const output = await new Response(proc.stdout).arrayBuffer(); + await proc.exited; - if (output.byteLength === 0) return null + if (output.byteLength === 0) return null; - const samples = new Int16Array(output) - if (samples.length === 0) return null + const samples = new Int16Array(output); + if (samples.length === 0) return null; // Downsample to `resolution` buckets by taking the max absolute amplitude // in each bucket. - const bucketSize = Math.max(1, Math.floor(samples.length / resolution)) - const data: number[] = [] + const bucketSize = Math.max(1, Math.floor(samples.length / resolution)); + const data: number[] = []; for (let i = 0; i < resolution; i++) { - const start = i * bucketSize - const end = Math.min(start + bucketSize, samples.length) - let maxAbs = 0 + const start = i * bucketSize; + const end = Math.min(start + bucketSize, samples.length); + let maxAbs = 0; for (let j = start; j < end; j++) { - const abs = Math.abs(samples[j]) - if (abs > maxAbs) maxAbs = abs + const abs = Math.abs(samples[j]); + if (abs > maxAbs) maxAbs = abs; } // Normalise to 0-1 - data.push(Number((maxAbs / 32768).toFixed(3))) + data.push(Number((maxAbs / 32768).toFixed(3))); } - return data + return data; } catch { - return null + return null; } } -/** - * Generate a procedural (fake) waveform that looks plausible. - * Uses a combination of sine waves with different frequencies to - * simulate varying audio energy. - */ -function generateProcedural(resolution: number, seed: number): number[] { - const data: number[] = [] - for (let i = 0; i < resolution; i++) { - const t = i + seed - const value = - 0.15 + - Math.abs(Math.sin(t / 3.7)) * 0.35 + - Math.abs(Math.sin(t / 7.3)) * 0.25 + - Math.abs(Math.sin(t / 13.1)) * 0.15 + - (Math.random() * 0.1) - data.push(Number(Math.min(1, value).toFixed(3))) - } - return data -} - -/** - * Simple numeric hash of a string, used to seed procedural generation - * so the same URL always produces the same waveform. - */ -function hashString(s: string): number { - let h = 0 - for (let i = 0; i < s.length; i++) { - h = (h * 31 + s.charCodeAt(i)) | 0 - } - return Math.abs(h) -} - /** * Get waveform data for an audio URL. * * Returns cached data if available, otherwise attempts ffmpeg extraction - * and falls back to procedural generation. */ export async function getWaveformData( audioUrl: string, resolution: number = DEFAULT_RESOLUTION, ): Promise { - const cacheKey = `${audioUrl}:${resolution}` - const cached = waveformCache.get(cacheKey) - if (cached) return cached + const cacheKey = `${audioUrl}:${resolution}`; + const cached = waveformCache.get(cacheKey); + if (cached) return cached; - // Try real extraction first - const real = await extractWithFfmpeg(audioUrl, resolution) + const real = await extractWithFfmpeg(audioUrl, resolution); if (real) { - waveformCache.set(cacheKey, real) - return real + waveformCache.set(cacheKey, real); + return real; + } else { + console.error("generation failure"); + return []; } - - // Fall back to procedural - const procedural = generateProcedural(resolution, hashString(audioUrl)) - waveformCache.set(cacheKey, procedural) - return procedural } -/** - * Synchronous fallback: get a waveform immediately (from cache or procedural). - * Use this when you need data without waiting for async extraction. - */ -export function getWaveformDataSync( - audioUrl: string, - resolution: number = DEFAULT_RESOLUTION, -): number[] { - const cacheKey = `${audioUrl}:${resolution}` - const cached = waveformCache.get(cacheKey) - if (cached) return cached - - const procedural = generateProcedural(resolution, hashString(audioUrl)) - waveformCache.set(cacheKey, procedural) - return procedural -} - -/** Clear the waveform cache (for memory management) */ export function clearWaveformCache(): void { - waveformCache.clear() + waveformCache.clear(); }