basic clean

This commit is contained in:
2026-02-06 09:57:33 -05:00
parent 0e4f47323f
commit 63ded34a6b
2 changed files with 87 additions and 129 deletions

View File

@@ -6,88 +6,92 @@
* separate progress bar. Click-to-seek is supported. * separate progress bar. Click-to-seek is supported.
*/ */
import { createSignal, createEffect, onCleanup } from "solid-js" import { createSignal, createEffect, onCleanup } from "solid-js";
import { getWaveformData, getWaveformDataSync } from "../utils/audio-waveform" import { getWaveformData } from "../utils/audio-waveform";
type MergedWaveformProps = { type MergedWaveformProps = {
/** Audio URL — used to generate or retrieve waveform data */ /** Audio URL — used to generate or retrieve waveform data */
audioUrl: string audioUrl: string;
/** Current playback position in seconds */ /** Current playback position in seconds */
position: number position: number;
/** Total duration in seconds */ /** Total duration in seconds */
duration: number duration: number;
/** Whether audio is currently playing */ /** Whether audio is currently playing */
isPlaying: boolean isPlaying: boolean;
/** Number of data points / columns */ /** Number of data points / columns */
resolution?: number resolution?: number;
/** Callback when user clicks to seek */ /** Callback when user clicks to seek */
onSeek?: (seconds: number) => void onSeek?: (seconds: number) => void;
} };
/** Block characters for waveform amplitude levels */ /** Block characters for waveform amplitude levels */
const BARS = [".", "-", "~", "=", "#"] const BARS = [".", "-", "~", "=", "#"];
export function MergedWaveform(props: MergedWaveformProps) { 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 // Waveform data — start with sync/cached, kick off async extraction
const [data, setData] = createSignal<number[]>( const [data, setData] = createSignal<number[]>();
getWaveformDataSync(props.audioUrl, resolution()),
)
// When the audioUrl changes, attempt async extraction for real data // When the audioUrl changes, attempt async extraction for real data
createEffect(() => { createEffect(() => {
const url = props.audioUrl const url = props.audioUrl;
const res = resolution() const res = resolution();
if (!url) return if (!url) return;
let cancelled = false let cancelled = false;
getWaveformData(url, res).then((result) => { getWaveformData(url, res).then((result) => {
if (!cancelled) setData(result) if (!cancelled) setData(result);
}) });
onCleanup(() => { cancelled = true }) onCleanup(() => {
}) cancelled = true;
});
});
const playedRatio = () => 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 renderLine = () => {
const d = data() const d = data();
const played = Math.floor(d.length * playedRatio()) if (!d) {
const playedColor = props.isPlaying ? "#6fa8ff" : "#7d8590" console.error("no data recieved");
const futureColor = "#3b4252" return;
}
const played = Math.floor(d.length * playedRatio());
const playedColor = props.isPlaying ? "#6fa8ff" : "#7d8590";
const futureColor = "#3b4252";
const playedChars = d const playedChars = d
.slice(0, played) .slice(0, played)
.map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))]) .map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))])
.join("") .join("");
const futureChars = d const futureChars = d
.slice(played) .slice(played)
.map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))]) .map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))])
.join("") .join("");
return ( return (
<box flexDirection="row" gap={0}> <box flexDirection="row" gap={0}>
<text fg={playedColor}>{playedChars || " "}</text> <text fg={playedColor}>{playedChars || " "}</text>
<text fg={futureColor}>{futureChars || " "}</text> <text fg={futureColor}>{futureChars || " "}</text>
</box> </box>
) );
} };
const handleClick = (event: { x: number }) => { const handleClick = (event: { x: number }) => {
const d = data() const d = data();
const ratio = d.length === 0 ? 0 : event.x / d.length const ratio = !d || d.length === 0 ? 0 : event.x / d.length;
const next = Math.max( const next = Math.max(
0, 0,
Math.min(props.duration, Math.round(props.duration * ratio)), Math.min(props.duration, Math.round(props.duration * ratio)),
) );
props.onSeek?.(next) props.onSeek?.(next);
} };
return ( return (
<box border padding={1} onMouseDown={handleClick}> <box border padding={1} onMouseDown={handleClick}>
{renderLine()} {renderLine()}
</box> </box>
) );
} }

View File

