Audio

io.github.audiojsv2.10.0Updated Oct 3, 2026

Edit, analyze and convert audio: loudness, spec checks, denoise, EQ, cuts, BPM, key. No ffmpeg.

VerifiedSTDIODesktop onlyMedia & DesignData & Analytics

Overview

AI-generated overview

Lets an assistant edit, analyze, and convert audio files locally — loudness, EQ, denoise, cuts, BPM and key — without ffmpeg.

What it does
Exposes a JavaScript audio library as MCP tools for decoding audio from files, URLs, bytes, or streams, then editing it: trim, shrink, crop, insert, cut, paste, move, split, reverse, speed, stretch, pitch, formant, and remix. It also processes audio with gain, fades, normalization to loudness targets, mixing, crossfades, panning, filters, EQ, denoise, vocal separation, and resampling. Analysis covers loudness, RMS, peak, noise floor, BPM and beat detection, and pass/fail checks against delivery specs such as ACX, podcast, streaming, broadcast, and Netflix. Results can be saved or encoded to formats like mp3, wav, flac, and m4a, or exported as cut lists for video editors.
When to use it
Useful when an assistant needs to prepare or inspect audio locally: cleaning up narration or podcast recordings, meeting loudness or delivery specs, trimming silence, converting formats, or extracting tempo and key. It suits workflows that would otherwise require ffmpeg or a DAW.
Requirements
Runs as a local process over stdio, installed from the npm package audio. It is desktop only and not available as a web executable. No authentication, environment variables, or headers are declared. Some optional features download model weights or need extra packages.
Before you install
Editing, writing, and saving operations modify or create audio files, so point them at copies rather than originals. Optional neural denoising and vocal separation may download model weights, and one separation model is described as having weights for research use only. Recording uses the microphone. No credentials or payments are declared.

Installation

In SourceWeft

  1. Open Audio in the dashboard and add it to a workspace.
  2. Enable the server for the chats that should use its tools.

Desktop only via STDIO. STDIO servers start a local process, so they need the SourceWeft desktop host.

Other MCP clients

Follow the launch instructions in the repository.

README

audio [test] [npm]

Audio playback, editing and analysis

  • Any Format — fast wasm codecs, no ffmpeg.
  • Non-destructive — virtual edits, infinite undo, instant clone.
  • Stream-first — playback/encode during decode, realtime editing.
  • Paged — no 2Gb memory limit, open 10Gb+ files.
  • Analysis — loudness, spectrum, beats, pitch, chords, key.
  • Modular – pluggable ops, tree-shakable.
  • CLI — playback, batch processing, scripting, unix pipes, tab completion.
  • Cross-platform — browsers, node, deno, bun.

Start

Node

npm i audio

js
import audio from 'audio'audio('voice.mp3').trim().normalize('podcast').fade(0.3, 0.5).save('clean.mp3')

Browser

html
<script type="module">  import audio from 'https://esm.sh/audio'  audio('./song.mp3').trim().normalize().fade(0.5, 2).clip({ at: 60, duration: 30 }).play()</script>

CLI

sh
npm i -g audio # or: npx audio …audio voice.wav trim normalize podcast fade 0.3s -0.5s save clean.mp3

Skill

sh
npx skills add audiojs/audio

MCP

sh
npx add-mcp "npx -y audio --mcp"  # asks which of your agents: Claude Code, Codex, Cursor, Gemini CLI, Kimi Code, Pi, OpenCode, Zed…

Prompt: make ~/Desktop/interview.m4a podcast-ready and tell me the loudness before and after

One agent at a time: claude mcp add audio -- npx -y audio --mcp, qwen mcp add audio npx -y audio --mcp, droid mcp add audio "npx -y audio --mcp".

Editor and agents

sh
npx audio --bridge  # audio bridge on http://127.0.0.1:7777  key K  agents claude, codex, pi, kimi

Connect the editor to it with the key (once: the bridge keeps it), and its chat runs an agent of yours, which measures, looks at, edits and plays the sound open there; each tab keeps its conversations, each with the agent picked under the message. The bridge finds Claude Code, Codex, Pi, Gemini CLI, Qwen Code, Kimi Code, OpenCode, Kilo Code, Cline, Goose, Factory Droid, Cursor, Augment, Kiro and Mistral Vibe on PATH; any other that speaks ACP runs by its command line, --agent "my-agent --acp".

Any MCP agent gets the editor's tools (state, measure, look, edit, select, play, check, …), the running bridge found by itself: npx add-mcp "npx -y audio --mcp --editor".

An agent thinks with the model its own settings name: Pi takes local ones for good from ollama launch pi --config; Claude Code takes any Anthropic-compatible endpoint from the bridge's environment:

sh
ANTHROPIC_BASE_URL=http://localhost:11434 ANTHROPIC_AUTH_TOKEN=ollama ANTHROPIC_API_KEY= ANTHROPIC_MODEL=qwen3-coder npx audio --bridge
ModelsANTHROPIC_BASE_URL, with the provider's key as ANTHROPIC_AUTH_TOKEN
Ollamahttp://localhost:11434, token ollama (docs)
Z.ai GLMhttps://api.z.ai/api/anthropic (docs)
Kimihttps://api.moonshot.ai/anthropic (docs)
Qwenhttps://coding-intl.dashscope.aliyuncs.com/apps/anthropic, Coding Plan (docs)
DeepSeekhttps://api.deepseek.com/anthropic (docs)

Recipes

Clean up

js
// master a raw takelet a = audio('raw-take.wav')a.trim(-30).normalize('podcast').fade(0.3, 0.5)await a.save('clean.wav')
// full restoration chain via ecosystem plugins (see API › Plugins)a.gate(-45).dehum().deesser().compressor({ threshold: -18 }).limiter({ ceiling: -1 })
// cut 2:00–2:15, smooth the splicea.remove({ at: 120, duration: 15 }).fade(0.1, { at: 120 })
// find clipped blockslet clips = await a.stat('clipping')
// does it pass? each rule of the spec, measuredlet { pass, rules } = await a.check('podcast')         // acx, podcast, streaming, broadcast, netflix

Master & deliver

