better visualizer
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
|
||||
import { ensureConfigDir, getConfigFilePath } from "./config-dir"
|
||||
import { backupConfigFile } from "./config-backup"
|
||||
import type { AppState, AppSettings, UserPreferences, ThemeColors } from "../types/settings"
|
||||
import type { AppState, AppSettings, UserPreferences, ThemeColors, VisualizerSettings } from "../types/settings"
|
||||
import { DEFAULT_THEME } from "../constants/themes"
|
||||
|
||||
const APP_STATE_FILE = "app-state.json"
|
||||
@@ -18,11 +18,20 @@ const LEGACY_PROGRESS_KEY = "podtui_progress"
|
||||
|
||||
// --- Defaults ---
|
||||
|
||||
const defaultVisualizerSettings: VisualizerSettings = {
|
||||
bars: 32,
|
||||
sensitivity: 1,
|
||||
noiseReduction: 0.77,
|
||||
lowCutOff: 50,
|
||||
highCutOff: 10000,
|
||||
}
|
||||
|
||||
const defaultSettings: AppSettings = {
|
||||
theme: "system",
|
||||
fontSize: 14,
|
||||
playbackSpeed: 1,
|
||||
downloadPath: "",
|
||||
visualizer: defaultVisualizerSettings,
|
||||
}
|
||||
|
||||
const defaultPreferences: UserPreferences = {
|
||||
|
||||
191
src/utils/audio-stream-reader.ts
Normal file
191
src/utils/audio-stream-reader.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Real-time audio stream reader for visualization.
|
||||
*
|
||||
* Spawns a separate ffmpeg process that decodes the same audio URL
|
||||
* the player is using and outputs raw PCM data (signed 16-bit LE, mono,
|
||||
* 44100 Hz) to a pipe. The reader accumulates samples in a ring buffer
|
||||
* and provides them to the caller on demand.
|
||||
*
|
||||
* This is independent from the actual playback backend — it's a
|
||||
* read-only "tap" on the audio for FFT analysis purposes.
|
||||
*/
|
||||
|
||||
/** PCM output format constants */
|
||||
const SAMPLE_RATE = 44100
|
||||
const CHANNELS = 1
|
||||
const BYTES_PER_SAMPLE = 2 // s16le
|
||||
|
||||
/** How many samples to buffer (≈1 second) */
|
||||
const RING_BUFFER_SAMPLES = SAMPLE_RATE
|
||||
|
||||
export interface AudioStreamReaderOptions {
|
||||
/** Audio URL or file path to decode */
|
||||
url: string
|
||||
/** Start position in seconds (for seeking sync) */
|
||||
startPosition?: number
|
||||
/** Sample rate (default: 44100) */
|
||||
sampleRate?: number
|
||||
}
|
||||
|
||||
export class AudioStreamReader {
|
||||
private proc: ReturnType<typeof Bun.spawn> | null = null
|
||||
private ringBuffer: Float64Array
|
||||
private writePos = 0
|
||||
private totalSamplesWritten = 0
|
||||
private _running = false
|
||||
private readPromise: Promise<void> | null = null
|
||||
private url: string
|
||||
private sampleRate: number
|
||||
|
||||
constructor(options: AudioStreamReaderOptions) {
|
||||
this.url = options.url
|
||||
this.sampleRate = options.sampleRate ?? SAMPLE_RATE
|
||||
this.ringBuffer = new Float64Array(RING_BUFFER_SAMPLES)
|
||||
}
|
||||
|
||||
/** Whether the reader is actively reading samples. */
|
||||
get running(): boolean {
|
||||
return this._running
|
||||
}
|
||||
|
||||
/** Total number of samples written since start(). */
|
||||
get samplesWritten(): number {
|
||||
return this.totalSamplesWritten
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the ffmpeg decode process and begin reading PCM data.
|
||||
* @param startPosition Seek position in seconds (default: 0).
|
||||
*/
|
||||
start(startPosition = 0): void {
|
||||
if (this._running) return
|
||||
if (!Bun.which("ffmpeg")) {
|
||||
throw new Error("ffmpeg not found — required for audio visualization")
|
||||
}
|
||||
|
||||
const args = [
|
||||
"ffmpeg",
|
||||
"-loglevel", "quiet",
|
||||
]
|
||||
|
||||
// Seek before input for network efficiency
|
||||
if (startPosition > 0) {
|
||||
args.push("-ss", String(startPosition))
|
||||
}
|
||||
|
||||
args.push(
|
||||
"-i", this.url,
|
||||
"-ac", String(CHANNELS),
|
||||
"-ar", String(this.sampleRate),
|
||||
"-f", "s16le", // raw signed 16-bit little-endian PCM
|
||||
"-acodec", "pcm_s16le",
|
||||
"-", // output to stdout
|
||||
)
|
||||
|
||||
this.proc = Bun.spawn(args, {
|
||||
stdout: "pipe",
|
||||
stderr: "ignore",
|
||||
stdin: "ignore",
|
||||
})
|
||||
|
||||
this._running = true
|
||||
this.writePos = 0
|
||||
this.totalSamplesWritten = 0
|
||||
|
||||
// Start async reading loop
|
||||
this.readPromise = this.readLoop()
|
||||
|
||||
// Detect process exit
|
||||
this.proc.exited.then(() => {
|
||||
this._running = false
|
||||
}).catch(() => {
|
||||
this._running = false
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Read available samples into the provided buffer.
|
||||
* Returns the number of samples actually copied.
|
||||
*
|
||||
* @param out - Float64Array to fill with samples (scaled ~±32768 for cavacore).
|
||||
* @returns Number of samples written to `out`.
|
||||
*/
|
||||
read(out: Float64Array): number {
|
||||
const available = Math.min(out.length, this.totalSamplesWritten, this.ringBuffer.length)
|
||||
if (available <= 0) return 0
|
||||
|
||||
// Read the most recent `available` samples from the ring buffer
|
||||
const readStart = (this.writePos - available + this.ringBuffer.length) % this.ringBuffer.length
|
||||
|
||||
if (readStart + available <= this.ringBuffer.length) {
|
||||
// Contiguous read
|
||||
out.set(this.ringBuffer.subarray(readStart, readStart + available))
|
||||
} else {
|
||||
// Wraps around
|
||||
const firstChunk = this.ringBuffer.length - readStart
|
||||
out.set(this.ringBuffer.subarray(readStart, this.ringBuffer.length))
|
||||
out.set(this.ringBuffer.subarray(0, available - firstChunk), firstChunk)
|
||||
}
|
||||
|
||||
return available
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the ffmpeg process and clean up.
|
||||
* Safe to call multiple times.
|
||||
*/
|
||||
stop(): void {
|
||||
this._running = false
|
||||
if (this.proc) {
|
||||
try { this.proc.kill() } catch { /* ignore */ }
|
||||
this.proc = null
|
||||
}
|
||||
this.writePos = 0
|
||||
this.totalSamplesWritten = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart the reader at a new position (e.g. after a seek).
|
||||
*/
|
||||
restart(startPosition = 0): void {
|
||||
this.stop()
|
||||
this.start(startPosition)
|
||||
}
|
||||
|
||||
/** Internal: continuously reads stdout from ffmpeg and fills the ring buffer. */
|
||||
private async readLoop(): Promise<void> {
|
||||
const stdout = this.proc?.stdout
|
||||
if (!stdout || typeof stdout === "number") return
|
||||
|
||||
const reader = (stdout as ReadableStream<Uint8Array>).getReader()
|
||||
try {
|
||||
while (this._running) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done || !this._running) break
|
||||
if (!value || value.byteLength === 0) continue
|
||||
|
||||
// Convert raw s16le bytes → Float64Array scaled for cavacore
|
||||
// Ensure we have an even number of bytes (each sample = 2 bytes)
|
||||
const sampleCount = Math.floor(value.byteLength / BYTES_PER_SAMPLE)
|
||||
if (sampleCount === 0) continue
|
||||
|
||||
const int16View = new Int16Array(
|
||||
value.buffer,
|
||||
value.byteOffset,
|
||||
sampleCount,
|
||||
)
|
||||
|
||||
// Write samples into ring buffer (as doubles, preserving int16 scale)
|
||||
for (let i = 0; i < sampleCount; i++) {
|
||||
this.ringBuffer[this.writePos] = int16View[i] // ±32768 range
|
||||
this.writePos = (this.writePos + 1) % this.ringBuffer.length
|
||||
this.totalSamplesWritten++
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Stream ended or process killed — expected during stop()
|
||||
} finally {
|
||||
try { reader.releaseLock() } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
230
src/utils/cavacore.ts
Normal file
230
src/utils/cavacore.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* TypeScript FFI bindings for libcavacore.
|
||||
*
|
||||
* Wraps cava's frequency-analysis engine (cavacore) via Bun's dlopen.
|
||||
* The precompiled shared library ships in src/native/ (dev) and dist/ (prod)
|
||||
* with fftw3 statically linked — zero native dependencies for end users.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* const cava = loadCavaCore()
|
||||
* if (cava) {
|
||||
* cava.init({ bars: 32, sampleRate: 44100 })
|
||||
* const freqs = cava.execute(pcmSamples)
|
||||
* cava.destroy()
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { dlopen, FFIType, ptr } from "bun:ffi"
|
||||
import { existsSync } from "fs"
|
||||
import { join, dirname } from "path"
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CavaCoreConfig {
|
||||
/** Number of frequency bars (default: 32) */
|
||||
bars?: number
|
||||
/** Audio sample rate in Hz (default: 44100) */
|
||||
sampleRate?: number
|
||||
/** Number of audio channels (default: 1 = mono) */
|
||||
channels?: number
|
||||
/** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */
|
||||
autosens?: number
|
||||
/** Noise reduction factor 0.0–1.0 (default: 0.77) */
|
||||
noiseReduction?: number
|
||||
/** Low frequency cutoff in Hz (default: 50) */
|
||||
lowCutOff?: number
|
||||
/** High frequency cutoff in Hz (default: 10000) */
|
||||
highCutOff?: number
|
||||
}
|
||||
|
||||
const DEFAULTS: Required<CavaCoreConfig> = {
|
||||
bars: 32,
|
||||
sampleRate: 44100,
|
||||
channels: 1,
|
||||
autosens: 1,
|
||||
noiseReduction: 0.77,
|
||||
lowCutOff: 50,
|
||||
highCutOff: 10000,
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type CavaLib = { symbols: Record<string, (...args: any[]) => any>; close(): void }
|
||||
|
||||
// ── Library resolution ───────────────────────────────────────────────
|
||||
|
||||
function findLibrary(): string | null {
|
||||
const platform = process.platform
|
||||
const libName = platform === "darwin"
|
||||
? "libcavacore.dylib"
|
||||
: platform === "win32"
|
||||
? "cavacore.dll"
|
||||
: "libcavacore.so"
|
||||
|
||||
// Candidate paths, in priority order:
|
||||
// 1. src/native/ (development)
|
||||
// 2. Same directory as the running executable (dist bundle)
|
||||
// 3. dist/ relative to cwd
|
||||
const candidates = [
|
||||
join(import.meta.dir, "..", "native", libName),
|
||||
join(dirname(process.execPath), libName),
|
||||
join(process.cwd(), "dist", libName),
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) return candidate
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ── CavaCore class ───────────────────────────────────────────────────
|
||||
|
||||
export class CavaCore {
|
||||
private lib: CavaLib
|
||||
private plan: ReturnType<CavaLib["symbols"]["cava_init"]> | null = null
|
||||
private inputBuffer: Float64Array | null = null
|
||||
private outputBuffer: Float64Array | null = null
|
||||
private _bars = 0
|
||||
private _channels = 1
|
||||
private _destroyed = false
|
||||
|
||||
/** Use loadCavaCore() instead of constructing directly. */
|
||||
constructor(lib: CavaLib) {
|
||||
this.lib = lib
|
||||
}
|
||||
|
||||
/** Number of frequency bars configured. */
|
||||
get bars(): number {
|
||||
return this._bars
|
||||
}
|
||||
|
||||
/** Whether this instance has been initialized (and not yet destroyed). */
|
||||
get isReady(): boolean {
|
||||
return this.plan !== null && !this._destroyed
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the cavacore engine with the given configuration.
|
||||
* Must be called before execute(). Can be called again after destroy()
|
||||
* to reinitialize with different parameters.
|
||||
*/
|
||||
init(config: CavaCoreConfig = {}): void {
|
||||
if (this.plan) {
|
||||
this.destroy()
|
||||
}
|
||||
|
||||
const cfg = { ...DEFAULTS, ...config }
|
||||
this._bars = cfg.bars
|
||||
this._channels = cfg.channels
|
||||
|
||||
this.plan = this.lib.symbols.cava_init(
|
||||
cfg.bars,
|
||||
cfg.sampleRate,
|
||||
cfg.channels,
|
||||
cfg.autosens,
|
||||
cfg.noiseReduction,
|
||||
cfg.lowCutOff,
|
||||
cfg.highCutOff,
|
||||
)
|
||||
|
||||
if (!this.plan) {
|
||||
throw new Error("cava_init returned null — initialization failed")
|
||||
}
|
||||
|
||||
// Pre-allocate output buffer (bars * channels)
|
||||
this.outputBuffer = new Float64Array(cfg.bars * cfg.channels)
|
||||
this._destroyed = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed PCM samples into cavacore and get frequency bar values back.
|
||||
*
|
||||
* @param samples - Float64Array of PCM samples (scaled ~±32768).
|
||||
* The array length determines the number of samples processed.
|
||||
* @returns Float64Array of bar values (0.0–1.0 range, length = bars * channels).
|
||||
* Returns the same buffer reference each call (overwritten in place).
|
||||
*/
|
||||
execute(samples: Float64Array): Float64Array {
|
||||
if (!this.plan || !this.outputBuffer) {
|
||||
throw new Error("CavaCore not initialized — call init() first")
|
||||
}
|
||||
|
||||
// Reuse input buffer if same size, otherwise allocate new
|
||||
if (!this.inputBuffer || this.inputBuffer.length !== samples.length) {
|
||||
this.inputBuffer = new Float64Array(samples.length)
|
||||
}
|
||||
this.inputBuffer.set(samples)
|
||||
|
||||
this.lib.symbols.cava_execute(
|
||||
ptr(this.inputBuffer),
|
||||
samples.length,
|
||||
ptr(this.outputBuffer),
|
||||
this.plan,
|
||||
)
|
||||
|
||||
return this.outputBuffer
|
||||
}
|
||||
|
||||
/**
|
||||
* Release all native resources. Safe to call multiple times.
|
||||
* After calling destroy(), init() can be called again to reuse the instance.
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.plan && !this._destroyed) {
|
||||
this.lib.symbols.cava_destroy(this.plan)
|
||||
this.plan = null
|
||||
this._destroyed = true
|
||||
}
|
||||
this.inputBuffer = null
|
||||
this.outputBuffer = null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Factory ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Attempt to load the cavacore shared library and return a CavaCore instance.
|
||||
* Returns null if the library cannot be found — callers should fall back
|
||||
* to the static waveform display.
|
||||
*/
|
||||
export function loadCavaCore(): CavaCore | null {
|
||||
try {
|
||||
const libPath = findLibrary()
|
||||
if (!libPath) return null
|
||||
|
||||
const lib = dlopen(libPath, {
|
||||
cava_init: {
|
||||
args: [
|
||||
FFIType.i32, // bars
|
||||
FFIType.u32, // rate
|
||||
FFIType.i32, // channels
|
||||
FFIType.i32, // autosens
|
||||
FFIType.double, // noise_reduction
|
||||
FFIType.i32, // low_cut_off
|
||||
FFIType.i32, // high_cut_off
|
||||
],
|
||||
returns: FFIType.ptr,
|
||||
},
|
||||
cava_execute: {
|
||||
args: [
|
||||
FFIType.ptr, // cava_in (double*)
|
||||
FFIType.i32, // samples
|
||||
FFIType.ptr, // cava_out (double*)
|
||||
FFIType.ptr, // plan
|
||||
],
|
||||
returns: FFIType.void,
|
||||
},
|
||||
cava_destroy: {
|
||||
args: [FFIType.ptr], // plan
|
||||
returns: FFIType.void,
|
||||
},
|
||||
})
|
||||
|
||||
return new CavaCore(lib as CavaLib)
|
||||
} catch {
|
||||
// Library load failed — missing dylib, wrong arch, etc.
|
||||
return null
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user