@@ -2,148 +2,102 @@
* Audio waveform analysis for PodTUI * Audio waveform analysis for PodTUI
* *
* Extracts amplitude data from audio files using ffmpeg (when available) * Extracts amplitude data from audio files using ffmpeg (when available)
* or generates procedural waveform data as a fallback. Results are cached * Results are cache in-memory keyed by audio URL.
* in-memory keyed by audio URL.
*/ */
/** Number of amplitude data points to generate */ /** Number of amplitude data points to generate */
const DEFAULT_RESOLUTION = 128 const DEFAULT_RESOLUTION = 128;
/** In-memory cache: audioUrl -> amplitude data */ /** In-memory cache: audioUrl -> amplitude data */
const waveformCache = new Map<string, number[]>() const waveformCache = new Map<string, number[]>();
/** /**
* Try to extract real waveform data from an audio URL using ffmpeg. * Try to extract real waveform data from an audio URL using ffmpeg.
* Returns null if ffmpeg is not available or the extraction fails. * Returns null if ffmpeg is not available or the extraction fails.
*/ */
async function extractWithFfmpeg(audioUrl: string, resolution: number): Promise<number[] | null> { async function extractWithFfmpeg(
audioUrl: string,
resolution: number,
): Promise<number[] | null> {
try { 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. // 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) // -t 300: read at most 5 minutes (enough data to fill the waveform)
const proc = Bun.spawn( const proc = Bun.spawn(
[ [
"ffmpeg", "ffmpeg",
"-i", audioUrl, "-i",
"-t", "300", audioUrl,
"-ac", "1", // mono "-t",
"-ar", "8000", // low sample rate to keep data small "300",
"-f", "s16le", // raw signed 16-bit PCM "-ac",
"-v", "quiet", "1", // mono
"-ar",
"8000", // low sample rate to keep data small
"-f",
"s16le", // raw signed 16-bit PCM
"-v",
"quiet",
"-", "-",
], ],
{ stdout: "pipe", stderr: "ignore" }, { stdout: "pipe", stderr: "ignore" },
) );
const output = await new Response(proc.stdout).arrayBuffer() const output = await new Response(proc.stdout).arrayBuffer();
await proc.exited await proc.exited;
if (output.byteLength === 0) return null if (output.byteLength === 0) return null;
const samples = new Int16Array(output) const samples = new Int16Array(output);
if (samples.length === 0) return null if (samples.length === 0) return null;
// Downsample to `resolution` buckets by taking the max absolute amplitude // Downsample to `resolution` buckets by taking the max absolute amplitude
// in each bucket. // in each bucket.
const bucketSize = Math.max(1, Math.floor(samples.length / resolution)) const bucketSize = Math.max(1, Math.floor(samples.length / resolution));
const data: number[] = [] const data: number[] = [];
for (let i = 0; i < resolution; i++) { for (let i = 0; i < resolution; i++) {
const start = i * bucketSize const start = i * bucketSize;
const end = Math.min(start + bucketSize, samples.length) const end = Math.min(start + bucketSize, samples.length);
let maxAbs = 0 let maxAbs = 0;
for (let j = start; j < end; j++) { for (let j = start; j < end; j++) {
const abs = Math.abs(samples[j]) const abs = Math.abs(samples[j]);
if (abs > maxAbs) maxAbs = abs if (abs > maxAbs) maxAbs = abs;
} }
// Normalise to 0-1 // Normalise to 0-1
data.push(Number((maxAbs / 32768).toFixed(3))) data.push(Number((maxAbs / 32768).toFixed(3)));
} }
return data return data;
} catch { } 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. * Get waveform data for an audio URL.
* *
* Returns cached data if available, otherwise attempts ffmpeg extraction * Returns cached data if available, otherwise attempts ffmpeg extraction
* and falls back to procedural generation.
*/ */
export async function getWaveformData( export async function getWaveformData(
audioUrl: string, audioUrl: string,
resolution: number = DEFAULT_RESOLUTION, resolution: number = DEFAULT_RESOLUTION,
): Promise<number[]> { ): Promise<number[]> {
const cacheKey = `${audioUrl}:${resolution}` const cacheKey = `${audioUrl}:${resolution}`;
const cached = waveformCache.get(cacheKey) const cached = waveformCache.get(cacheKey);
if (cached) return cached if (cached) return cached;
// Try real extraction first const real = await extractWithFfmpeg(audioUrl, resolution);
const real = await extractWithFfmpeg(audioUrl, resolution)
if (real) { if (real) {
waveformCache.set(cacheKey, real) waveformCache.set(cacheKey, real);
return 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 { export function clearWaveformCache(): void {
waveformCache.clear() waveformCache.clear();
} }