basic clean
This commit is contained in:
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user