js
// master a song to a reference track: its tone (mid and side), width and loudness, under -1 dBTPaudio('mix.wav').master(await audio('reference.wav')).save('master.wav')
// audiobook chapter for ACX: RMS -23..-18 dB, peaks under -3 dB, floor under -60 dB, room tone at each end, 44.1 kHzlet ch = audio('chapter-01.wav')  .highpass(80).omlsa({ gMin: -12 }).compressor({ threshold: -24, ratio: 2.5 })  .normalize(-20, 'rms', { ceiling: -3.5 })  .trim().pad(1.5, 2).roomtone()                      // room tone, not digital silence  .resample(44100)console.log(await ch.check('acx'))                    // passed 10 of 10 real narrations (.work/pro.md)await ch.save('chapter-01.mp3', { bitrate: 192 })
// tighten pauses in a talking-head video, then hand the cuts to the video editorlet talk = audio('talk.mp4').shrink(0.3)await talk.save('talk.m4a')                            // the sound, cutawait talk.save('talk.edl')                            // the same cuts for the picture (Premiere, Resolve)

Compose

js
// podcast montagelet ep = audio([intro, interview.trim().normalize('podcast'), outro], { crossfade: 0.5 })await ep.save('episode.mp3')
// voiceover over musicmusic.gain(-12).mix(voice, { at: 2 })
// ringtone: the chorus + fadesaudio('song.mp3').crop({ at: 45, duration: 30 }).fade(0.5, 2).normalize().save('ringtone.mp3')
// split an audiobook into chapterslet [ch1, ch2, ch3] = audio('audiobook.mp3').split(1800, 3600)
// glitch: stutter + reverselet v = a.clip({ at: 1, duration: 0.25 })audio([v, v, v, v]).reverse({ at: 0.25, duration: 0.25 })

Analyze

js
// waveform bars — and progressively, as it decodeslet [mins, peaks] = await a.stat(['min', 'max'], { bins: canvas.width })a.on('data', ({ delta }) => appendBars(delta.max[0], delta.min[0]))
// features for MLlet mfcc = await a.stat('cepstrum', { bins: 13 })let [loud, rms] = await a.stat(['loudness', 'rms'])
// notes, chords, keylet notes = await a.stat('notes')    // [{time, duration, freq, midi, note, clarity}]let chords = await a.stat('chords')  // [{time, duration, label, root, quality, confidence}]let key = await a.stat('key')        // {tonic, mode, label, confidence}

Record & generate

js
// mic takelet a = audio()a.record()// …latera.stop()a.trim().normalize()
// tone — any t => sample functionlet tone = audio.from(t => Math.sin(440 * Math.PI * 2 * t), { duration: 2 })
// sonify datalet s = audio.from(t => Math.sin((200 + data[t / 0.2 | 0]) * Math.PI * 2 * t) * 0.5, { duration: data.length * 0.2 })

Automate

js
a.gain(t => -12 * (0.5 + 0.5 * Math.cos(t * Math.PI * 4)))  // 2Hz tremolo in dBa.lowpass(t => 400 + 4000 * t)                              // filter sweepa.pan({ t: [0, 2, 4], v: [-1, 1, -1] })                     // curve L→R→L over 4s, serializablemusic.ducker({ key: voice })                                // sidechain (plugin)

Stream & persist

js
// stream to network — encode/playback during decodefor await (let chunk of audio('2hour-mix.flac').highpass(40)) socket.send(chunk[0].buffer)
// serialize edits, restore laterlet json = JSON.stringify(a)     // { source, edits, ... }let b = audio(JSON.parse(json))  // re-decode + replay edits

API

Create

Method                        Description                                                                                                                        
audio(source, opts?)decode from file, URL, bytes, or a byte stream. Returns instantly — decodes in background; streams decode as they arrive.
audio.from(source, opts?)wrap existing PCM, AudioBuffer, silence, or function. Sync, no I/O.
js
let a = audio('voice.mp3')                // file pathlet b = audio('https://cdn.ex/track.mp3') // URLlet c = audio(inputEl.files[0])           // Blob, File, Response, ArrayBufferlet d = audio()                           // empty, ready for .push() or .record()let e = audio([intro, body, outro])       // concat (virtual, no copy)let f = audio([a, b, c], { crossfade: 2 })  // concat with 2s crossfadelet g = audio(process.stdin)              // byte stream: pipe, socket, fetch body – decodes as it arrives// opts: { sampleRate, channels, crossfade, curve, storage: 'memory' | 'persistent' | 'auto' }
await a    // await for decode — if you need .duration, full stats etc
let a = audio.from([left, right])                 // Float32Array[] channelslet b = audio.from(3, { channels: 2 })           // 3s silencelet c = audio.from(t => Math.sin(440*TAU*t), { duration: 2 })  // generatorlet d = audio.from(audioBuffer)                   // Web Audio AudioBufferlet e = audio.from(int16arr, { format: 'int16' }) // typed array + format

Properties

