Overview
mastacraf is a command-line audio mastering pipeline that applies a configurable processing chain to audio files. It is built for self-produced, self-mixed experimental electronic music — material where no commercial loudness standard applies and the goal is a repeatable, documented process you own entirely.
It does not make aesthetic decisions. You define the targets, the filters, and the dynamics behavior in a preset file. mastacraf applies them, measures the result, and writes a report documenting exactly what happened.
What it does
- Measures integrated loudness (LUFS), true peak, loudness range, RMS, crest factor, dynamic range, DC offset, phase correlation, and spectral balance
- Applies a configurable filter chain: high-pass, optional low-pass, optional compression, peak limiting, two-pass EBU R128 loudness normalization
- Outputs all files for a track into a dedicated subfolder named after the track stem
- Generates before/after spectrogram and waveform images
- Writes a JSON report with all measurements, the exact FFmpeg filter string, and input/output delta
What it does not do
- Make EQ decisions
- Automatically match loudness to a reference track
- Alter the stereo field, phase, or any aspect of the mix outside the defined chain
How it works
The two-pass loudnorm process #
The core of every master run is FFmpeg's loudnorm filter, run twice.
Pass 1 (analysis): The entire file is decoded and fed through loudnorm in measurement-only mode. No audio is written. The filter outputs a JSON block containing the file's measured integrated loudness, true peak, loudness range, and threshold.
Pass 2 (processing): The file is decoded again through the full filter chain. The loudnorm filter on this pass receives the pass-1 measurements and applies a linear gain adjustment. Because it has full-file measurements, it can apply a precise, artifact-free gain change.
Extended analysis passes #
After pass 1, three additional FFmpeg passes collect extended analysis data:
- astats pass — RMS level, peak level (for crest factor), DC offset
- aphasemeter pass — per-frame stereo phase correlation, averaged across the file; skipped for mono
- Three band-energy passes —
volumedetecton low/mid/high bandpass-filtered signal to compute spectral balance and centroid estimate
All extended passes are non-fatal. If any fail, the core analysis still completes.
Filter chain execution #
The filter chain is a comma-separated FFmpeg -af string built by build_filter_chain() in src/pipeline/process.rs. Disabled stages are omitted entirely — they do not exist in the graph and have zero overhead.
Installation
Prerequisites #
FFmpeg must be in your $PATH, or placed alongside the compiled mastacraf binary.
brew install ffmpeg rust
sudo apt install ffmpeg
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
sudo pacman -S ffmpeg rust
Build from source #
git clone <your-repo>
cd mastacraf
cargo build --release
# binary: ./target/release/mastacraf
Verify #
mastacraf --version
mastacraf --help
Quick start
mastacraf analyze track.wav
This runs the full analysis suite and prints all measurements. No audio is written. Use this to understand the material before writing a preset.
mastacraf master track.wav
Output goes to ./mastered/track/. You get the mastered file, before/after images, and a JSON report.
mastacraf master track.wav --preset noise
mastacraf master track.wav --preset film -o ./deliverables/
mastacraf master track.wav --preset mypreset --dry-run
CLI — master
Runs the full mastering pipeline: pre-analysis, pre-visualization, processing, post-analysis, post-visualization, report.
mastacraf master <INPUT> [OPTIONS]
| Flag | Default | Description |
|---|---|---|
-p, --preset | default | Preset name. Searches ./presets/<n>.toml. |
-o, --output | ./mastered/ | Base output directory. A subfolder named after the input stem is created inside it. |
--suffix | _master | Appended to input stem for the output filename. |
--lufs | from preset | Override integrated loudness target. |
--true-peak | from preset | Override true peak ceiling (dBTP). |
--no-visualize | off | Skip spectrogram and waveform generation. |
--dry-run | off | Analyze and print measurements. Write nothing. |
-v, --verbose | off | Print every FFmpeg command as it runs. |
# Basic master, all defaults
mastacraf master track.wav
# Named preset, custom output directory
mastacraf master track.wav -p film -o ./deliverables/
# Override loudness without editing the preset
mastacraf master track.wav --lufs -14
# Verify what the pipeline would do without writing anything
mastacraf master track.wav -p noise --dry-run -v
# Custom filename suffix
mastacraf master track.wav --suffix _2024_v1
# → mastered/track/track_2024_v1.wav
CLI — analyze
Runs full analysis: loudnorm pass 1 + extended passes. Prints all measurements. No audio written.
mastacraf analyze <INPUT> [OPTIONS]
| Flag | Default | Description |
|---|---|---|
--visualize | off | Generate spectrogram and waveform images. |
-o, --output | ./ | Output directory for images (only with --visualize). |
-v, --verbose | off | Print FFmpeg commands. |
[analysis]
integrated loudness -18.4 LUFS
true peak -1.2 dBTP
loudness range 14.6 LU
duration 00:07:43
format pcm_s24le / 44100 Hz / 2 ch
[extended analysis]
RMS level -22.1 dBFS
crest factor 19.7 dB
dynamic range (DR) DR17
DC offset 0.00012
phase correlation 0.741 (in phase / wide stereo)
spectral balance low 38% mid 44% high 18%
spectral centroid 1420 Hz
CLI — presets / preset
List all presets
mastacraf presets
Lists all presets found across all search directories. Prints name, LUFS target, true peak, LRA, and description.
Inspect a preset
mastacraf preset noise
mastacraf preset film > mybase.toml # copy as starting point
Preset search order
When you pass --preset name, mastacraf searches for name.toml in this order, using the first match:
./presets/relative to your current working directory (use this)<exe_dir>/presets/— next to the compiled binary (for bundled installs)~/.config/mastacraf/presets/(or platform equivalent)
Creating a preset
A preset is a TOML file that fully defines one mastering configuration. Every audio-affecting field maps directly to one FFmpeg filter parameter. There is no hidden processing between what you write in the file and what FFmpeg executes.
mastacraf analyze are the inputs to rational preset decisions. Guessing preset values without measurement produces arbitrary results.
mastacraf analyze track.wav
Note every value. Specifically: integrated loudness (how far loudnorm will push gain), loudness range (your floor for target.lra), crest factor (determines limiter attack setting), phase correlation (negative or very low means a mix problem, not a mastering problem), spectral balance (informs whether highpass matters).
This determines target.lufs and target.true_peak.
| Destination | lufs | true_peak | Notes |
|---|---|---|---|
| Streaming (general) | -16.0 | -1.0 | Avoids all platform normalization |
| Film / broadcast | -23.0 | -2.0 | EBU R128 — non-negotiable for delivery |
| Vinyl prep | -12.0 | -0.3 | Cutting lathe adds its own limiting |
| DJ pool | -9.0 | -0.5 | Club systems are loud |
| Personal / archive | -18.0 | -1.0 | More headroom, full dynamics |
cp presets/default.toml presets/mypreset.toml
Open mypreset.toml and make these specific changes:
- Set
[meta].nameto match the filename stem - Set
[target].lufsfrom the destination table - Set
[target].true_peakfrom the destination table - Set
[target].lrato the measured LRA value or higher - Set
[compressor].enabled— default isfalse, keep it that way unless you have a specific reason - Set
[limiter].attack_msbased on measured crest factor: high crest (>18 dB) → 5–10ms; low crest (<10 dB) → 0.5–2ms - Set
[output].sample_rateto 48000 for film delivery, 44100 otherwise
mastacraf master track.wav -p mypreset --dry-run
mastacraf master track.wav -p mypreset
cat mastered/track/track_master_report.json
Check the delta section. lra_change should be near zero or slightly positive. A large negative value (e.g. -4) means target.lra is set too low — loudnorm is compressing your dynamics. Raise it and re-run.
Preset field reference — [target]
These three values define the loudness signature of the master. They are all direct inputs to FFmpeg's loudnorm filter on pass 2.
mastacraf analyze.
lra = 8, loudnorm compresses 6 LU of your dynamic range away. Check delta.lra_change in the report after every master run.Preset field reference — [filters]
Preset field reference — [compressor]
true or false.Preset field reference — [limiter]
| Crest factor | attack_ms | Why |
|---|---|---|
| > 18 dB | 5–10 ms | Very transient — preserve attack shape |
| 10–18 dB | 2–5 ms | Moderate — catch peaks without dulling |
| < 10 dB | 0.5–2 ms | Dense / noise — catch everything |
Preset field reference — [output]
wav or flac. WAV is uncompressed. FLAC is lossless compressed. MP3/AAC only for platform-specific delivery when an uncompressed master already exists.Preset field reference — [visualization]
| Field | Default | Description |
|---|---|---|
enabled | true | Master switch. Overridden per-run by --no-visualize. |
spectrogram | true | Generate spectrogram image. Log-scale frequency axis. |
waveform | true | Generate waveform image. L/R channels split. |
width | 1920 | Horizontal resolution in pixels. Increase for long pieces needing time resolution. |
spectrogram_height | 512 | Vertical resolution. Increase to 768–1024 for dense spectral content. |
waveform_height | 200 | Vertical resolution. |
Analysis — core measurements (EBU R128)
target.lufs.target.true_peak.target.lra at or above this value.Analysis — extended measurements
limiter.attack_ms.
| Crest factor | Character |
|---|---|
| > 20 dB | Very transient / dynamic |
| 14–20 dB | Normal range for mixed material |
| 8–14 dB | Dense, compressed, or sustained |
| < 8 dB | Heavily limited or noise-like |
| Value | Meaning |
|---|---|
| +0.8 to +1.0 | Strong mono compatibility |
| +0.3 to +0.8 | Normal stereo |
| 0.0 to +0.3 | Wide stereo — check mono fold-down |
| -0.2 to 0.0 | Approaching out-of-phase |
| Below -0.2 | Significant phase problem |
| Range | Character |
|---|---|
| < 400 Hz | Sub/bass-dominant |
| 400–1500 Hz | Low-mid heavy |
| 1500–3000 Hz | Balanced |
| 3000–5000 Hz | Bright / presence-heavy |
| > 5000 Hz | Very bright / high-frequency dominant |
The filter chain
Structure and execution order #
Built in src/pipeline/process.rs, function build_filter_chain(). Disabled stages are omitted entirely from the FFmpeg filter graph — they have zero overhead.
Example output #
highpass=f=20.0,alimiter=level_in=1:level_out=0.891251:limit=0.891251:attack=5.0:release=50.0:asc=1,loudnorm=I=-16.0:TP=-1.0:LRA=11.0:measured_I=-18.43:measured_LRA=14.2:measured_TP=-2.1:measured_thresh=-28.7:offset=0.0:linear=true:print_format=summary
Viewing the chain used #
mastacraf master track.wav -v # prints during run
cat mastered/track/track_master_report.json # stored in report
True peak conversion #
The limiter's level_out takes linear amplitude. The tool converts target.true_peak from dBTP automatically: linear = 10 ^ (true_peak / 20)
| dBTP | Linear |
|---|---|
| -0.5 | 0.944061 |
| -1.0 | 0.891251 |
| -2.0 | 0.794328 |
| -3.0 | 0.707946 |
Output folder structure
Each master run creates a dedicated subfolder inside the base output directory, named after the input file stem.
mastered/
track01/
track01_master.wav
track01_pre_spectrogram.png individual pre-master spectrogram
track01_pre_waveform.png individual pre-master waveform
track01_post_spectrogram.png individual post-master spectrogram
track01_post_waveform.png individual post-master waveform
track01_compare_spectrogram.png pre (top) + post (bottom), amber separator
track01_compare_waveform.png pre (top) + post (bottom), amber separator
track01_diff_spectrogram.png contrast-amplified pixel difference
track01_diff_waveform.png contrast-amplified pixel difference
track01_master_report.json
track02/
...
--suffix flag affects the audio filename only, not the folder name. --suffix _v2 produces mastered/track01/track01_v2.wav.Visualization output
Six images per master run. Individual pre/post images are the raw material. Compare and diff images are the quick-review tools — open those first.
Individual visuals #
Spectrogram (_pre_spectrogram.png, _post_spectrogram.png)
FFmpeg: showspectrumpic=mode=combined:color=intensity:scale=log:legend=1 — X: time, Y: frequency (log scale), color: intensity. Legend on right edge.
Waveform (_pre_waveform.png, _post_waveform.png)
FFmpeg: showwavespic=split_channels=1 — X: time, Y: amplitude, L channel top, R channel below in distinct colors.
Stacked comparison (_compare_spectrogram.png, _compare_waveform.png) #
Pre on top, post below, amber 3px separator line between them. Both aligned on the same time axis — scan vertically to compare any moment.
[pre][post] → pad pre bottom +3px amber → vstack
Stacked spectrogram: Frequency content and brightness should be similar top and bottom. A band brighter in the bottom (post) image gained energy at those frequencies. Non-uniform differences — some bands brighter, some darker — indicate the compressor or limiter shaped specific moments rather than applying clean linear gain.
Stacked waveform: Envelope shape should look similar. A noticeably different envelope in the post (bottom) image means target.lra is set too low and loudnorm is compressing dynamics to fit.
Diff image (_diff_spectrogram.png, _diff_waveform.png) #
Absolute pixel difference between pre and post, contrast-amplified and saturation-boosted. Black = no change. Bright = change.
[pre][post] → blend=difference → curves (12% maps to 100%) → hue=s=6
| Diff appearance | Meaning |
|---|---|
| Black / near-black | No change. Pipeline made no audible difference here. |
| Bright uniform region | Significant change — limiter peak, loudnorm gain, or compressor. |
| Yellow-tinted bright region | High-frequency energy increased at that moment. |
| Blue-tinted bright region | High-frequency energy decreased. |
| Isolated bright spikes (waveform diff) | Limiter caught specific transient peaks. |
| Dense uniform brightness (waveform diff) | Large, clean linear gain change from loudnorm — expected. |
| Nearly black waveform diff, faint glow | Ideal: clean linear gain, no dynamic processing artifacts. |
_compare_spectrogram.png first — full picture at a glance. Then _diff_spectrogram.png if you need to know specifically what changed. Individual pre/post images for detailed inspection.
The mastering report
Written to <out_dir>/<stem>/<stem><suffix>_report.json after every master run.
{
"generated_at": "2024-01-15T10:23:44Z",
"input_file": "track.wav",
"output_file": "mastered/track/track_master.wav",
"preset": { ... full preset config ... },
"pre_analysis": {
"integrated_lufs": -18.4,
"true_peak_dbtp": -2.1,
"loudness_range_lu": 14.6,
"extended": {
"rms_dbfs": -22.1,
"crest_factor_db": 20.0,
"dynamic_range_dr": 17.0,
"dc_offset": 0.00012,
"phase_correlation": 0.74,
"spectral_balance": { "low_pct": 38.2, "mid_pct": 44.1, "high_pct": 17.7 },
"spectral_centroid_hz": 1420.0
}
},
"post_analysis": { ... same structure ... },
"filter_chain": "highpass=f=20.0,alimiter=...,loudnorm=...",
"delta": {
"lufs_change": 2.4,
"true_peak_change": 1.8,
"lra_change": -0.3,
"crest_factor_change": -0.2
}
}
The filter_chain field contains the exact string passed to FFmpeg. A master is fully reproducible:
ffmpeg -i input.wav -af "<filter_chain>" -acodec pcm_s24le -ar 44100 output.wav
Extending the pipeline
Adding a filter stage #
Four changes across two files. Example: adding a configurable high-shelf EQ.
src/config.rspub struct Filters {
pub highpass_hz: f32,
pub lowpass_hz: f32,
pub high_shelf_db: f32, // new field
}
impl Default for Presetfilters: Filters {
highpass_hz: 20.0,
lowpass_hz: 0.0,
high_shelf_db: 0.0, // 0 = disabled by convention
},
build_filter_chain() — src/pipeline/process.rsif preset.filters.high_shelf_db != 0.0 {
filters.push(format!(
"treble=g={:.1}:f=8000",
preset.filters.high_shelf_db
));
}
.toml[filters]
highpass_hz = 20.0
lowpass_hz = 0.0
high_shelf_db = 1.5 # gentle high shelf boost
Then rebuild: cargo build --release
Useful FFmpeg audio filters #
Full reference: ffmpeg.org/ffmpeg-filters.html
# Parametric EQ band
equalizer=f=1000:width_type=o:width=2:g=-3.0
# High shelf boost
treble=g=2.0:f=8000
# Low shelf boost
bass=g=1.0:f=100
# Stereo widening
extrastereo=m=1.5
# Noise gate
agate=threshold=-40dB:ratio=2:attack=20:release=250
# M/S encode
pan=stereo|c0=0.5*c0+0.5*c1|c1=0.5*c0-0.5*c1
# Static gain
volume=2dB
AI integration
matchering — reference-based mastering #
matchering analyzes a reference track and applies its spectral and loudness character to your track. Run it as a pre-pass before mastacraf.
pip install matchering
python3 << 'EOF'
import matchering as mg
mg.process(
target='track.wav',
reference='reference.wav',
results=[mg.Result('track_matched.wav', use_limiter=False)]
)
EOF
mastacraf master track_matched.wav --preset default
Pass use_limiter=False to matchering. mastacraf's limiter handles the peak ceiling.
demucs — source separation #
demucs separates stems. For mastering experimental music this is primarily useful for analysis — inspecting spectral and dynamic character of individual elements before mastering decisions.
pip install demucs
python3 -m demucs --two-stems=vocals track.wav
# examine separated/ directory
mastacraf master track.wav --preset default
Bundling (Tauri)
When you are ready to wrap mastacraf in a desktop UI via Tauri:
1. mastacraf as a Tauri sidecar
"bundle": {
"externalBin": ["../mastacraf/target/release/mastacraf"]
}
2. FFmpeg as a sidecar
Use the ffmpeg-sidecar crate for automatic platform-specific FFmpeg download on first run:
ffmpeg-sidecar = "1.1"
Update src/ffmpeg.rs to check the sidecar path before the $PATH lookup.
3. Preset resolution
The preset loader already searches <exe_dir>/presets/. Bundle your .toml files there and they resolve automatically.
4. Frontend interface
The Tauri frontend calls the sidecar, waits for exit, and reads the JSON report from the output directory. No additional IPC beyond filesystem reads is needed for the initial UI.
Troubleshooting
$PATH and not placed next to the mastacraf binary. Install it with your package manager, or copy the binary to the same directory as mastacraf.presets/name.toml does not exist in any search directory. Run mastacraf presets to see what is found. Confirm you are running from the directory containing ./presets/.target.lra is set below the measured LRA. Loudnorm is compressing dynamics to fit. Raise target.lra to at or above the measured value and re-run.--verbose to see which pass failed and why. Likely cause: very short file, or an FFmpeg build missing a specific filter.delta.lufs_change. If it does not match target.lufs − pre.integrated_lufs, the pass-1 JSON was not correctly parsed. Run with --verbose and inspect the raw loudnorm JSON in FFmpeg's stderr output.lavfi virtual device support. Verify: ffmpeg -filters | grep showspectrum. If missing, your FFmpeg build is minimal — install a full build from your package manager.