Fluent FFmpeg wrapper for TypeScript runtimes, v0.5.4
Offers a fluent, chainable API for media/audio/video processing with FFmpeg, supporting streams, crossfade, audio effects, timeouts, and progress tracking.
- TypeScript-first: typed, chainable, and modern
- Cross-runtime: Node.js, Bun, Deno, and Browser (via
@ffmpeg/ffmpeg) - Web Streams: native
ReadableStream/WritableStreamwith Node.js stream compatibility - Audio processing: built-in EQ, volume, compression controls
- Human-friendly: good defaults and self-describing API
- Advanced features: crossfade, format conversion, codec control, kill/timeout, progress
Powered by FFmpeg.
Works in Node.js, Bun, Deno, and the browser. Streaming does not require files on disk.
# From npm (when published)
npm install fluent-streamer
# From GitHub (latest)
npm install github:dirold2/fluent-streamer
# Using Yarn
yarn add github:dirold2/fluent-streamer
# Using PNPM
pnpm install github:dirold2/fluent-streamer
# Using Bun
bun install github:dirold2/fluent-streamerBrowser (optional): install FFmpeg WASM bindings alongside the package:
npm install @ffmpeg/ffmpeg @ffmpeg/coreThe browser runner is selected automatically when window is defined.
It buffers the complete input and runs FFmpeg WASM before exposing output; it does not support live/infinite input streams or real-time output.
| 0.4.x | 0.5.x |
|---|---|
import FluentStream from "fluent-streamer" |
import { FluentStream } from "fluent-streamer" |
const { done } = fs.run() |
const { done } = await fs.run() |
output: Readable (Node.js) |
output: ReadableStream<Uint8Array> |
.output(fs.createWriteStream(...)) |
.output("file.wav") or pipe output from .run() |
HTTP(S) media URLs are hidden as [redacted-url] in FFmpeg command logs, abnormal-exit stderr tails and process-exit errors by default. The whole URL is hidden because credentials/signatures can appear in the path as well as the query. This does not change the URL sent to FFmpeg.
For temporary debugging, explicitly opt in before starting your app:
FLUENT_STREAMER_LOG_URLS=1 bun ./src/main.tsOnly the exact value 1 enables full URLs. Command logs are sent to a supplied logger regardless of verbose; console fallback requires verbose: true. Full URLs may contain credentials or expiring access tokens; do not publish these logs. Raw hooks (onBeforeChildProcessSpawn, stderrLogHandler) remain unsanitized; custom logging in these hooks and other application logs must handle secrets separately.
Logger, LoggingOptions and LogLevel are public type exports. Pass your logger through logger?: Logger:
import { FluentStream, type Logger } from "fluent-streamer";
export function createPlayer(logger?: Logger) {
return new FluentStream({ logger });
}The external logger receives messages independently of verbose and controls its own filtering. FFmpeg commands/configuration, bitrate, playback statistics, blob validation and live effect changes are debug, not info. Internal event levels are centralized in src/Core/processor/logging.ts (LOG_LEVELS). Warnings and errors retain their levels. Without a logger, non-error console output requires verbose: true.
import { FluentStream } from "fluent-streamer";
// Basic conversion example
const fs = new FluentStream()
.input("input.wav")
.audioCodec("aac")
.audioBitrate("192k")
.output("output.m4a");
const { done } = await fs.run();
await done;
console.log("Conversion finished.");import { FluentStream } from "fluent-streamer";
import fs from "node:fs";
// Node.js streams are accepted as input (adapted to Web Streams internally)
const input = fs.createReadStream("track.mp3");
const f = new FluentStream()
.input(input)
.format("wav")
.output("new.wav");
await f.run();
console.log("Done streaming!");For piped output, use pipe:1 and consume the returned stream:
const { output, done } = await new FluentStream()
.input(input)
.format("wav")
.output("pipe:1")
.run();
// Pipe Web Stream to a destination (Node.js 18+)
import { Writable } from "node:stream";
const file = fs.createWriteStream("new.wav");
await output.pipeTo(Writable.toWeb(file));
await done;import { FluentStream } from "fluent-streamer";
await new FluentStream()
.input("a.mp3")
.input("b.mp3")
.crossfadeAudio(2.5) // 2.5 seconds crossfade
.output("x.mp3")
.run();Crossfade prebuffers approximately 20 ms (at most 1024 frames) of the next source while continuing to output the old source at full gain. The fade frame counter starts only after this buffer is ready (or the next source reaches EOF). Old-decoder retirement runs in the background: transition completion means PCM generation has completed, not decoder cleanup. Session done and stop() still await every pending retirement and report cleanup failures. This removes cleanup waits from the audio path, but cannot eliminate later network underruns or gaps after the old source runs out.
await streamer.crossfade(url, time = 3) requests an immediate transition to another file/URL in an active mixed playback session; it does not wait for the current track's end. The duration is in seconds (finite 0…3600); 0 switches without a fade. The promise resolves after PCM transition generation, not after decoder retirement or the incoming track finishes. You can request another transition after awaiting the previous one; concurrent transitions reject. result.crossfade?.(url, time) exposes the same control in an opted-in session.
The optional third argument is LiveCrossfadeOptions: bassHandoff?: boolean defaults to false. Enable it manually with await streamer.crossfade(url, seconds, { bassHandoff: true, curve: 'equalPower', headroomDb: 3 }). It uses independent per-channel outgoing/incoming low-band histories to smoothly close the outgoing low band and open the incoming low band over 35–65% of the fade. The single-pole split (default bassCutoffHz: 180) has leakage, not a brick-wall cutoff: the rest of the spectrum still overlaps, and drums/percussive material can still clash. This does not guarantee a clean drum handoff or adjust pitch/playback speed.
Set enableCrossfade: true explicitly in the constructor or run() options before playback starts. This mode keeps the same result.output across tracks, and result.done tracks completion/cleanup of the whole mixed session, not each decoder. Live seek preserves the same public result, output, and session done in this mode (see Live seek). Unlike the offline .crossfadeAudio() filter above, this API accepts a new source during playback.
import { spawn } from "node:child_process";
import { Writable } from "node:stream";
import { FluentStream } from "fluent-streamer";
const streamer = new FluentStream({
enableCrossfade: true,
autoDrainOutput: false, // mixed playback always disables auto-drain
disableThrottling: true, // a clocked player supplies pacing/backpressure
});
const result = await streamer
.input("first.mp3") // exactly one file/URL input, passed as an FFmpeg argument
.audioCodec("pcm_s16le")
.outputOptions("-vn", "-f", "s16le", "-ar", "48000", "-ac", "2")
.output("pipe:1")
.run();
const player = spawn("ffplay", [
"-nodisp", "-autoexit", "-f", "s16le", "-ar", "48000", "-ac", "2", "-i", "pipe:0",
], { stdio: ["pipe", "ignore", "inherit"] });
player.on("error", (error) => console.error("Player failed:", error));
// Start the consumer BEFORE awaiting any transition; do not await playback here.
const playback = result.output.pipeTo(Writable.toWeb(player.stdin));
try {
// In an application, request this from a user action/timer BEFORE current EOF.
await streamer.crossfade("second.mp3", 3);
await streamer.crossfade("third.mp3", 0); // repeat after await; same output
// Only await session completion once no more transitions are wanted.
await Promise.all([playback, result.done]);
} finally {
await result.stop();
player.kill();
}Requirements and limits:
- Use exactly one
.input(fileOrURL)represented by-iin the FFmpeg arguments, one stdout outputpipe:1, and no complex filters or extra inputs. Raw stream, pipe, and blob inputs are unsupported. - Explicitly configure
pcm_s16le,-f s16le,-ar 48000, and-ac 2as above. If overridingaudioProcessorOptions.sampleRate/channels, explicitly match-ar/-acand the consumer format. Built-in DSP is optional; enableuseAudioProcessor: truefor karaoke/EQ and other effects. - Keep the consumer pipeline flowing before and throughout
await crossfade()andawait seek(). Without consumption, backpressure stalls the fade and its promise (even a zero-duration switch needs output demand). Auto-drain is disabled in this mode. Do not wait forresult.donebefore requesting the next track: by then the session has ended. - Trigger transitions before EOF yourself. This is not an automatic track queue, DJ beat matching, or end-of-track scheduler. An input reaching EOF during a fade contributes silence for the remaining fade frames.
- Two FFmpeg decoders overlap during transitions, increasing CPU use and potentially simultaneous network traffic. Decoder startup and URL warmup can delay audio; network gapless playback is not guaranteed.
- The browser/WASM runner buffers input/output rather than streaming live audio; it is not a live-crossfade playback backend.
Prepare the next queue entry early, while the live-crossfade consumer above keeps reading the same result.output. prepareAutomix() is an instance method on FluentStream, not a run-result method; it requires the same opted-in PCM session and replayable file/URL inputs.
import {
analyzePcm, analyzePcmAsync, analyzeTrack, planAutomix,
type DjAnalysis, type DjAnalysisOptions, type PcmAnalysisOptions, type AutomixPlan,
type PrepareAutomixOptions, type PreparedAutomix,
type AutomixDiagnostic, type AutomixFallbackReason,
} from "fluent-streamer";
// Application queue handler: nextUrl and optional incomingDurationMs come from
// queue metadata; currentPositionMs() returns the current source position in ms.
const preparationAbort = new AbortController();
const prepared = await streamer.prepareAutomix(nextUrl, {
outgoingRemainingMs: currentDurationMs - currentPositionMs(), // sample NOW
incomingDurationMs, // optional; omit if unknown
fallbackSeconds: 8,
onDiagnostic: (diagnostic: AutomixDiagnostic) => {
console.log("Automix decision:", diagnostic);
},
signal: preparationAbort.signal,
});
// Old audio continues during analysis/prebuffering and while prepared is ready.
// If the queue entry is replaced before start:
if (nextUrl !== queue.nextUrl) {
await prepared.cancel();
} else {
try {
await prepared.start(); // arms the PCM schedule; resolves after the overlap
} catch (error) {
await prepared.cancel().catch(() => {});
console.error("Automix did not complete:", error); // reassess live queue/timing
}
}
// A replacement while prepareAutomix is still pending must call
// preparationAbort.abort(), handle its rejection, then prepare the new entry.outgoingRemainingMsis the actual remaining duration sampled at the call, not the full track duration or a future timer delay. Preparation subtracts PCM progress during analysis. Keep consuming output throughout preparation andstart(); do not await sessiondonefirst.- Preparation analyzes both sources (unless supplied as
outgoingAnalysis/incomingAnalysis), plans a cue, and prebuffers the incoming decoder without fading. It reserves the transition: seek, crossfade, and another preparation reject while preparing/ready and through the started transition. Start or cancel promptly.cancel()is idempotent before start; after start it rejects and cannot undo the transition. - There is no automatic start at EOF, and preparation does not hold EOF open. Call
start()while playback is still active; nosetTimeoutis needed. It schedules against consumed/generated PCM frames, not audible device time. Late arming can move a quantized start to a later beat; if the overlap no longer fits with the reserved one-second tail,start()cancels and rejects as stale rather than silently changing the decoded cue. Handle preparation/start rejection and reassess remaining time before retrying. - Decoder configuration and effects are snapshotted during preparation. Later setters affect only the active decoder until the prepared track becomes active. Cached analyses must describe the source from its original beginning, not the post-seek excerpt.
plan.startMs,cueMs, anddropMsare source-relative absolute milliseconds; the session translates its seek origin to mixer frames.plan.secondsis seconds, not milliseconds. Subsequent seeks remain absolute in the incoming track.
Analysis and planning. Use await analyzePcmAsync(pcm: Int16Array, sampleRate, options?: PcmAnalysisOptions) for CPU analysis during playback. It runs the shared analyzer in a dedicated worker and copies the analyzed input rather than transferring/detaching the caller's buffer. PcmAnalysisOptions has signal?: AbortSignal and timeout?: number: the default 25,000 ms deadline includes worker startup and the whole worker job. Timeout must be finite and positive. Abort/timeout rejects and terminates the worker; each job also terminates its worker before settling normally. analyzePcm(pcm, sampleRate) remains explicitly synchronous for deliberate offline use: it blocks the calling/main thread and is not a playback-safe substitute. Both APIs require a finite positive sample rate.
await analyzeTrack(fileOrURL, { ffmpegPath?, headers?, timeout?, signal?, startSeconds? }) decodes through the runtime-selected Processor/runner, then automatically runs CPU analysis in a worker. prepareAutomix() automatically uses this path for each uncached source; callers do not need to opt in. Worker startup/execution errors reject: there is no synchronous fallback. Track analysis requires available FFmpeg (or the runner's FFmpeg backend), uses analysis runner defaults rather than all playback/run options, and does not apply playback effects. Its default 25-second total deadline covers decoder startup, decoding, worker startup, and analysis, not separate 25-second budgets per phase. Abort/timeout stops decoding and terminates an active analysis worker. Decoded output is capped at 720,000 bytes: 90 seconds of 4 kHz mono s16le. This is not a network download-size guarantee.
Worker deployment. Node uses node:worker_threads; compiled packages resolve the separate dist/Audio/analysis/DjAnalysisWorker.js entry and its DjAnalysisCore.js dependency. Bundlers/deployments must preserve these worker/core assets and their relative URLs beside the compiled client, or explicitly arrange equivalent worker asset resolution; bundling only the main entry is insufficient. Running the TypeScript source directly requires Node ≥22.18; older Node versions must use a compiled build (subject to the package's normal runtime requirements). The non-Node branch uses a module Web Worker, but Bun, Deno, and browser/Web Worker execution remain unverified, not a compatibility promise. Missing Worker support or unavailable worker assets cause rejection, never main-thread analysis.
All analysis entry points inspect at most 90 seconds from the beginning of the selected input window (the first 90 seconds by default) and abstain on less than 8 seconds or unreliable material. DjAnalysis contains bpm, beatPhaseMs, dropMs (nullable), their respective bpmConfidence, beatPhaseConfidence, dropConfidence (0–1 heuristics, not calibrated probabilities), and analyzedDurationMs. DjAnalysis.dropCandidates lists sustained energy-rise candidates in time order; legacy dropMs/dropConfidence still describe the strongest rise. Spectral onsets estimate tempo/phase; sustained energy rises suggest a drop. Half/double-tempo ambiguity, weak onsets, or changing tempo intentionally lower confidence or produce nulls: fallback is expected, not a detection failure to bypass.
DjAnalysis.structuralCues lists sustained low-band spectral-share increases (window-relative ms, bassShareBefore, bassShareAfter, onsetCount) even when the loudness rise detector leaves dropMs null. These are unverified arrangement cues, not semantic drops or downbeats. DjAnalysis.localRhythm reports previewOnly pairs of locally fitted grids checked against neighboring holdouts (≤0.5% BPM difference and ≤1/16-beat extrapolated phase disagreement). Their fitting windows can overlap and use a shared global tempo seed: this does not calibrate or raise bpmConfidence and does not make Automix accept rejected grids. For a post-seek analysis window, all times and phases remain relative to that window. Check click tracks and rendered transitions before using such candidates for a live DJ handoff.
Offline cue audition (explicit and not a live Automix plan). After npm run build, run node scripts/render-drop-candidate.mjs <outgoing-local-file> <incoming-local-file> <outgoing-anchor-seconds> <incoming-anchor-seconds> <outgoing-BPM> <incoming-BPM> <output.wav> from the repository root. This produces a 22-second 48 kHz stereo WAV and a .json report using a 6-second overlap, a pitch-preserving tempo match that returns to 1×, and a basic equal-power crossfade. It refuses to overwrite files and rejects invalid positions or >8% tempo corrections. Duration, digital silence and peak checks guard render integrity; they do not verify downbeats, vocal clashes, musical structure, or real-time PCM pacing. The supplied Hot Wheels → Zatoichi experiment has three candidate previews in .dj-diagnostics/drop-review-20261101/ (incoming anchors 37/63/72 seconds, outgoing anchor 132 seconds). The listener selected 72 s for further verification; this ignored directory contains downloaded copyrighted MP3s and generated WAVs for local listening only. The script is a convenient comparison tool, not the production PlaybackMixer/bass-handoff implementation.
PCM-mixer confirmation for the selected cue. After npm run build, node scripts/render-live-handoff.mjs <outgoing-local-file> <incoming-local-file> 132 72 101.71 109.99 <output.wav> exercises the real FluentStream.prepareAutomix() / PlaybackMixer with an explicit transitionOverride, transitionMode: "beat-handoff" and a pitch-preserving tempo return. The verified example is .dj-diagnostics/drop-review-20261101/live-hot-132-to-zatoichi-72.wav with .json stats. The source excerpt starts at 120 s, its overlap starts at 126 s, the incoming source seeks to 66.4517 s, and both chosen anchors meet at the six-second overlap endpoint. The test applies -af volume=0.1 to both decoders, as in the bot's supplied FFmpeg logs; without attenuation the outgoing MP3 clipped before mixing. It validates local mixed PCM and estimated source time, not Discord/WebRTC output, vocal clashes, perfect phase, or automatic cue detection. No confidence gates are changed or skipped except by the explicitly supplied manual override. Do not apply these hardcoded cue positions to any other pair.
Seamless DJ handoff vs. a mashup. For continuous playback without a long overlap, opt in to prepareAutomix(nextUrl, { transitionMode: 'seamless', outgoingRemainingMs, incomingDurationMs? }). It prepares the next source without BPM analysis, then switches near the outgoing tail with a ≤350 ms native-speed fade and incoming cue 0; it reserves one outgoing second to protect against an exhausted decoder. It does not automatically seek to a drop, match tempos, or promise a perfect vocal/phrase boundary. enableTempoMatch, dropHandoff, explicitDropMs and manual overlaps over 500 ms are rejected in this mode. An explicitly approved cue can be specified with transitionOverride: { startMs, cueMs, seconds: 0.35 } (absolute source ms). If preparation misses the planned start it rejects instead of silently moving the incoming cue. This mode does not replace the existing long beat-handoff; callers must select it. For the chosen Hot Wheels 132 s → Zatoichi 72 s experiment, node scripts/render-live-handoff.mjs <outgoing-local-file> <incoming-local-file> 132 72 101.71 109.99 <output.wav> seamless renders a 16.35 s local PCM-mixer preview at native speed with a 350 ms overlap. The example is .dj-diagnostics/drop-review-20261101/seamless-hot-132-to-zatoichi-72.wav. Compare it by ear with the earlier 6-second beat-handoff; PCM checks do not prove a clean vocal cut or Discord/WebRTC playback.
Second-half drop handoff (opt-in). Pass dropHandoff: true and a known incomingDurationMs to prepareAutomix() to try matching a measured outgoing second-half energy rise to a measured incoming first-half energy rise at the end of the overlap. It analyzes one additional window of up to 90 seconds starting at or after the outgoing midpoint, sequentially after ordinary analysis; analyzeTrack(source, { startSeconds }) exposes the same windowed analysis independently, with window-relative dropMs, beat phase, and analyzedDurationMs. planDropHandoff({ outgoingLateAnalysis, incomingAnalysis, windowStartMs, outgoingDurationMs, outgoingPositionMs, incomingDurationMs, enableTempoMatch?, maxTempoChange? }) is the pure planner and returns null without sufficiently confident drops/grids, a four-beat overlap, a future start, or enough track time. The planner also requires fitted beat phases to agree within 1/16 of a beat across the overlap (including estimated drift when tempo matching is off). If it returns null or optional late analysis fails, preparation uses the normal Automix plan. With tempo matching, the incoming cue is adjusted for the overlap's initial tempo factor and playback returns toward 1× afterward. A drop handoff whose scheduled start is already past rejects on start() rather than moving the cue to an unrelated beat; re-plan with a new track. Energy contrast is not semantic drop/downbeat detection, and this is not a guarantee of a good mix. This mode incurs one extra FFmpeg decode and worker job/network request; leave it off if playback is CPU-constrained. onDiagnostic.outcome reports drop-handoff when selected, with outgoingLateWindowStartMs and outgoingHandoffDropMs in absolute source milliseconds; the outgoing scalar snapshot still describes the initial window, not the extra window.
planAutomix({ outgoingAnalysis, incomingAnalysis, outgoingPositionMs, outgoingRemainingMs, incomingDurationMs?, fallbackSeconds, explicitDropMs?, onDiagnostic? }) is pure timing advice and returns AutomixPlan | null. Its fields are seconds, cueMs, dropMs, reason (energy-entry, tempo-phrase, or crossfade; the opt-in planner can also return drop-handoff), startMs, and beatPeriodMs (null for fallback).
- Both tracks must have valid 70–180 BPM grids with BPM and phase confidences ≥ 0.65, and faster/slower tempo ratio ≤ 1.03 (at most 3% apart). Automatic incoming drops additionally need confidence ≥ 0.7 and must lie inside the analyzed window.
explicitDropMsoverrides drop detection only after both grid/tempo gates pass. - Low confidence or incompatible tempos choose a plain crossfade from incoming cue 0, with no drop seek, even if an explicit drop was supplied, using the configured fallback length subject to duration caps. Fallback defaults to 8 seconds in
prepareAutomix; invalid planner fallback lengths also use 8. This fallback can still overlap percussive material; it is not automatically a perfect mix. Invalid outgoing timing/known incoming duration returns null. All overlaps are capped at 16 seconds, reserve one outgoing second, and use at most a quarter of a known incoming duration. - Quantized plans use up to 16 beats, preferring four-beat bars. For unequal reliable BPMs within the tempo gate, the planner limits estimated drift to 1/16 of the shorter beat, shortening the overlap and using whole beats when a bar cannot fit. If no grid-aligned overlap fits, these unequal-tempo plans use a short ordinary cue-0 crossfade of at most 1 second, not the longer configured fallback. Equal-tempo plans retain four-beat groups and use the configured fallback if they cannot fit. Four beats are only treated as a bar: this is not bar/downbeat detection, pitch/speed adjustment, time stretching, or guaranteed persistent beat sync. The drift budget assumes stable tempo, not measured phase error; tempo can drift, especially beyond the analyzed excerpt. Drop lead-in is approximate, not a guarantee that the drop lands on the fade endpoint.
prepareAutomix()automatically enablesbassHandoff: truewith smooth outgoing bass attenuation to −12 dB,equalPoweroverlap, and 3 dB midpoint headroom. Independent outgoing/incoming low-band histories support the smooth 35–65% handoff: outgoing lows close as incoming lows open. This is a leaky single-pole split, not brick-wall isolation or a full frequency exchange. Full-range envelopes still overlap, so drums/percussive material may overlap; neither clean drum separation nor an automatically perfect mix is guaranteed. Tempo matching is opt-in (enableTempoMatch) and pitch-preserving; ordinary preparation does not automatically change speed.
Automix decision diagnostics. PrepareAutomixOptions.onDiagnostic?: (diagnostic: AutomixDiagnostic) => void observes the planning decision independently of verbose. It is called exactly once when planAutomix runs, even if the planner returns null (in which case prepareAutomix() rejects); the seamless mode and explicit transitionOverride do not invoke that planner and do not emit its diagnostic. failures or cancellation before planning do not emit a decision. Callback exceptions are swallowed and cannot change the plan or interrupt preparation. Direct planAutomix() calls also accept onDiagnostic with the same once-per-decision behavior, including null results. AutomixDiagnostic and AutomixFallbackReason are exported public types.
outgoingandincomingcontain scalar analysis snapshots of both tracks:bpm,bpmConfidence,beatPhaseMs,beatPhaseConfidence,dropMs,dropConfidence, andanalyzedDurationMs. Missing/non-finite numbers becomenull; no source URLs are included.outgoingGridIssues/incomingGridIssuesexplain grid validation failures.timingrecordsoutgoingPositionMs,outgoingRemainingMs,incomingDurationMs,fallbackSeconds, andexplicitDropMs, also sanitized to finite numbers ornull.fallbackReason: AutomixFallbackReason | nullis one ofunreliable-both-grids,unreliable-outgoing-grid,unreliable-incoming-grid,tempo-mismatch,insufficient-overlap,insufficient-grid-window,incoming-duration-limit, orinvalid-timing; it isnullwhen no fallback/rejection reason applies.outcomeis the resulting plan reason (energy-entry,tempo-phrase,crossfade, or opt-indrop-handoff), ornullwhen no plan is possible.tempoRatiois faster/slower positive finite BPM (ornull), even for unreliable grids.octaveRelatedflags ratios from 1.85 to 2.15: it is diagnostic only, not tempo correction, permission to bypass confidence gates, or an automatic drop seek.- A configured logger receives
logger.debug("[tag] Automix decision", { code: "FluentStream-automix-decision", detail: diagnostic, ... })independently ofverbose, wheretagis the effectiveloggerTag(defaultFluentStream). Logger exceptions are swallowed too. These decision diagnostics are separate fromgetPlaybackStats()mixer/buffering statistics below.
Diagnostics do not relax the conservative safety thresholds above or change fallback behavior: unreliable grids or incompatible tempos still use incoming cue 0 with no drop seek, even with explicitDropMs.
Cost and migration. Uncached preparation separately opens/decodes outgoing and incoming sources, then opens the incoming source again for playback. Remote requests expose URLs/headers to the configured media endpoint and use additional network traffic and CPU; there is no external analysis service or built-in analysis cache. Cache/reuse trusted DjAnalysis in the caller (for example, using analyzeTrack ahead of time); analyzePcmAsync avoids a separate library download/decode if mono PCM is already available without running the CPU analyzer on the main thread. For bots, replace local analyzer/planner imports with the public exports above and remove duplicate implementations in the bot project—keep one library implementation. Bot files outside this repository must be migrated in that project. Their playback/buffering settings and downstream player are not available here: worker isolation alone does not verify that a bot's audible stutters have disappeared.
streamer.getPlaybackStats() returns PlaybackMixerStats | null: a frozen point-in-time snapshot for the current mixed playback session, or null outside one. Counters span that mixer session and do not reset when read; take another snapshot to observe changes. The exported interface matches src/Audio/PlaybackMixer.ts:
interface PlaybackMixerStats {
readonly emittedFrames: number;
readonly pullCount: number;
readonly maxPullMs: number;
readonly latePullCount: number;
readonly deadlineMissCount: number;
readonly pcmWaitMs: number;
readonly maxPcmWaitMs: number;
readonly frameShortageCount: number;
readonly preparationWaitCount: number;
readonly preparationWaitMs: number;
readonly pendingReadCount: number;
readonly prebufferedFrames: number;
readonly preparing: boolean;
readonly cleanupPending: boolean;
}emittedFramescounts successfully enqueued PCM frames, including fades/silence, not discarded work.pullCountincludes first demand and terminal pulls (EOF, errors, cancellation).maxPullMsandmaxPcmWaitMsmeasure the longest generation pull;pcmWaitMssums pull duration including PCM/preparation waits, finalized when each pull ends. Time between consumer reads is excluded.latePullCountcounts pulls longer thanmax(20 ms, duration of PCM enqueued by that pull);deadlineMissCountcounts pulls strictly longer than the fixed 20 ms deadline (960 frames at 48 kHz), including first/terminal pulls.frameShortageCountcounts current-source frame waits exceeding 20 ms before the pull has produced output. Immediate/small reads are not shortages; delayed EOF, errors, or cancellation can count.preparationWaitCount/preparationWaitMsdescribe EOF waits for a held replacement or incoming crossfade warmup.pendingReadCountincludes live upstream reads, including background prebuffering.prebufferedFramescounts complete frames in explicit warmup queues, not upstream chunks or output.preparingandcleanupPendingreport current preparation/cleanup state.
Deadline misses and frame shortages are not physical player underruns. Event-loop starvation between pulls may not appear in generation-only timings. Measure consumer delivery against a separate fixed clock, alongside event-loop delay and downstream/device telemetry; consumer pauses and external buffering are separate from mixer generation.
From the repository root, run the reproducible synthetic comparison:
./node_modules/.bin/tsc && node scripts/bench-dj-responsiveness.mjsIt compares no-analysis baseline, two consecutive synchronous analyses, and two concurrent worker analyses on deterministic seeded 90-second mono tracks through the actual PlaybackMixer with a paced synthetic PCM source. The JSON reports environment, a shared calibration-sized observation window, fixed 20 ms consumer opportunities/misses, event-loop delay/utilization, and mixer stats. Async elapsed time includes startup, copying, DSP, IPC, and termination; the empty-job startup/IPC/termination proxy is separate, not pure startup or subtracted compute time. Expired consumer slots are skipped, not replayed in a catch-up burst.
This is a short local responsiveness observation, not a worst-case input or performance guarantee: 90 seconds is the analysis cap, not maximum computational complexity. There is no FFmpeg, network, device, or physical-underrun measurement; the direct mixer bypasses Processor throttling/settings. Repeat on the target deployment and compare loop delay and consumer misses, not just analysis throughput or mixer counters. No timing threshold is asserted and machine-specific results are not API guarantees.
import { FluentStream } from "fluent-streamer";
import { Readable } from "node:stream";
// Stream from HTTP source to response
app.get("/audio/:fileId", async (req, res) => {
const audioUrl = `https://cdn.example.com/audio/${req.params.fileId}.mp3`;
try {
const streamer = new FluentStream()
.input(audioUrl)
.setHeaders({
Authorization: "Bearer token",
"X-Client-Id": "my-app",
})
.audioCodec("aac")
.audioBitrate("128k")
.format("mp3")
.output(FluentStream.stdout);
const { output, done } = await streamer.run();
res.setHeader("Content-Type", "audio/mpeg");
res.setHeader("Content-Disposition", 'inline; filename="converted.mp3"');
Readable.fromWeb(output).pipe(res);
await done;
} catch (error) {
console.error("Audio stream error:", error);
res.status(500).send("Streaming failed");
}
});Built-in DSP processes raw signed 16-bit little-endian PCM from FFmpeg stdout, not encoded MP3/AAC, WAV headers, or files written directly by FFmpeg. Match FFmpeg output, audioProcessorOptions, and the player format explicitly. Only mono and stereo are supported.
The following Node.js example uses ffplay as a clocked consumer; both binaries must be installed. The browser/WASM runner buffers input and does not support live playback.
import { spawn } from "node:child_process";
import { Writable } from "node:stream";
import { FluentStream } from "fluent-streamer";
const streamer = new FluentStream({
useAudioProcessor: true,
disableThrottling: true, // the player controls playback speed via backpressure
autoDrainOutput: false,
audioProcessorOptions: {
volume: 1,
bass: 0,
mids: 0,
treble: 0,
compressor: false,
normalize: false,
sampleRate: 48000,
channels: 2,
},
});
const player = spawn("ffplay", [
"-nodisp", "-autoexit", "-f", "s16le", "-ar", "48000", "-ac", "2", "-i", "pipe:0",
], { stdio: ["pipe", "ignore", "inherit"] });
player.on("error", (error) => console.error("Player failed:", error));
const { output, done, stop } = await streamer
.input("https://example.com/music.mp3")
.audioCodec("pcm_s16le")
.outputOptions("-vn", "-f", "s16le", "-ar", "48000", "-ac", "2")
.output("pipe:1")
.run();
try {
const playback = output.pipeTo(Writable.toWeb(player.stdin));
streamer.setBass(5).setMids(4).setTreble(-3);
await Promise.all([playback, done]);
} finally {
await stop();
player.kill();
}If supplying audioProcessorOptions, TypeScript requires volume, bass, treble, compressor, and normalize; mids, karaoke, sampleRate, and channels are optional. Setting these format options does not configure FFmpeg output: specify matching -ar, -ac, codec, and raw format as above. Volume is clamped to 0…1.
Values in audioProcessorOptions are not all forwarded by the current Fluent pipeline: initial normalize is reset to false, and cloneInput, headers, low-pass options, and fade are not passed to the built-in DSP. Enable normalization after run() via result.setNormalize?.(true) or streamer.changeNormalize(true). A directly constructed AudioProcessor does honor its initial normalize and cloneInput settings.
Enable useAudioProcessor: true with audioProcessorOptions.karaoke: true (default: false) in the raw PCM pipeline above, or configure it before run() with streamer.setKaraoke(true). No restart is needed for live changes:
streamer.setKaraoke(true); // chainable; initial or live setting
streamer.karaoke = false; // equivalent read/write accessor
const enabled = streamer.karaoke;
result.setKaraoke?.(true); // run() result; when a processor is attached
streamer.changeKaraoke(false); // returns false without an attached runtime setterAudioProcessor also exposes setKaraoke(enabled) and a read/write karaoke accessor. FluentAudioState supports the same state, chainable setter, and changeKaraoke() control. Fluent karaoke state is included in cached options/debug state for subsequent runs and preserved across live seek restarts.
Karaoke uses mid/side processing: it preserves the side signal, applies frequency-selective attenuation to the mid signal, then reconstructs stereo before volume, EQ, and compression. Shelving filters shape attenuation over approximately 180 Hz–6 kHz, with a nominal center depth of -24 dB. An adaptive coherence estimate based on overall mid-versus-side energy—not per-frequency analysis—reduces suppression on hard-panned signals. Mono bypasses karaoke; other enabled effects still apply.
This is center attenuation, not ML vocal isolation or source separation. Centered instruments can also be attenuated, while panned vocals and vocal reverb can remain. Stereo is retained, but loudness and timbre can change; neither complete vocal removal nor listening quality is guaranteed. Reconstruction can produce peaks that clip even when the original PCM did not; the processor saturates samples to the 16-bit range rather than allowing integer wraparound. This processing uses more CPU than the old simple subtraction.
Runtime toggles ramp the wet mix over 50 ms of processed audio; an initially enabled setting takes effect immediately. Already queued audio is not reprocessed. Existing PCM buffering, 16-bit quantization, normalization, and saturation rules still apply. Start with neutral EQ and normalization/compression disabled to evaluate the attenuation; these effects remain available but alter the residual sound.
Use the PCM pipeline and consumer above. Start at volume zero, then invoke fades while output is being consumed:
// Before run():
streamer.setVolume(0);
// After run(), while output.pipeTo(...) is active:
streamer.fadeIn(1, 2000);
// Later, when a fade-out is needed:
streamer.fadeOut(3000);Fade durations use processed audio frames, not system time. Without an output consumer, backpressure stops processing and fades cannot advance. For file encoding, use FFmpeg filters instead of built-in live DSP.
// Before run(): initial settings apply immediately.
streamer
.setVolume(0.8)
.setBass(8)
.setMids(4)
.setTreble(-3)
.setCompressor(true);
// During PCM playback: EQ transitions take 50 ms of processed audio.
streamer.setBass(0).setMids(-2).setTreble(5);
streamer.changeNormalize(true); // live update; returns whether a run is attachedNormalization is bounded peak control, not an EBU R128/LUFS loudness normalizer. Gain is limited to ×4 with a -1 dBFS input-peak target. Signals at or below -60 dBFS are not amplified. Gain reduction is immediate; recovery uses a 0.5-second audio-duration time constant. Silence and enable/disable transitions reset gain history. EQ and volume processing affect the final peak, so -1 dBFS is not a guaranteed output ceiling; final PCM saturation remains in place.
FluentStream provides a fluent interface:
import { FluentStream } from "fluent-streamer";
await new FluentStream()
.input("song.mp3")
.seekInput(30) // seek to 30s
.audioCodec("opus")
.audioBitrate("128k")
.output("clip.opus")
.run();Main methods:
.input(src)— add file/URL/stream input (string,ReadableStream<Uint8Array>, or Node.js Readable).output(dst)— set the output (file path, pipe object, or fd).audioCodec(codec)/.videoCodec(codec)— set codecs.audioBitrate(bps)/.videoBitrate(bps).format(fmt)— set output format (e.g. 'mp3', 'wav').seekInput(time)— set the input start position before.run()await streamer.seek(time)— live seek; returns a new run result and output stream.overwrite()— overwrite output file(s).map(spec)— select specific streams.crossfadeAudio(seconds, options?)— offline FFmpeg crossfade filterawait streamer.crossfade(url, seconds = 3)— live transition withenableCrossfade: true.setKaraoke(enabled)/.karaoke— initial/live center cancellation.run()— start processing (async; returnsoutput,done,stop)
new FluentStream(options?: ProcessorOptions)Creates a new FluentStream instance with optional processor options.
Options:
timeout?: number- FFmpeg process timeout in milliseconds (0disables it)enableProgressTracking?: boolean- Enable progress event emissionfailFast?: boolean- Stop processing on first errorwallTimeLimit?: number- Maximum wall clock time limituseAudioProcessor?: boolean- Enable built-in audio processingdisableThrottling?: boolean- Defaults totrue. Setfalseto pace PCM output by its configured byte rate; this is not a jitter buffer.autoDrainOutput?: boolean- Defaults totrue. An unlocked output is automatically drained after 100 ms. Setfalsewhen attaching your own player or stream consumer.enableCrossfade?: boolean- Explicit opt-in (defaultfalse) for stable mixed PCM output and live.crossfade(); disables auto-drain. See Live Crossfade.audioProcessorOptions?: AudioProcessingOptions- Audio effect defaults (volume, bass, treble, mids, compressor, normalize, karaoke, sampleRate, channels, cloneInput)logger?: Logger- Custom object logger (debug,info,warn,error,playerError,child, optionalend). Messages are forwarded aslogger[level](message, meta)regardless ofverbose; the logger controls its own filtering. The library does not own or end the supplied logger.verbose?: boolean- Enable non-error console output when no custom logger is supplied. Console errors are always reported.
Add file, URL, blob URL, or stream input.
// File input
.input('/path/to/audio.mp3')
// HTTP URL with custom headers
.input('https://cdn.com/track.mp3')
// Web Stream input
.input(readableStream)
// Node.js stream input (adapted internally)
.input(fs.createReadStream('input.wav'), { pipeIndex: 0 })Options:
label?: string- Input label for identificationpipeIndex?: number- Custom pipe index for streamsallowDuplicate?: boolean- Allow duplicate inputs
Set output destination.
// File output (auto-overwrites if .overwrite() called)
.output('/path/to/output.mp4')
// Stdout / pipe
.output(FluentStream.stdout)
.output({ pipe: 'pipe:1' })For stream consumers, use pipe:1 and read the output stream returned by .run().
Set audio codec: 'aac', 'mp3', 'opus', 'vorbis', 'pcm_s16le', etc.
Set video codec: 'h264', 'h265', 'vp9', 'av1', 'mpeg4', etc.
Set audio bitrate: '128k', '192k', '320k', 'variable', etc.
Set video bitrate: '1M', '2M', '5M', '10M' for high-quality, etc.
Set output container format: 'mp3', 'mp4', 'wav', 'flac', 'webm', etc.
Set sample rate: 44100, 48000, 96000, etc.
The built-in PCM audio processor supports 1 (mono) or 2 (stereo) channels. Other channel counts are rejected rather than partially applying effects.
Set the input start position before .run(): 30, '00:00:30', '1:30', etc. This is pre-run configuration, not a live playback seek; use await streamer.seek(position) for an active run.
Limit output duration.
Select specific streams (advanced FFmpeg feature):
.map('0:v') // Select first video stream
.map('1:a') // Select second audio stream
.map('0') // Select all streams from first inputDisable video/audio processing entirely.
Allow overwriting existing output files.
Copy streams without re-encoding (faster, preserves quality).
Set volume multiplier, clamped to 0…1: 0 (mute), 0.5 (half), 1 (normal). Values above 1 are clamped rather than boosted.
Adjust bass level (-20 to 20): 0 (neutral), 5 (boost), -3 (cut).
Adjust treble level (-20 to 20): 0 (neutral), 5 (boost), -3 (cut).
Adjust midrange level (-20 to 20): 0 (neutral), positive values boost, negative values cut. Also available as the mids property and optional audioProcessorOptions.mids (default 0). Like bass/treble, Fluent setters retain the value for subsequent runs. The run result exposes setMids(value) for live updates and retains its value for the next run of the same Processor.
AudioProcessor.setEqualizer(bass, treble, compressor) keeps its existing signature and does not change mids; use setMids(value) separately.
Live changes to bass, mids, and treble automatically transition over 50 ms of processed audio, including transitions back to neutral. Repeated changes continue from the current transition. Initial constructor settings apply immediately; smoothing does not delay or buffer the audio stream and does not use wall-clock time.
Enable/disable audio compression for consistency.
Enable/disable automatic peak normalization during a run. Use setNormalize(enabled) on the run result or streamer.changeNormalize(enabled) (returns success). FluentStream does not expose a normalize property or chainable setNormalize() method.
Configure or toggle center cancellation with useAudioProcessor: true. .setKaraoke() is chainable; .karaoke is a read/write accessor. .changeKaraoke() returns whether a runtime setter is attached. See Karaoke for DSP behavior and limitations.
Fade in from current volume to target volume.
Fade out to silence.
Real-time changes (during playback):
.changeVolume(value: number)— boolean success.changeBass(value: number)— boolean success.changeTreble(value: number)— boolean success.changeMids(value: number)— boolean success.changeCompressor(enabled: boolean)— boolean success.changeNormalize(enabled: boolean)— boolean success
Create crossfade between inputs using FFmpeg's acrossfade filter.
.crossfadeAudio(2.5, {
curve1: 'tri', // crossfade curve
curve2: 'tri',
overlap: true,
secondInput: 'path/to/second.mp3'
})Add complex FFmpeg filter graphs for advanced processing.
Set custom HTTP headers for remote sources.
Set User-Agent header for HTTP requests.
Add FFmpeg arguments at specific positions.
Start processing asynchronously. Returns:
{
output: ReadableStream<Uint8Array>, // Output stream when piped
done: Promise<void>, // Settles exactly once on completion/failure
stop: () => void, // User stop/kill helper
passthrough: ReadableStream<Uint8Array>,
close: () => Promise<void> | void,
seek?: (position: number | string) => Promise<FFmpegRunResultExtended>,
crossfade?: (url: string, time?: number) => Promise<void>, // Opted-in mixed session
setVolume?, setBass?, setTreble?, setMids?, setCompressor?, setNormalize?, setKaraoke?, startFade?
}done resolves for normal process completion and intentional user stop()/close() calls. It rejects for spawn/process errors, non-zero FFmpeg exits and timeouts. In Node.js, child process errors such as spawn ffmpeg ENOENT are surfaced as process errors instead of ambiguous code === null exits.
Both await streamer.seek(90) and await result.seek?.('00:01:30') seek to an absolute input position, not a relative offset. The optional result method must be present to use it; optional chaining returns undefined otherwise.
Ordinary seek (enableCrossfade off, unchanged): FFmpeg restarts and returns a new FFmpegRunResultExtended, with its own output, done, stop, and other runtime controls. Runtime volume, EQ (bass/mids/treble), compressor, karaoke, and normalize settings are preserved across the restart. Replace the result and output as shown below:
const streamer = new FluentStream({ autoDrainOutput: false })
.input('song.mp3')
.audioCodec('pcm_s16le')
.format('s16le')
.output('pipe:1');
let result = await streamer.run();
let output = result.output;
// Connect output to your player; keep ownership of that consumer.
result = await streamer.seek(90);
output = result.output;
// Detach the old consumer, clear the player's queued audio, and connect NEW output.
const next = await result.seek?.('00:01:30');
if (next) {
result = next;
output = result.output;
// Again replace the consumer and clear the player's queue before playing NEW output.
}
// Use the current result.done / result.stop / other controls from now on.Optional HTTP seek cache: Set seekCache: true on a stream with one HTTP input (or in run() options). While the original FFmpeg decoder plays, a separate hyperttp streaming request writes the original compressed bytes into a private temporary file. hcacher retains only the path to a fully written, validated file; a partial download is never passed to FFmpeg. Check streamer.getSeekCacheStatus() ('disabled' | 'loading' | 'ready' | 'miss') before relying on the optimization. Ordinary seek still replaces result.output, while mixed seek retains its existing output. The default seekCacheMaxBytes is 16 MiB per stream on disk, with no full-track in-memory copy (set a smaller positive integer to cap disk use); oversized, failed, incomplete or still-downloading sources fall back to the original HTTP seek. The temporary file is deleted when the playback session ends. This is opt-in because it adds a second full HTTP request during playback and temporary disk use; it does not speed up a seek made before download completes, work for unbounded sources, or solve downstream PCM/WebRTC buffering. Example:
const streamer = new FluentStream({
seekCache: true,
seekCacheMaxBytes: 12 * 1024 * 1024,
autoDrainOutput: false,
});
// Configure a single HTTP input and PCM stdout as in the example above.For ordinary seek, the old output stream ends during a seek. Replace the consumed output and clear any external player queue yourself; the library does not automatically clear it and does not guarantee gapless playback. autoDrainOutput: false is recommended when your application consumes the output.
Lifecycle (ordinary, non-mixed runs): done belongs to one FFmpeg run, not the entire playback session. Settlement of the old done during a restart does not mean that the playback session has ended. Do not attach unconditional session/player teardown to an old result's done; track the replacement result and handle seek transitions separately.
Mixed seek (enableCrossfade: true): Both seek entry points return the same public result, with the same output and done. After a completed crossfade, the position is absolute within the current track, not the original track or the mixed-session timeline. Keep the existing output consumer running continuously before and throughout await seek(); do not detach it or wait for seek to finish before consuming. Backpressure can otherwise stall completion. done still covers the whole mixed session, not the replacement decoder.
Mixed seek applies a 20 ms fade-out to the old audio, switches decoders at silence, then applies a 20 ms fade-in from the requested position. These sequential envelopes are counted in audio frames and do not modify the user volume or DSP fade state. await seek() resolves after fade-in PCM is generated (not after physical playback). The consumer must keep reading. Ordinary replacement-output seek does not apply these mixer envelopes.
For example, in the Live Crossfade setup above, replace the try body with:
// playback is already consuming result.output and must keep running.
await streamer.crossfade("second.mp3", 3);
const sought = await streamer.seek(90); // absolute 90 seconds into second.mp3
console.assert(sought === result);
console.assert(sought.output === result.output && sought.done === result.done);
await result.seek?.("00:00:30"); // same behavior; absolute 30 seconds into second.mp3
// No result/output replacement or consumer reconnection for mixed seek.
await Promise.all([playback, result.done]);The library does not flush PCM already queued by an external player. Old frames may continue during decoder warmup, and network startup may cause an audible gap; preserving stream identity does not guarantee an immediate audible jump or gapless playback.
A seek during an active fade rejects. While a seek is in progress, crossfade and a second seek reject. These operations do not cancel or supersede an in-progress transition; await completion before requesting another operation.
Limits and errors:
- Only an active run with a single replayable file/URL input, stdout output (
pipe:1), and no complex filters supports live seek. - Raw stream and blob inputs are unsupported. A live/non-seekable URL server may fail even though the input is a URL.
- The position must be a finite number of seconds
>= 0, or a time string such as'00:01:30'; it is not a relative offset. - Concurrent seeks reject. For ordinary seek, seeking through a stale result (one superseded by a seek) rejects; always retain the new result. Mixed seek retains the existing public result instead.
.seekInput()remains pre-run configuration; it does not replace this live-seek API.
Reset instance for reuse (required before .run() after previous use).
Check instance state.
'progress'— Progress updates (ifenableProgressTracking: true)'error'— Processing errors'complete'— Processing finished'start'— Processing started
FluentStream.stdout— { pipe: 'stdout' }FluentStream.stderr— { pipe: 'stderr' }FluentStream.pipe1— { pipe: 'pipe:1' }FluentStream.pipe2— { pipe: 'pipe:2' }
- Timeout: Set the
timeoutoption to auto-kill long FFmpeg jobs; timeout termination rejectsdone. - Progress: Get real-time progress events by enabling
.options({ enableProgressTracking: true })and listening for"progress"events. - Headers: Default humanity headers are sent to FFmpeg HTTP(S) sources; override with
.setHeaders(obj). - Kill/close:
run()returnsstop()andclose()helpers for intentional user termination; these are tracked separately from FFmpeg errors.
- Written in TypeScript: All primary objects (
FluentStream,Processor) are strongly typed - Modular layout:
Fluent/(API),Core/(execution),Runner/(platform FFmpeg),Audio/(effects) - Built-in audio processing: Volume, EQ, compression via
AudioProcessorandAudioEffectController - Extensible runners: Implement
FFmpegRunnerfor custom process spawning or blob resolution
FFmpeg not found:
Error: spawn ffmpeg ENOENT
- Ensure FFmpeg is installed and accessible in PATH
- On Linux/macOS:
which ffmpeg - On Windows: Check if ffmpeg.exe is in your PATH
- Alternative: Provide full path with
new FluentStream({ ffmpegPath: '/usr/bin/ffmpeg' })
Stream errors with HTTP sources:
[tcp @ 0x...] Connection refused
- Verify URL is accessible:
curl -I https://example.com/audio.mp3 - Check network connectivity and firewall settings
- Some servers block requests without proper headers
Codec/format not supported:
Unknown encoder 'xxx' or unsupported codec
- Verify FFmpeg installation supports the codec:
ffmpeg -codecs | grep xxx - Some codecs require additional FFmpeg builds (e.g., non-free codecs)
- Try simpler codecs like
aacfor audio,h264for video
Out of memory during processing:
Cannot allocate memory
- Reduce input resolution/frame rate for videos
- Use
copyCodecs()for passthrough where quality loss is acceptable - Process in smaller chunks for large files
- Increase system memory limits if possible
File locking/permissions issues:
Permission denied (file locked)
- Ensure output directory is writable
- Close file handles before processing
- On Windows, ensure files aren't open in other applications
- Use unique temporary filenames
For URL/file playback through a clocked player, start without -re, -fflags nobuffer, or -flags low_delay. -re limits input reading to real time and reduces the opportunity to prepare audio ahead of playback; it is not needed merely because output is played in real time. These flags are not a general cure for latency. Live sources and publishing pipelines may require different settings.
- With a player that controls timing and supports backpressure, keep
disableThrottling: trueand useautoDrainOutput: false. - If you need library-side PCM pacing, set
disableThrottling: false. Do not blindly combine multiple pacing layers. Throttling cannot compensate for unavailable network data and is not a jitter buffer. - In your player, consider a bounded 100–200 ms startup buffer, trading initial latency for underrun resistance. Respect backpressure rather than accumulating an unbounded queue.
- At 48000 Hz, stereo, s16le: 192000 bytes/s, 3840 bytes per 20 ms, and 19200–38400 bytes per 100–200 ms. FFmpeg chunks have variable sizes: do not treat each chunk as one fixed-duration packet. Preserve incomplete frames when repacketizing.
- Match the FFmpeg output, DSP options, and consumer format.
.audioFrequency()/.audioChannels()configure FFmpeg arguments; they do not synchronize the DSP options automatically. - Distinguish pitch/speed changes from delivery gaps. Log chunk byte counts, arrival times, player buffer depth, and underruns using monotonic clocks. Inspect FFmpeg stderr for reconnects and decoding errors.
- Normalization measures the incoming signal before the built-in EQ. FFmpeg
volume=0.1attenuates input by 20 dB and normalization may partly compensate for it; this changes loudness, not playback speed.
Enable verbose logging:
const streamer = new FluentStream({
verbose: true, // Console fallback; no custom logger required
});Inspect FFmpeg command:
const streamer = new FluentStream()
.input('in.mp3')
.audioCodec('aac')
.output('out.m4a');
console.log('FFmpeg args:', streamer.getArgs());
console.log('Inputs:', streamer.getInputSummary());Check instance state:
console.log('Is dirty:', streamer.isDirtyState());
console.log('Is ready:', streamer.isReady());
console.log('Debug info:', streamer.debugInfo());For real-time streaming:
- Use high priority for FFmpeg processes
- Optimize audio-only processing with
.noVideo() - Use efficient codecs like
aacinstead ofmp3for streaming - Implement buffering to prevent stutter
- Monitor system resources during peak usage
For file processing:
- Use multiple CPU cores with FFmpeg threading:
.outputOptions('-threads', '0') - Process related tasks in parallel with separate FluentStream instances
- Cache processed results when possible
- Use faster presets:
.outputOptions('-preset', 'fast')
Memory management:
- Use streams for large files instead of loading entire content
- Clean up instances with
.clear()after use - Limit concurrent FFmpeg processes
- Monitor memory usage in production
Runtimes: Node.js 18+, Bun, Deno, Browser (with @ffmpeg/ffmpeg)
FFmpeg versions: 4.0+ required, 5.0+ recommended (native runtimes)
OS support: Linux, macOS, Windows, Docker containers
Known limitations:
- Real-time effects require raw signed 16-bit little-endian PCM on stdout, with matching sample rate and mono/stereo channels; encoded output is not decoded by the built-in DSP.
- Some advanced filters may not support all input combinations
- HTTP streaming may have buffering delays depending on network
- Large crossfades can be memory-intensive
git clone https://github.com/Dirold2/fluent-streamer.git
cd fluent-streamer
npm install
npm run build
npm test- TypeScript: Strict typing required, oxlint compliant
- Naming: CamelCase for classes, camelCase for methods/properties
- Documentation: JSDoc comments for all public APIs
- Tests: Vitest-based, aim for >90% coverage
src/
Fluent/ — FluentStream builder API
Core/ — Processor, ThrottleStream, utilities
Runner/ — FFmpegRunner + platform runners (node, bun, deno, browser)
Audio/ — AudioProcessor, AudioEffectController
Types/ — Shared TypeScript interfaces
- Create issue on GitHub for new features
- Implement in appropriate module (Core, Types, etc.)
- Add tests for new functionality
- Update documentation (README, JSDoc)
- Ensure no breaking changes without major version bump
The AudioProcessor class provides built-in audio effects that can be extended:
- Real-time effects: Volume, EQ, compression available during playback
- Custom AudioProcessor: Extend
AudioProcessorfor new effects - Integration: Effects are applied to PCM audio streams in the processing pipeline
When reporting bugs, please include:
- Node.js and FFmpeg versions
- OS and architecture
- Minimal reproducible example
- Expected vs actual behavior
- Debug output if available
- Enhanced video processing filters
- Improved browser/WASM audio processing
- GPU acceleration for video encoding
- Advanced audio analysis features
- Streaming server integrations
- Plugin system for custom filters and processors
MIT © dirold2
Live mixed playback also accepts crossfade(nextUrl, seconds, options) on both
FluentStream and its active run result. LiveCrossfadeOptions is exported as a type.
Keep consuming the same result.output while awaiting the transition:
await result.crossfade!(nextUrl, 8, {
startTime: 42.5, // incoming cue, absolute seconds into nextUrl
curve: "equalPower", // cosine/sine overlap instead of default linear
outgoingBassDb: -24, // progressively clear outgoing bass during overlap
bassCutoffHz: 180, // low-band split frequency
headroomDb: 3, // midpoint headroom, smoothly unity at both endpoints
});Defaults remain linear with no bass attenuation, bass handoff, or headroom reduction. Cue defaults
to the beginning of the incoming file. startTime must be finite and nonnegative;
outgoingBassDb is -60..0 dB, bassCutoffHz is 20..1000 Hz (internally capped below
Nyquist), and headroomDb is 0..12 dB. bassHandoff?: boolean defaults to false.
Bass uses a per-channel one-pole low-band split with a smooth progressive outgoing
attenuation envelope. Without handoff, incoming EQ is unchanged; with
bassHandoff: true, independent incoming low-band histories allow outgoing lows
to close as incoming lows open smoothly over 35–65% of the fade. This leaky split
is not brick-wall isolation: full-range envelopes and drums can still overlap.
Shaped transitions use a channel-linked instantaneous peak guard to prevent PCM
clipping/wraparound. This is not a lookahead mastering limiter; dense correlated
material may still sound compressed. Headroom reduces how often that guard acts.
Effects end with the overlap; normal track volume/EQ remains unchanged.
The incoming decoder prebuffers up to 20 ms / 1024 PCM frames while outgoing audio
continues. The fade clock starts once that warmup completes, not necessarily at the
instant of the call. For advance analysis/prebuffering and PCM-frame scheduling,
use prepared automix; plain crossfade() does not analyze BPM
or drops. Slow network startup can still cause gaps at outgoing EOF. Neither API
performs time stretching or pitch-changing resampling. A cue identifies the start
of incoming overlap, not a promise to land its drop at the fade endpoint.
Seeking after completion remains absolute within the incoming track.