Property                        Description                                                                                                                        
.durationtotal seconds, after edits.
.channelschannel count.
.sampleRatesample rate.
.lengthsamples per channel.
.currentTimewhat the speakers play now, in seconds: their latency compensated, smooth; at pause it holds where playback resumes.
.playingtrue during playback.
.pausedtrue when paused.
.volume0..1 linear. Settable.
.mutedmute, independent of volume. Settable.
.loopsettable, mid-playback too: the span repeats, each seam a 10 ms equal-power crossfade.
.playbackRate0.0625..16, settable mid-playback, click-free. The pitch stays (WSOLA, as a browser's media element plays at a speed) unless .preservesPitch is false: then it glides over ~50 ms and the pitch follows, tape-style. .speed() bakes it.
.preservesPitchtrue: at a rate other than 1, the pitch kept. false: varispeed, the pitch with the speed. Settable mid-playback.
.endedtrue when playback reached the end, not after stop().
.seekingtrue during a seek.
.playedpromise, resolves when playback sounds.
.recordingtrue during mic recording.
.readypromise, resolves when fully decoded.
.sourceoriginal source.
.bitDepthstored sample depth of the source: 16, 24, 32 (float); null for lossy or generated audio. Lossless save keeps it.
.pagesFloat32Array page store.
.statsper-block stats (peak, rms, etc.).
.editsedit list.
.versionincrements on each edit.

Structure

Method                        Description                                                                                                                        
.trim(threshold?)strip leading/trailing silence (dB, default auto). On a live stream a given threshold streams, holding a silent tail until sound resumes; the automatic one reads the whole input, so it waits for the end.
.shrink(gap?, threshold?)shorten silent pauses to gap seconds (default 0.3); 0 removes them. Streams with a given threshold, like trim.
≡ FFmpeg silenceremove, Audacity truncate-silence
.crop({at, duration})keep range, discard rest.
.remove(at, duration, crossfade?)delete range, close gap. crossfade ('10ms') makes the splice an equal-power crossfade centered on the cut; the length stays the same.
.insert(source, at?, crossfade?)insert audio (default: at end), or a number of seconds of silence; crossfade fades both seams.
.copy({at?, duration?})copy range (default: all) to this instance's clipboard.
.cut({at?, duration?, crossfade?})copy, then remove.
.paste({at?, crossfade?})insert the clipboard (default: at end).
.move({at, duration, to, crossfade?})slide a range to to, over what is there; silence where it was, the length kept (past the end, extended). crossfade crossfades each edge, centered.
≡ a DAW's clip moved in slip mode
.clip({at, duration})zero-copy excerpt as a new instance.
.split(...offsets)zero-copy excerpts between timestamps.
.pad(before, after?)silence at edges (seconds).
.repeat(n)repeat n times.
.reverse({at?, duration?})reverse audio or range.
.speed(rate)changes pitch and duration together.
.stretch(factor, {voice?})changes duration, keeps pitch (phase-locked vocoder). A t => f or {t, v} factor slides the tempo; duration becomes ∫factor dt. A range {at, duration} comes out round(round(duration · sampleRate) · factor) samples long, to the sample; the audio around it as it was. { voice: true } keeps a voice's pulse shape and consonants, which the vocoder makes distant: shortened, its waveform copied a segment at a time (WSOLA, @audio/stretch-wsola); slowed, the vocoder's frames restarted from the waveform where it fits (PVSOLA, @audio/stretch-pvsola), so breath and reverberation are not repeated into a flanger. One voice, not chords.
≡ Logic Flex Time Monophonic, Ableton Tones
.warp(markers)move moments in time: [[from, to], …] in seconds. Between markers the audio stretches to fit, pitch kept; start and end stay.
≡ Logic Flex Time, Ableton warp markers
.pitch(semitones, {voice?})changes pitch, keeps duration. Semitones may be a curve {t, v} (seconds → semitones, straight between points, flat past the ends, as the gain line's) or t => semitones; where it is zero the audio is as it was. { voice: true } re-spaces a voice's own glottal cycles (TD-PSOLA, the optional @audio/tune-curve): formants and consonants kept, one voice.
≡ Melodyne pitch drawing
.intonation(factor)a voice's rises and falls wider or flatter about its median pitch: 1 as it was, 0 a monotone, 2 twice as wide. Its own cycles re-spaced (as pitch({ voice: true })): timing, formants and consonants kept, one voice.
≡ Melodyne pitch modulation, Praat's pitch range factor
.formant(semitones)moves the formants (the spectral envelope: a voice's vowels, the size of its head), keeps the pitch. A number, a curve {t, v} or t => semitones. Any sound; with pitch(), a voice kept its own or made another's.
≡ Melodyne formant tool, Praat's formant shift ratio
.remix(channels)channel count (down per ITU-R BS.775: 7.1 → 5.1, stereo, mono), or a map: [1, 0] swaps L/R, null a silent channel.

Every op takes a trailing {at, duration, channel} range, except channel-changing remix and crossover. Times are seconds or strings ('1:30', '2m'); negative counts from the end. FFmpeg's short names work wherever the long ones do: d for duration, xfade for crossfade (a.remove({ at: 1, d: 0.5, xfade: 0.01 })).

js
a.trim(-30)                               // strip silence below -30dBa.remove({ at: '2m', duration: 15 })      // delete 2:00–2:15, close gapa.remove(12.3, 0.4, '10ms')               // cut a breath, crossfaded: no clicka.insert(intro, { at: 0 })                // prepend; .insert(3) appends 3s silencea.copy(60, 30).paste(120)                 // duplicate the chorus at 2:00a.cut(2, 1).paste(5)                      // move 2s–3s to 5s of the shortened timelinea.move({ at: 2, duration: 1, to: 5 })     // slide 2s–3s over 5s–6s, silence left at 2s–3slet [pt1, pt2] = a.split('30m')           // zero-copy partslet hook = a.clip({ at: 60, duration: 30 })  // zero-copy excerpta.stretch(1.1)                            // 10% longer, same pitcha.warp([[1, 1], [2, 2.4], [3, 3]])        // the hit at 2s lands at 2.4s; 1s–3s keeps its lengtha.pitch(-2)                               // 2 semitones down, same tempoa.pitch({ t: [1, 1.2, 2, 2.2], v: [0, 3, 3, 0] }, { voice: true })  // a note drawn 3 semitones up, formants kepta.intonation(1.5, { at: 2, d: 3 })        // 2s–5s: every rise and fall half as wide againa.formant(-2)                             // a larger, darker voice on the same notesa.remix([0, 0])                           // L→both; .remix(1) for mono

Process

Method                        Description                                                                                                                        
.gain(dB, opts?){ unit: 'linear' } takes a multiplier.
.fade(in, out?, curve?)curves 'linear' 'exp' 'log' 'cos', as functions in audio.op('fade').curves. {start, end} levels 0..1 fade between any levels (a duck); {mid} skews the half-amplitude point.
≡ Audacity adjustable-fade
.normalize(target?, mode?)remove DC, normalize. Loudness targets hold a true-peak ceiling, -1 dBTP by default: a lookahead limiter, then the loudness it took made back up. Presets per Apple Podcasts, Spotify, EBU R 128 (ITU-R BS.1770-4):
'podcast' -16 LUFS
'streaming' -14 LUFS
'broadcast' -23 LUFS
-18, 'lufs' any loudness; -3 peak dB; no arg: peak 0 dBFS; 'rms' mode
an audio instance: its integrated loudness
{ ceiling: -2 } dBTP, false off
{ dc: false } keep DC
{ adaptive: true } on a live stream, start at once: the gain follows what it has heard, the ceiling (the target itself in peak mode) guards what it hasn't. Without it, one gain for the whole selection: a live stream waits for its end.
≡ FFmpeg loudnorm
.roomtone(threshold?)fill digital silence (≥ 10 ms under -90 dBFS: edited-out pauses, pad()) with the recording's own room tone. .trim().pad(1.5, 2).roomtone() gives an audiobook chapter its room tone at each end (ACX rejects digital silence).
≡ iZotope RX Ambience Match
.mix(source, at?, gain?)overlay at at seconds, source level gain dB.
≡ FFmpeg amix weights
.crossfade(source, duration?, curve?)append with overlap, default 0.5s. 'cos' (default) suits similar material; 'equal' (equal-power) keeps loudness across unrelated tracks; each in audio.op('crossfade').curves. With no source, .crossfade({ at, duration }) crossfades across the range, as an editor crossfades a selection: the audio before it fades into the audio after it, and the range goes.
≡ FFmpeg acrossfade
.pan(value, opts?)−1 left, 0 center, 1 right.
.write(data, {at?})overwrite from at with raw PCM or another sound, as a tape records over what is there; what runs past the end extends it.
.transform(fn)inline (input, output, ctx) => void. Not serialized.
js
a.gain(-3)                                // reduce 3dBa.gain(6, { at: 10, duration: 5 })        // boost rangea.gain(t => -12 * Math.cos(t * TAU))      // automate over timea.fade(0.5, -2, 'exp')                    // 0.5s in, 2s exp fade-outa.normalize('podcast')                    // -16 LUFS, -1 dBTPa.normalize(-27, 'lufs', { ceiling: -2 }) // Netflixa.mix(voice, { at: 2 })                   // overlay at 2sa.mix(bed, 0, -18)                        // music bed, 18 dB undera.crossfade(next, 2)                      // 2s crossfade into nexta.crossfade(song2, 4, 'equal')            // equal-power, for unrelated tracksa.pan(-0.3, { at: 10, duration: 5 })      // pan left for range

Filter

Method                        Description                                                                                                                        
.highpass(freq, order?), .lowpass(freq, order?)Butterworth pass filter; even integer order ≥ 2: 2 (12 dB/oct, default), 4 (24), 6, 8, … Other orders are rejected.
.bandpass(freq, Q?), .notch(freq, Q?)band-pass / notch.
.allpass(freq, Q?)phase shift, unity magnitude.
.lowshelf(freq, dB), .highshelf(freq, dB)shelf EQ.
.eq(freq, gain, Q?)parametric EQ.
.filter(type, ...params)by type name, or a custom filter function.

All biquads.

js
a.highpass(80).lowshelf(200, -3)          // rumble + muda.eq(3000, 2, 1.5).highshelf(8000, 3)     // presence + aira.notch(50)                               // remove huma.allpass(1000)                           // phase shift at 1kHza.filter(customFn, { cutoff: 2000 })      // custom filter function

Effect

Method                        Description                                                                                                                        
.vocals(mode?, {model?})mid/side: 'isolate' (default) keeps center, 'remove' keeps sides. model separates with a trained model instead, 'umxhq' (Open-Unmix, MIT weights) or 'htdemucs' (Hybrid Transformer Demucs, higher SDR, weights for research only), through the optional @audio/neural-separate; 'remove' then subtracts the model's vocals. Weights are exported locally (how) or served from weights.
≡ SoX oops; Demucs, Open-Unmix
.dither(bits?, {shape?})TPDF, default 16-bit. shape: true adds 2nd-order noise shaping: quantization noise moves above ~Nyquist/2, audibly quieter.
.crossfeed(freq?, level?)headphone crossfeed, default 700 Hz, 0.3.
≡ SoX earwax, bs2b
.resample(rate, {type?})upsampling defaults to linear, downsampling to anti-aliased windowed sinc, its taps widening with the ratio. type: 'sinc' or 'linear' forces one.
.crossover(...freqs)N split frequencies → N+1 bands × channels, band-major. Linkwitz-Riley 4th order; bands sum back flat.
≡ FFmpeg acrossover
.match(ref, amount?)match EQ: up to 8 parametric bands fit to the reference/source spectrum ratio. Tone only; loudness stays with normalize. Streams {lookahead} s behind (10), refitting as it hears more. { midside: true } matches a stereo pair's mid and side apart, and the side level to the reference's width.
≡ iZotope Ozone Match EQ
.master(ref, opts?)master to a reference track: match in mid and side, then normalize to the reference's integrated loudness under -1 dBTP ({ ceiling }).
≡ Matchering
.spectral(band?, gain?, {at, duration})gain on a time × frequency region, band = [lo, hi] Hz; default removes it.
≡ Audacity spectral edit, FFmpeg afftfilt
.repair(band?, {at, duration, method?, window?})rebuild a damaged range (dropout, beep, click burst) from its surroundings. method 'auto' (default) transplants the passage that joins seamlessly, searched in the window s (10) before the range; failing that, AR interpolation up to 70 ms, a sinusoidal bridge beyond. 'ar', 'sinusoidal', 'similarity', 'spectral' force one.
≡ iZotope RX Spectral Repair
.denoise(reduction?, threshold?, {noise})remove a noise that holds still (hiss, hum and buzz, a fan, room tone, tape), learned where it plays alone: noise is that { at, duration } of the op's input, or several, or a print saved from stat('print'). It goes reduction dB down (12) everywhere, or in the op's own { at, duration }, or only in a band [low, high] Hz, the rest as it was; what stays is the same noise, quieter, without musical tones. threshold (dB) raises the print: more of the quiet counts as noise. OM-LSA on the held noise (@audio/denoise-omlsa), each channel its own print; a live source renders once the range has arrived. VoiceBank+DEMAND PESQ, the noise learned from the half second before each speaker starts: noisy 1.97, omlsa() 2.40, denoise() 2.48. For noise that moves: omlsa(), deepfilter().
≡ iZotope RX Spectral De-noise (Learn), Adobe Audition Noise Reduction (noise print), Audacity Noise Reduction
.deepfilter(limit?, floor?, {weights?, device?}), .rnnoise(limit?)neural speech denoising through the optional @audio/neural-denoise: it also removes noise that moves (keys, traffic, a busy room). deepfilter runs DeepFilterNet3, its 8 MB model downloaded once, over the whole input before rendering; rnnoise streams RNNoise, weights in the package, 30 ms behind. deepfilter takes the noise limit dB down (12), or further, to floor dB under the voice's loudness (−45; false: the limit only): a narration keeps its room tone, noisy speech loses its noise. VoiceBank+DEMAND PESQ: noisy 1.97, wiener() 2.19, rnnoise() 2.46, deepfilter({ floor: false }) 2.67, deepfilter() 3.10, deepfilter(0) 3.16. limit 0 lifts the limit; rnnoise's 20 keeps it from removing the voice. Speech only: both drop music and singing.
≡ DeepFilterNet, RNNoise
js
a.vocals()                                // isolate center-panned vocalsa.vocals('remove')                        // remove vocals (karaoke)a.vocals({ model: 'umxhq' })              // vocals by a separation modela.dither(16)                              // TPDF dither to 16-bita.dither(16, { shape: true })             // noise-shapeda.crossfeed()                             // headphone crossfeeda.resample(48000)                         // resample to 48kHz (linear)a.resample(96000, { type: 'sinc' })       // high-quality windowed-sinca.match(reference, 0.7)                   // 70% of the way to its tonea.spectral([1000, 4000], -30, { at: 2.1, duration: 0.3 })  // a cougha.repair({ at: 1.2, duration: 0.05 })     // a dropouta.repair({ at: 42, duration: 1 })         // a lost second of music: the passage that fitsa.denoise({ noise: { at: 1.2, duration: 0.5 } })  // hiss learned from a pause, 12 dB down everywherea.deepfilter()                            // speech out of noise: the noise 45 dB under the voice, room tone kepta.rnnoise()                               // the same, streaming

I/O

Method                        Description                                                                                                                        
await .read(opts?)rendered PCM. { format, channel } to convert. A source still arriving is waited for: a range until it has arrived, all of it until the end (an endless stream: read ranges, or stream()).
await .save(path, opts?)encode + write, format from extension. Lossless keeps the source depth; { bitDepth, bitrate, quality, codec } set the encoder; m4a and mp3 write markers as chapters. Output streams as it encodes, headers patched with their totals at the end (a pipe keeps them "unknown"); m4a from a live source is fragmented. A video source saved to .mp4/.mov keeps its picture: only the audio track changes. .edl, .otio, .fcpxml write the cuts.
await .encode(format?, opts?)encode to Uint8Array; 'edl', 'otio', 'fcpxml': the cuts, as UTF-8.
await .cuts(format?, opts?)the edits as a cut list for a video editor: 'edl' (CMX 3600: Premiere, Resolve, Avid), 'fcpxml' (Final Cut Pro, Resolve), 'otio' (OpenTimelineIO); none gives { fps, clips: [{ at, duration, from, rate, source }] }. Cuts, moves, gaps and inserted files place the clips; speed, stretch and warp set their rate; markers go along. Processing is not in the list: lay the processed audio under the picture. Cuts land on the video track's frames (MP4/MOV), else { fps } (30), each within half a frame of the sound; its timecode track starts the source times, drop-frame as it counts ({ dropFrame }). The CLI's save cuts.edl writes one.
.clone()independent edits, shared pages.
.push(data, format?)feed PCM into a pushable instance; .stop() finalizes.
js
let pcm = await a.read()                              // Float32Array[]let raw = await a.read({ format: 'int16', channel: 0 })for await (let block of a) send(block)                 // async-iterable over blocksawait a.save('out.mp3')                                // format from extensionawait a.save('book.mp3', { bitrate: 192 })             // ACX: 192 kbps CBRawait a.save('master.wav', { bitDepth: 24 })           // 24-bitawait a.save('talk.mp4')                               // video in, video outlet bytes = await a.encode('flac')                     // Uint8Arraylet b = a.clone()                                      // independent copy, shared pages
let src = audio()                                      // pushable sourcesrc.push(buf, 'int16')                                 // feed PCMsrc.stop()                                             // finalize

Playback / Recording

Method                        Description                                                                                                                        
.play(opts?){ at, duration, loop, volume, rate, paused }. at defaults to currentTime (the start once ended); playing already, it jumps there without a gap.
.play({ from: b })take over b's playback where it is (its span, loop, volume, rate, pause), crossfaded, no gap; b stops.
.pause(), .resume(), .seek(t), .stop()each ramps over 5 ms, none clicks; seek crossfades, and in a loop stays in its span. stop() also ends recording.
.record(opts?)mic. { deviceId, sampleRate, channels }.
audio.contextthe page's one AudioContext, which playback uses: made on first use, resumed by the first gesture; set your own before playing.

Playback renders up to 2 s ahead into an AudioWorklet on audio.context (Node: @audio/speaker), so a busy main thread doesn't stop it, and sounds within milliseconds of play() (the device's own latency aside). An edit to the playing instance is heard ~50 ms later where it happens: the audio rendered ahead gives way, crossfaded. A source still arriving (decoding, pushed) plays what has come and goes on as more comes. Any channel count plays as it is; the device downmixes.

js
a.play({ at: 30, duration: 10 })          // play 30s–40sawait a.played                            // wait for sounda.volume = 0.5; a.loop = true             // live adjustmentsa.muted = true                            // mute without changing volumea.playbackRate = 1.5                      // faster, the pitch kepta.preservesPitch = false                  // the pitch follows the speed, as a tape'sa.pause(); a.seek(60); a.resume()         // jump to 1:00a.highpass(80)                            // an edit while playing: heard where it happensb.play({ from: a })                       // b takes over at the same place, crossfadedb.stop()                                  // end playback or recording
await audio.context.audioWorklet.addModule('./scrub.js')  // your own nodes, on the same contextlet scrub = new AudioWorkletNode(audio.context, 'scrub')
let mic = audio()mic.record({ sampleRate: 16000, channels: 1 })mic.stop()

Metering

Method                        Description                                                                                                                        
.meter(what, cb?)live per-block stats of what plays, delivered as it is heard: rms, peak, ms, min, max, dc, clipping, spectrum, or your own. Without cb, read .value. Returns { value, stop() }. The same on a worker facade: measured in the worker, delivered on the page.
Option                        Description                                                                                                                        
typestat name, array of names, or omit for all block stats.
channeln for one channel, [n, m] per-channel, or omit for scalar avg (mirrors a.stat()).
smoothingone-pole EMA time constant τ, in seconds.
holdpeak-hold decay τ, in seconds.
bins, fMin, fMaxspectrum resolution and range (when type: 'spectrum').
js
a.meter('rms', v => draw(v))                                       // scalar avg across channelsa.meter(['rms', 'peak'], v => draw(v))                             // { rms, peak }a.meter({ type: 'rms', channel: [0, 1] }, v => draw(v))            // [L, R]a.meter({ type: 'spectrum', bins: 64, smoothing: 0.15 }, drawFFT)  // Float32Array of mel binsa.meter({}, ({ delta, offset }) => draw(delta))                    // no type → all block stats
let m = a.meter({ type: 'rms' })                                   // pull formrequestAnimationFrame(function tick() { draw(m.value); requestAnimationFrame(tick) })m.stop()                                                           // release

Analysis

Method                        Description                                                                                                                        
await .stat(name, opts?)one value; with { bins: n } a Float32Array, the value over each of n spans of the range: where, not only how much (lists, a key and spectra come whole; bins sizes spectrum and cepstrum); an array of names gives an array. { channel: n } one channel, [n, m] per channel; {at, duration} sub-range.
await .detect(opts?){ bpm, confidence, beats, onsets } in one pass; { channel } as in stat.
await .check(spec)pass or fail against a delivery spec: { pass, rules: [{ name, value, unit, min, max, pass }] }. 'acx' (RMS, peak, noise floor, room tone, 44.1 kHz), 'podcast' (Apple: -16 LUFS ±1, ≤ -1 dBTP), 'streaming' (Spotify: plays at -14 LUFS, ≤ -1 dBTP), 'broadcast' (EBU R 128: -23 ±0.2 LUFS, ≤ -1 dBTP), 'netflix' (dialog -27 ±2 LUFS, ≤ -2 dBTP). Each limit cites its source in fn/check.js.
Stat                        Description                                                                                                                        
'db'peak amplitude in dBFS.
'rms'RMS amplitude, linear (the CLI prints dBFS).
'noisefloor'RMS of the quietest 0.4 s, dB: the room between words (ACX Check's measure, sample-exact).
'print'the noise print of a range, as denoise({ noise }) takes it: dB in 1025 bands 23.4375 Hz apart, 0 to 24 kHz (white noise of RMS 0.01 prints −40).
'peak'max(|min|, |max|), linear.
'loudness'integrated LUFS (ITU-R BS.1770-4; surround channels weighted, LFE excluded).
'momentary', 'shortterm'maximum 400 ms / 3 s loudness, LUFS (EBU Tech 3341).
'dialog'loudness of the speech only, LUFS: speech found automatically (AES TD1008 dialog loudness).
'dc'DC offset.
'clipping'clipped samples, at 16-bit full scale (±32767/32768) or beyond (scalar: timestamps, binned: counts).
'silence'silent ranges as {at, duration}.
'crest'peak/RMS in dB. Sine ≈ 3dB, square ≈ 0dB.
'centroid'spectral centroid in Hz (brightness).
'flatness'spectral flatness: 0 tonal, 1 noise.
'correlation'L/R phase correlation, −1 to +1. Mono returns 1.
'max', 'min'peak envelope per bin, for waveforms.
'spectrum'mel spectrum in dB (A-weighted); of several channels, their mean power.
'cepstrum'MFCCs.
'bpm'tempo.
'beats', 'onsets'timestamps as Float64Array (seconds).
'hits'where the level jumps, up (a strike) or down (a stop), as Float64Array (seconds): each at its attack's zero crossing, the sample to cut at, where 'onsets' reads 23 ms blocks.
'notes'[{time, duration, freq, midi, note, clarity}] (pYIN + Tony note HMM); with robust: true, the same through noise and rooms (a neural pYIN stage 1); with poly: true, polyphonic [{time, duration, freq, midi, note, velocity, bends}] (Basic Pitch).
'chords'[{time, duration, label, root, quality, bass, confidence}]: Chordino on NNLS chroma (Mauch & Dixon 2010), matched to the reference plugin; labels like 'Am', 'G7', 'C/E', 'N'.
'key'{tonic, mode, label, confidence} (Krumhansl-Schmuckler).

Opts: bpm, beats, onsets take { minBpm, maxBpm, delta, frameSize, hopSize }; notes takes { minFreq, maxFreq, frameSize, hopSize, minDuration }; chords, key take { frameSize, hopSize, tuning } (frames of 16384 samples at 44.1 kHz, 0.34 to 0.51 s at other rates, every eighth of a frame; concert A read from the audio unless tuning in Hz is given); chords also boostN (no-chord bias, 0.1); key also method: 'nnls' | 'pcp'. chords needs @audio/mir-nnls-chroma and @audio/mir-chordino, key needs @audio/mir-nnls-chroma: GPL-2.0-or-later translations of the reference plugins, installed by choice (npm i @audio/mir-nnls-chroma @audio/mir-chordino); key with method: 'pcp' needs only the MIT @audio/mir-chroma and @audio/mir-key, installed with audio unless optional dependencies are skipped. notes with robust: true needs @audio/neural-pitch (weights inside): a network's pitch candidates in place of YIN's keep the notes where YIN loses them (Vocadito onsets F 0.76 against 0.53 at 0 dB SNR) and trail it slightly on clean audio, so YIN stays the default. notes with poly: true takes { minFreq, maxFreq, minDuration, onsetThreshold, frameThreshold } and needs @audio/neural-transcribe, whose model downloads on first use; bends are cents from the note's pitch per 11.6 ms frame, in 33.3-cent steps (in-tune notes read 0).

js
let loud = await a.stat('loudness')                       // LUFSlet [db, clips] = await a.stat(['db', 'clipping'])        // multiple at oncelet spec = await a.stat('spectrum', { bins: 128 })        // frequency binslet [min, max] = await a.stat(['min', 'max'], { bins: 800 }) // peak envelope for canvas renderingawait a.stat('rms', { channel: 0 })                       // left only → numberawait a.stat('rms', { channel: [0, 1] })                  // per-channel → [n, n]let gaps = await a.stat('silence', { threshold: -40 })    // [{at, duration}, ...]let bpm = await a.stat('bpm')                             // 120.5let beats = await a.stat('beats')                         // Float64Array [0, 0.5, 1, ...]let { bpm, confidence, beats, onsets } = await a.detect() // full pipeline, one passlet notes = await a.stat('notes')                         // [{time, duration, freq, midi, note: 'A4', clarity}]let chords = await a.stat('chords')                       // [{time, duration, label: 'Am', confidence}]let k = await a.stat('key')                               // {label: 'C', mode: 'major', confidence}

Meta

Property                        Description                                                                                                                        
.metatags: {title, artist, album, year, bpm, key, comment, pictures, raw, ...}. Writable. meta.raw holds format-specific blocks untouched (WAV bext/iXML, ID3v2 frames, FLAC blocks).
.meta.picturescover art [{mime, type, description, data, url}]. .url is a lazy Blob URL (browser) or data URL (Node).
.markers[{time, label}] in output seconds; edits shift or drop them.
.mark(time, label?)a marker at time, seconds of the audio as edited so far; {at, duration}, a region. Later edits carry it. Chainable.
.regions[{at, duration, label}]; edits shift or drop them.

Parsed on decode, written on save; round-trips WAV, MP3, FLAC.

js
let a = await audio('song.mp3')a.meta.title                     // 'Track Name'a.meta.artist = 'Me'             // mutateimg.src = a.meta.pictures[0].url // lazy Blob URL
a.crop({ at: 10, duration: 30 })a.markers                         // re-projected — outside markers dropped, inside shifted
await a.save('edited.mp3')        // tags + pictures preservedawait a.save('stripped.wav', { meta: false })   // opt out

Utility

Method                        Description                                                                                                                        
.on(event, fn), .off(event?, fn?)subscribe / unsubscribe.
.undo(n?)returns the undone edit, for redo via .run().
.run(...edits)apply ['type', opts] edits: op params (value, freq, …) plus range keys.
.dispose()release resources. Supports using.
Event                        Description                                                                                                                        
'data'pages decoded/pushed. Payload: { delta, offset, sampleRate, channels }.
'change'any edit or undo.
'metadata'stream header decoded. Payload: { sampleRate, channels, estDuration }: seconds it lasts, from the header where it says (WAV, AIFF, FLAC, an MP3's Xing or VBRI frame, a constant bitrate), else from its size; null when neither is known.
'timeupdate'playback position, as heard (~50 times a second). Payload: currentTime.
'play'playback started or resumed.
'pause'playback paused.
'volumechange'volume or muted changed.
'ended'playback ended: at its end, by stop(), or taken over by play({ from }); not in a loop.
'progress'during save/encode. Payload: { offset, total } in seconds.
js
a.on('data', ({ delta }) => draw(delta))  // decode progressa.on('timeupdate', t => ui.update(t))     // playback position
a.run(  ['gain', { value: -3, at: 10, duration: 5 }],  ['crop', { at: 1, duration: 2 }],  ['fade', { in: 1, curve: 'exp' }],  ['insert', { source: ref, at: 2 }],)a.undo()                                  // undo last editb.run(...a.edits)                         // replay onto another fileJSON.stringify(a); audio(json)            // serialize / restore

Plugins

Method                        Description                                                                                                                        
audio.use(...plugins)register an @audio contract factory, a stat { stat, compute }, a codec { codec, test?, decode?, encode? }, a function receiving audio, or a registry name. Registry plugins need no use: a.compressor() and a.stat('truepeak') load them on first use.
audio.op(name, descriptor)register an op: a process function or { params, process, plan, resolve }.
audio.op(name?)one descriptor, or all ops.
audio.stat(name, descriptor)register a stat: (chs, ctx) => [...] or { block, reduce, query }.

Plugins also run without the engine: audio/batch over a whole signal, audio/stream over live chunks. Plugin tutorial.

js
import { compressor } from '@audio/dynamics-compressor/audio'audio.use(compressor)                       // bring-your-own factory
a.freeverb({ room: 0.8 })                   // registry plugin: loads on first render; tail composesmusic.ducker({ key: voice })                // sidechain via the key optionawait a.stat('truepeak')                    // stat plugins land on a.stat()
audio.op('crush', { params: ['bits'], process: (input, output, ctx) => {  let steps = 2 ** (ctx.bits ?? 8)  for (let c = 0; c < input.length; c++)    for (let i = 0; i < input[c].length; i++)      output[c][i] = Math.round(input[c][i] * steps) / steps}})a.crush(4)                                  // custom op, chainable like built-ins

Worker

Call                        Description                                                                                                                        
audioWorker(source, opts?)same API, engine in a Worker; the main thread keeps a few-KB facade.
audio(source, { worker: true })same, once audio/worker is imported.
{ worker: new Worker(url) }your own worker entry: codecs, plugins, your own code and messages, then audio/worker, which talks on a port of its own.
expose(a) → id, audioWorker.adopt(id, { worker })hand an instance your worker made to the page as a facade.
audioWorker.contextthe page's AudioContext, the same as audio.context.

Across the boundary clip(), split(), clone() return promises; op errors emit 'error'; functions don't cross, use {t, v} curves. play() renders in the worker straight into the page's AudioWorklet: the main thread can stall for seconds without a dropout. Architecture.

js
import audioWorker from 'audio/worker'let a = audioWorker('track.mp3')            // decode/edits/stats/encode in a Workera.gain(-3).fade(0.5)let [mins, maxs] = await a.stat(['min','max'], { bins: 640 })  // transferred, zero-copya.play()                                    // rendered in the worker, played by an AudioWorklet (Node: @audio/speaker)
// your own worker: its messages stay its own, and what it makes plays on the pageimport audio from 'audio'                   // worker.jsimport { expose } from 'audio/worker'self.onmessage = ({ data }) => self.postMessage({ out: expose(audio(data.file).gain(-3)) })
let worker = new Worker('./worker.js', { type: 'module' })   // pageworker.onmessage = ({ data }) => audioWorker.adopt(data.out, { worker }).play()

CLI

npm i -g audio, or without installing: npx audio …

sh
audio [source] [transforms...] [sink] [options]

A pipeline: a source produces audio, transforms reshape it, a sink consumes it. The default sink is stat — printing an overview.

sh
# sourcesFILE         path, URL, or glob  ('*.wav' for batch)-            stdin (or omit when piping)record       capture from microphone
# transforms (chained left-to-right)gain         fade        trim        normalize   cropclip         remove      reverse     repeat      padspeed        stretch     pitch       insert      mixcrossfade    remix       pan         split       resamplehighpass     lowpass     eq          lowshelf    highshelfnotch        bandpass    allpass     vocals      dithercrossfeed    shrink      crossover   match       spectralrepair       copy        cut         paste
# sinks (terminate the chain — at most one)stat [NAMES...]    print analysis (default)play [loop]        open player UIsave PATH          encode and write (or `-` for stdout); `192k` bitrate, `24bit` depth
# options-f --force         overwrite existing output--format FMT       override output format--macro FILE       apply edits from JSON--cue FILE         split at cue-sheet tracks (with split)--verbose          show progress--help, -h         help (or per-op: `audio gain --help`)--mcp              serve the CLI to AI agents as an MCP tool (stdio)
# named options, after an op or sink: name:valuenormalize -27 lufs ceiling:-2     ducker key:voice.wav     save out.m4a codec:alac
# compatibility shortcuts-p ⇔ play     -l ⇔ play loop     -o PATH ⇔ save PATH

Playback

[Audiojs demo]

␣ pause · ←/→ seek ±10s · ⇧←/⇧→ seek ±60s · ↑/↓ volume · l loop · s save as · q quit

sh
# play full songaudio song.mp3 play
# play fragmentaudio song.mp3 10s..15s play
# play and loop a hookaudio song.mp3 30s..45s play loop
# play with effects applied live (streamable ops)audio song.mp3 normalize broadcast highpass 80hz play

Edit

sh
# clean upaudio raw-take.wav trim -30db normalize podcast fade 0.3s -0.5s save clean.wav
# scope a range (applies to whole chain)audio in.wav 1s..10s gain -3db save out.wav
# range on a single opaudio in.wav gain -3db 1s..10s save out.wav
# filter chainaudio in.mp3 highpass 80hz lowshelf 200hz -3db save out.wav
# concataudio intro.mp3 + content.wav + outro.mp3 trim normalize fade 0.5s -2s save ep.mp3
# crossfade into nextaudio track1.mp3 crossfade track2.mp3 2s save mixed.wav
# voiceoveraudio bg.mp3 gain -12db mix narration.wav 2s save mixed.wav
# music bed ducked under the voice (sidechain)audio bed.mp3 ducker key:voice.wav mix voice.wav save episode.wav
# loudness to any target, true peak held; delivery settingsaudio book.wav normalize -20 lufs save book.mp3 192k
# fix a video's sound, keep the pictureaudio talk.mp4 highpass 80hz 4 normalize podcast save talk.clean.mp4
# master to a reference track (tone, width, loudness)audio mix.wav master reference.wav save master.wav
# shorten pauses; the same cuts as an EDL for the video editor (.fcpxml, .otio too)audio talk.mp4 shrink 0.3 save talk.edl
# splitaudio audiobook.mp3 split 30m 60m save 'chapter-{i}.mp3'audio album.wav split --cue album.cue save '{i} - {title}.mp3'   # cue-sheet tracks, tagged
# recordaudio record 30s save voice.wav

Analysis

sh
# overview (default sink)audio speech.wav
# range overview — `audio FILE 0..10s` ⇔ `audio FILE stat 0..10s`audio speech.wav 0..10s
# specific statsaudio speech.wav stat loudness rms
# tempo / beat grid / onsetsaudio track.mp3 stat bpmaudio track.mp3 stat beats onsets
# loudness to spec: integrated, max momentary / short-term, speech only, true peakaudio mix.wav stat loudness momentary shortterm dialog truepeak
# pass or fail against a delivery spec (exit 1 on a fail); --json for scriptsaudio episode.wav normalize podcast check podcastaudio chapter.wav check acx --json
# pitch / chords / keyaudio song.mp3 stat notesaudio song.mp3 stat chordsaudio song.mp3 stat key
# spectrum / cepstrum with bin countaudio speech.wav stat spectrum 128audio speech.wav stat cepstrum 13
# stat after transforms (transforms apply, then stat)audio speech.wav gain -3db stat db

Batch

sh
audio '*.wav' trim normalize podcast save '{name}.clean.{ext}'audio '*.wav' gain -3db save '{name}.out.{ext}'audio 'chapters/*.mp3' check acx                      # a whole audiobook: one line per chapter

Stdin/stdout

sh
cat in.wav | audio gain -3db save -      > out.wavcurl -s https://ex.com/speech.mp3 | audio normalize save clean.wav

Tab completion

sh
eval "$(audio --completions zsh)"       # add to ~/.zshrceval "$(audio --completions bash)"      # add to ~/.bashrcaudio --completions fish | source       # fish

FAQ

What formats are supported?
Decode: WAV, MP3, FLAC, OGG Vorbis, Opus, AAC, AIFF, CAF, WebM, AMR, WMA, QOA via decode. Encode: WAV, MP3, FLAC, Opus, OGG, AIFF via encode. Codecs are WASM-based, lazy-loaded on first use.
Does it need ffmpeg or native addons?
No, pure JS + WASM. For CLI, you can install globally: npm i -g audio.
How big is the bundle?
~20K gzipped core. Codecs load on demand via import(), so unused formats aren't fetched.
How does it handle large files?
Audio is stored in fixed-size pages. In the browser, cold pages can evict to OPFS when memory exceeds budget — auto-sized from navigator.storage.estimate() (quota/4, 64MB..2GB), overridable via {budget}. Stats stay resident (~7 MB for 2h stereo).
Are edits destructive?
No. a.gain(-3).trim() pushes entries to an edit list — source pages aren't touched. Edits replay on read() / save() / for await.
Can I use it in the browser?
Yes, same API. See Browser for bundle options and import maps.
Does it need the full file before I can work with it?
No. Playback, edits, and structural ops (crop, repeat, pad, insert, etc.) all stream incrementally during decode — output begins before the file finishes loading. The edit plan recompiles as data arrives, tracking a safe output boundary per op. Only ops that depend on total length (open-end reverse, negative at) wait for full decode.
TypeScript?
Yes, ships with audio.d.ts.
Does it have feature parity with FFmpeg / SoX / librosa?
Yes — the audiojs ecosystem covers the practical baseline of FFmpeg filters, SoX effects, librosa analysis, Pedalboard and MIREX, all as @audio/* plugins audio wires through one API (the few uncovered items are esoteric or deliberately skipped). Every effect, filter, generator and analyzer lives in the registry — call one by name and it loads on first use. Coverage matrix: docs/comparison.md.
How is this different from SoX / FFmpeg / Audacity / librosa / Web Audio / Tone.js?
In one line: audio is the only one that runs the same API in Node and the browser, with non-destructive lazy edits that stream during decode. The native tools (SoX, FFmpeg) are faster on raw throughput but have no JS API, browser, or undo; the browser libs (Web Audio, Tone.js, Howler) are real-time graphs, not file editors. Full feature and performance matrices vs pydub, librosa, aubio, essentia, Pedalboard, SoX, FFmpeg, Audacity and MATLAB are in docs/comparison.md.

Built with

MIT · ॐ

Source: README.md at commit c81e504

Tools

0
Tool metadata has not been indexed yet.

Version history

1
  1. v2.10.0LatestOct 3, 2026