Commit a14aee74 by PLN (Algolia)

feat(mix): the mix/ toolbox lands — ten scripts the index never knew

Ten shell tools plus FORMAT.md and MIX.md, written 2026-07-29 and untracked
since: stem-stats, energy-profile, onset-detect, silence-detect,
transition-detect, mix-meta, mix-render, mix-normalize, mix-master, lufs-check,
and lib/progress.sh. FORMAT.md defines a <project>.mix.json metadata contract
whose tools 'read existing metadata and augment (don't overwrite unless told)'.

Committed with the exec bit set IN THE INDEX, which is the part that bites:
3fdf5b97 had to restore it on 63 scripts, and MIX.md invokes every one of these
as ./stem-stats. A file that is executable in the working tree and 100644 in
git is executable exactly until someone else clones it. Note also that
'git commit -- path' silently drops a staged mode flip (the trap 1667bb02
documented), so this went in without a pathspec after proving nothing else
was staged.

mix/take94's 833 MB stayed out (a5a5b37). The scripts are the artifact; the
take is the input.

️ Flagged, not resolved: this overlaps ../tidal-ears' mastering CLI, which is
the canonical recipe per MASTERING.md. Two toolboxes that both master a set is
the shape feedback_one_parser_per_concept warns about. I am not merging them
blind — committing first so the overlap is visible in one tree instead of
half-invisible in an untracked directory. PLN's call which one is the spine.

    ten small knives in a drawer nobody opened,
    sharp the whole time.
parent 71516e25
# Mix Tools Metadata Format (v1)
All mix tools read/write a standard JSON format for track and set metadata.
## File Convention
- `<project>.mix.json` - metadata file alongside audio
- Tools read existing metadata and augment (don't overwrite unless told)
- Human-editable, version-controllable
## Schema
```json
{
"version": 1,
"project": {
"name": "39c3 HoT",
"artist": "ParVagues",
"date": "2024-12-29",
"duration_s": 5632.48,
"sample_rate": 48000,
"bit_depth": 16
},
"stems": [
{
"id": "d1",
"file": "39c3_HoT_Tidal 01.wav",
"role": "kick",
"rms_db": -12.0,
"peak_db": 0.0,
"rms_peak_db": -1.0
}
],
"tracks": [
{
"id": 1,
"name": "Quand on décolle",
"start_s": 0,
"end_s": 300,
"section": "INTRO",
"tidal_file": "ambient/quand_on_decolle.tidal",
"transition_in": "silence",
"transition_out": "crossfade",
"crossfade_s": 2.0,
"notes": "Soft ambient opener"
}
],
"transitions": [
{
"time_s": 300,
"type": "crossfade",
"duration_s": 2.0,
"from_track": 1,
"to_track": 2
}
],
"analysis": {
"lufs_integrated": -14.2,
"lufs_range": 8.5,
"true_peak_db": -0.3,
"analyzed_at": "2024-12-29T12:30:00Z"
},
"mastering": {
"target_lufs": -14,
"eq_low_cut_hz": 20,
"eq_high_cut_hz": 20000,
"bass_mono_hz": 200,
"limiter_ceiling_db": -0.3
}
}
```
## Field Reference
### project
| Field | Type | Description |
|-------|------|-------------|
| name | string | Project/set name |
| artist | string | Artist name |
| date | string | Recording/export date (ISO) |
| duration_s | float | Total duration in seconds |
| sample_rate | int | Sample rate in Hz |
| bit_depth | int | Bit depth |
### stems[]
| Field | Type | Description |
|-------|------|-------------|
| id | string | Stem identifier (d1-d12) |
| file | string | Filename (relative to .mix.json) |
| role | string | kick/snare/hihat/bass/lead/breaks/ambient/vocals |
| rms_db | float | Overall RMS level |
| peak_db | float | Peak level |
| rms_peak_db | float | RMS peak level |
### tracks[]
| Field | Type | Description |
|-------|------|-------------|
| id | int | Track number (1-indexed) |
| name | string | Track title |
| start_s | float | Start time in seconds |
| end_s | float | End time in seconds |
| section | string | Set section (INTRO/TECHNO/DNB/NUJAZZ/OUTRO) |
| tidal_file | string | Source tidal file (relative) |
| transition_in | string | How track starts: silence/crossfade/cut/hotswap |
| transition_out | string | How track ends: silence/crossfade/cut/hotswap |
| crossfade_s | float | Crossfade duration if applicable |
| notes | string | Optional notes |
### transitions[]
| Field | Type | Description |
|-------|------|-------------|
| time_s | float | Transition point in seconds |
| type | string | silence/crossfade/cut/hotswap |
| duration_s | float | Transition duration |
| from_track | int | Source track id |
| to_track | int | Destination track id |
### analysis (computed by tools)
| Field | Type | Description |
|-------|------|-------------|
| lufs_integrated | float | Integrated loudness |
| lufs_range | float | Loudness range |
| true_peak_db | float | True peak |
| analyzed_at | string | ISO timestamp |
### mastering (user-defined targets)
| Field | Type | Description |
|-------|------|-------------|
| target_lufs | float | Target loudness (-14 for streaming) |
| eq_low_cut_hz | int | Low cut frequency |
| eq_high_cut_hz | int | High cut frequency |
| bass_mono_hz | int | Mono bass below this freq |
| limiter_ceiling_db | float | Limiter ceiling |
## Tool Integration
Tools should:
1. Check for existing `.mix.json` in working directory
2. Load and merge their output into existing data
3. Use `jq` for reading/writing (available everywhere)
Example reading:
```bash
jq -r '.tracks[] | "\(.start_s) \(.end_s) \(.name)"' project.mix.json
```
Example updating:
```bash
jq '.analysis.lufs_integrated = -14.2' project.mix.json > tmp && mv tmp project.mix.json
```
# Mix Workflow
Standard workflow for mixing and mastering live sets with these tools.
## Quick Start
```bash
# 1. Analyze stems
./stem-stats /path/to/stems/*.wav
# 2. Create mix metadata
export MIXFILE="./project.mix.json"
./mix-meta init "Project Name" "Artist"
./mix-meta track 1 "Track Name" 0 300 # start_s end_s
# 3. Render stems to single file
./mix-render --from-mix "$MIXFILE" -o output.flac
# 4. Master to target LUFS
./mix-master output.flac -o output_master.flac --preset streaming
```
## Full Workflow
### 1. Stem Analysis
```bash
./stem-stats /path/to/*.wav
./lufs-check /path/to/file.wav
```
### 2. Silence/Transition Detection
```bash
# Find initial silence to trim
./onset-detect input.wav -35 0.2
# Find track transitions (multi-stem for livecoding)
./transition-detect --from-mix project.mix.json --multi
```
### 3. Track Metadata
```bash
export MIXFILE="./project.mix.json"
./mix-meta init "Set Name" "Artist"
# Add tracks with boundaries
./mix-meta track 1 "Intro" 0 180
./mix-meta track 2 "Track Two" 180 420
# ...
```
### 4. Rendering
```bash
# Mix all stems
./mix-render --from-mix "$MIXFILE" -o raw.flac --normalize
# Trim silence if needed
sox raw.flac trimmed.flac trim 25.1 # trim first 25.1s
```
### 5. Mastering
```bash
# Streaming platforms (-14 LUFS)
./mix-master trimmed.flac -o master.flac --preset streaming
# Club play (-8 LUFS)
./mix-master trimmed.flac -o master_club.flac --preset club
```
### 6. Adding Crowd Recording (Optional)
When you have a separate audience/room mic recording:
```bash
# Resample crowd recording to match master (if needed)
sox crowd.wav -r 192000 crowd_192k.wav
# Align with master ending (adjust offset based on your recording)
# Create silence padding + concatenate
sox crowd_192k.wav silence_pad.wav trim 0 0 pad 11 # 11s offset
sox silence_pad.wav crowd_192k.wav crowd_aligned.wav
sox crowd_aligned.wav crowd_aligned_120.wav trim 0 120 # match length
# VAR3 approach (recommended): Soft blend during music, boost for claps
# Split into quiet part (during music) and loud part (claps)
sox crowd_aligned_120.wav crowd_soft.wav trim 0 35 gain -15
sox crowd_aligned_120.wav crowd_loud.wav trim 35 gain -3 fade t 3
sox crowd_soft.wav crowd_loud.wav crowd_var3.wav
# Mix with master ending
sox -m master_last120.wav crowd_var3.wav ending_with_crowd.wav gain -3
# Splice into full master
ffmpeg -y -i master.flac -t 5487 -c:a flac master_start.flac
ffmpeg -y -i ending_with_crowd.wav -c:a flac ending.flac
echo "file 'master_start.flac'" > concat.txt
echo "file 'ending.flac'" >> concat.txt
ffmpeg -y -f concat -safe 0 -i concat.txt -c:a flac master_crowd.flac
```
**Crowd recording approaches:**
- **var1 (fade-in)**: Audience fades in over 30s, full for claps
- **var2 (room ambience)**: Phase-cancel to extract reverb + claps only
- **var3 (soft blend)**: Quiet during music (-15dB), boost for claps (-3dB) ← recommended
## Output Files
Standard naming:
- `project_raw.flac` - Mixed stems, unprocessed
- `project_trimmed.flac` - Silence trimmed
- `project_master.flac` - Mastered (no crowd)
- `project_master_crowd.flac` - Mastered with crowd recording
## Tips
- Always check LUFS before upload: `./lufs-check master.flac`
- Keep intermediate files until final verification
- Use `--dry-run` on tools to preview commands
- For long files, tools show progress/ETA
#!/bin/bash
# energy-profile: Generate RMS energy profile over time for audio files
# Usage: energy-profile <audio_file> [interval_seconds] [window_seconds]
#
# Output: timestamp (MM:SS) and RMS level in dB
# Use to detect track transitions via energy changes
set -euo pipefail
FILE="${1:?Usage: energy-profile <audio_file> [interval=30] [window=30]}"
INTERVAL="${2:-30}"
WINDOW="${3:-30}"
if [[ ! -f "$FILE" ]]; then
echo "Error: File not found: $FILE" >&2
exit 1
fi
# Get duration
DURATION=$(sox "$FILE" -n stat 2>&1 | grep "Length" | awk '{print int($3)}')
echo "# Energy profile for: $(basename "$FILE")"
echo "# Duration: ${DURATION}s ($(printf '%02d:%02d:%02d' $((DURATION/3600)) $(((DURATION%3600)/60)) $((DURATION%60))))"
echo "# Interval: ${INTERVAL}s, Window: ${WINDOW}s"
echo "#"
echo "# TIME_S TIME RMS_dB"
for ((i=0; i<DURATION; i+=INTERVAL)); do
rms=$(sox "$FILE" -n trim "$i" "$WINDOW" stats 2>&1 | grep "RMS lev dB" | awk '{print $4}')
printf "%6d %02d:%02d:%02d %s\n" "$i" $((i/3600)) $(((i%3600)/60)) $((i%60)) "$rms"
done
#!/bin/bash
# progress.sh - Shared progress/ETA utilities for mix tools
# Source this file: source "$(dirname "$0")/lib/progress.sh"
# Get audio duration in seconds
get_duration() {
local file="$1"
sox "$file" -n stat 2>&1 | grep "Length" | awk '{print $3}'
}
# Format seconds as HH:MM:SS
format_time() {
local secs="${1%.*}"
printf '%02d:%02d:%02d' $((secs/3600)) $(((secs%3600)/60)) $((secs%60))
}
# Progress bar (call repeatedly with current/total)
# Usage: progress_bar 50 100 "Processing"
progress_bar() {
local current="$1"
local total="$2"
local label="${3:-Progress}"
local width=40
local pct=$((current * 100 / total))
local filled=$((current * width / total))
local empty=$((width - filled))
printf "\r%s: [" "$label"
printf '%*s' "$filled" '' | tr ' ' '='
printf '%*s' "$empty" '' | tr ' ' '-'
printf "] %3d%%" "$pct"
}
# ETA calculator
# Usage: calc_eta $start_time $current $total
calc_eta() {
local start="$1"
local current="$2"
local total="$3"
local now=$(date +%s)
local elapsed=$((now - start))
if [[ "$current" -gt 0 ]]; then
local rate=$(echo "$current / $elapsed" | bc -l)
local remaining=$(echo "($total - $current) / $rate" | bc -l)
format_time "$remaining"
else
echo "--:--:--"
fi
}
# Monitor ffmpeg progress (parse stderr)
# Usage: ffmpeg_with_progress input.wav output.wav "filter" duration_secs
ffmpeg_with_progress() {
local input="$1"
local output="$2"
local filter="$3"
local duration="$4"
local label="${5:-Processing}"
local start=$(date +%s)
ffmpeg -y -progress pipe:1 -i "$input" -af "$filter" "$output" 2>/dev/null | \
while IFS='=' read -r key value; do
if [[ "$key" == "out_time_ms" && -n "$value" ]]; then
local current_secs=$((value / 1000000))
local pct=$((current_secs * 100 / ${duration%.*}))
local eta=$(calc_eta "$start" "$current_secs" "${duration%.*}")
printf "\r%s: %d%% | %s / %s | ETA: %s " \
"$label" "$pct" \
"$(format_time $current_secs)" \
"$(format_time ${duration%.*})" \
"$eta"
fi
done
echo ""
}
# Simple spinner for unknown duration
spinner() {
local pid="$1"
local label="${2:-Working}"
local chars='|/-\'
local i=0
while kill -0 "$pid" 2>/dev/null; do
printf "\r%s %c " "$label" "${chars:i++%4:1}"
sleep 0.2
done
printf "\r%s done\n" "$label"
}
#!/bin/bash
# lufs-check: Measure integrated LUFS loudness (for streaming targets)
# Usage: lufs-check <audio_file> [audio_file2 ...]
#
# Targets:
# SoundCloud: -14 LUFS (but they don't normalize up, only down)
# Spotify: -14 LUFS
# YouTube: -14 LUFS
# Apple Music: -16 LUFS
# Club/DJ: -6 to -8 LUFS (louder)
set -euo pipefail
if [[ $# -eq 0 ]]; then
echo "Usage: lufs-check <audio_file> [audio_file2 ...]" >&2
exit 1
fi
echo "# LUFS Loudness Analysis"
echo "# Target: -14 LUFS (SoundCloud/Spotify/YouTube)"
echo "#"
printf "%-40s %10s %10s %10s %10s\n" "FILE" "INT_LUFS" "LRA" "TRUE_PK" "STATUS"
printf "%-40s %10s %10s %10s %10s\n" "----" "--------" "---" "-------" "------"
for FILE in "$@"; do
if [[ ! -f "$FILE" ]]; then
echo "Warning: File not found: $FILE" >&2
continue
fi
# Use ffmpeg loudnorm filter to measure LUFS
result=$(ffmpeg -i "$FILE" -af loudnorm=print_format=json -f null - 2>&1 | tail -12)
int_lufs=$(echo "$result" | grep "input_i" | sed 's/.*: "\([^"]*\)".*/\1/')
lra=$(echo "$result" | grep "input_lra" | sed 's/.*: "\([^"]*\)".*/\1/')
true_pk=$(echo "$result" | grep "input_tp" | sed 's/.*: "\([^"]*\)".*/\1/')
# Determine status
lufs_val=${int_lufs%.*}
if [[ "$lufs_val" -gt -12 ]]; then
status="TOO_HOT"
elif [[ "$lufs_val" -lt -16 ]]; then
status="TOO_QUIET"
else
status="OK"
fi
printf "%-40s %10s %10s %10s %10s\n" "$(basename "$FILE")" "$int_lufs" "$lra" "$true_pk" "$status"
done
#!/bin/bash
# mix-master: Apply mastering chain to audio
# Usage:
# mix-master <input.wav> -o <output.wav> [options]
#
# Mastering chain (based on rhadamanthe_techno tips):
# 1. EQ extreme (cut <20Hz and >20kHz)
# 2. Bass mono (mono everything below 200Hz)
# 3. Compression (glue compressor)
# 4. Limiting (to target LUFS)
#
# Options:
# -o, --output FILE Output filename (required)
# --target-lufs N Target LUFS (default: -14 for streaming, -8 for club)
# --low-cut HZ Low cut frequency (default: 20)
# --high-cut HZ High cut frequency (default: 20000)
# --bass-mono HZ Mono bass below this freq (default: 200)
# --no-limit Skip limiter (just EQ + bass mono)
# --preset NAME Use preset: streaming, club, loud
# --dry-run Show ffmpeg command without running
set -euo pipefail
# Source progress utilities
SCRIPT_DIR="$(dirname "$0")"
if [[ -f "$SCRIPT_DIR/lib/progress.sh" ]]; then
source "$SCRIPT_DIR/lib/progress.sh"
fi
INPUT=""
OUTPUT=""
TARGET_LUFS=-14
LOW_CUT=20
HIGH_CUT=20000
BASS_MONO=200
NO_LIMIT=false
DRY_RUN=false
while [[ $# -gt 0 ]]; do
case "$1" in
-o|--output)
OUTPUT="$2"
shift 2
;;
--target-lufs)
TARGET_LUFS="$2"
shift 2
;;
--low-cut)
LOW_CUT="$2"
shift 2
;;
--high-cut)
HIGH_CUT="$2"
shift 2
;;
--bass-mono)
BASS_MONO="$2"
shift 2
;;
--no-limit)
NO_LIMIT=true
shift
;;
--preset)
case "$2" in
streaming)
TARGET_LUFS=-14
;;
club)
TARGET_LUFS=-8
;;
loud)
TARGET_LUFS=-6
;;
*)
echo "Unknown preset: $2 (use: streaming, club, loud)" >&2
exit 1
;;
esac
shift 2
;;
--dry-run)
DRY_RUN=true
shift
;;
-*)
echo "Unknown option: $1" >&2
exit 1
;;
*)
if [[ -z "$INPUT" ]] && [[ -f "$1" ]]; then
INPUT="$1"
fi
shift
;;
esac
done
if [[ -z "$INPUT" ]] || [[ -z "$OUTPUT" ]]; then
echo "Usage: mix-master <input.wav> -o <output.wav> [options]" >&2
echo ""
echo "Presets:"
echo " --preset streaming -14 LUFS (SoundCloud, Spotify)"
echo " --preset club -8 LUFS (DJ/club play)"
echo " --preset loud -6 LUFS (maximum loudness)"
exit 1
fi
echo "=== Mastering Chain ==="
echo "Input: $INPUT"
echo "Output: $OUTPUT"
echo ""
echo "Settings:"
echo " Low cut: ${LOW_CUT}Hz"
echo " High cut: ${HIGH_CUT}Hz"
echo " Bass mono below: ${BASS_MONO}Hz"
echo " Target LUFS: $TARGET_LUFS"
echo ""
# Build filter chain
FILTERS=""
# 1. EQ extreme - cut sub and ultra-high
FILTERS+="highpass=f=${LOW_CUT}:poles=2,"
FILTERS+="lowpass=f=${HIGH_CUT}:poles=2,"
# 2. Bass mono - convert low frequencies to mono
# Using crossfeed technique: extract bass, mono it, add back
FILTERS+="pan=stereo|c0=c0|c1=c1," # passthrough first
# Split into low (mono) and high (stereo)
FILTERS+="channelsplit=channel_layout=stereo[L][R];"
FILTERS+="[L]lowpass=f=${BASS_MONO}[Llow];"
FILTERS+="[R]lowpass=f=${BASS_MONO}[Rlow];"
FILTERS+="[Llow][Rlow]amerge=inputs=2,pan=mono|c0=0.5*c0+0.5*c1[mono_bass];"
FILTERS+="[L]highpass=f=${BASS_MONO}[Lhigh];"
FILTERS+="[R]highpass=f=${BASS_MONO}[Rhigh];"
FILTERS+="[mono_bass]asplit[bass_l][bass_r];"
FILTERS+="[Lhigh][bass_l]amix=inputs=2:duration=first[Lfinal];"
FILTERS+="[Rhigh][bass_r]amix=inputs=2:duration=first[Rfinal];"
FILTERS+="[Lfinal][Rfinal]amerge=inputs=2,pan=stereo|c0=c0|c1=c1"
# Actually, that's getting complex. Let's use a simpler approach with sox
# Switching to sox for better audio quality
# Build sox effects
SOX_EFFECTS=""
# 1. High pass (low cut)
SOX_EFFECTS+="highpass $LOW_CUT "
# 2. Low pass (high cut)
SOX_EFFECTS+="lowpass $HIGH_CUT "
# 3. Compressor (glue)
SOX_EFFECTS+="compand 0.3,1 6:-70,-60,-20 -5 -90 0.2 "
# For bass mono, we'd need more complex processing
# Skip for now, can add with ffmpeg separately
if [[ "$NO_LIMIT" == false ]]; then
# 4. Limiter via loudnorm
LIMITER_CMD="ffmpeg -y -i TEMP_FILE -af loudnorm=I=${TARGET_LUFS}:TP=-1:LRA=11 OUTPUT_FILE"
fi
if [[ "$DRY_RUN" == true ]]; then
echo "Would run:"
echo "sox \"$INPUT\" temp_master.wav $SOX_EFFECTS"
if [[ "$NO_LIMIT" == false ]]; then
echo "ffmpeg -y -i temp_master.wav -af loudnorm=I=${TARGET_LUFS}:TP=-1:LRA=11 \"$OUTPUT\""
fi
exit 0
fi
# Run mastering chain
TEMP_FILE=$(mktemp --suffix=.wav)
# Get input duration for progress tracking
DURATION=$(sox "$INPUT" -n stat 2>&1 | grep "Length" | awk '{print $3}')
DURATION_FMT=$(printf '%02d:%02d:%02d' $((${DURATION%.*}/3600)) $(((${DURATION%.*}%3600)/60)) $((${DURATION%.*}%60)))
echo "Duration: $DURATION_FMT"
echo ""
START_TIME=$(date +%s)
echo "Step 1/3: EQ + Compression..."
sox "$INPUT" "$TEMP_FILE" $SOX_EFFECTS 2>/dev/null
STEP1_TIME=$(date +%s)
echo " Done in $((STEP1_TIME - START_TIME))s"
if [[ "$NO_LIMIT" == false ]]; then
echo "Step 2/3: Loudness normalization to ${TARGET_LUFS} LUFS..."
echo " (this takes ~${DURATION_FMT} at 25-30x speed, ETA ~$((${DURATION%.*} / 28))s)"
ffmpeg -y -progress pipe:1 -i "$TEMP_FILE" -af "loudnorm=I=${TARGET_LUFS}:TP=-1:LRA=11" "$OUTPUT" 2>/dev/null | \
while IFS='=' read -r key value; do
if [[ "$key" == "out_time_us" && -n "$value" && "$value" != "N/A" ]]; then
current_secs=$((value / 1000000))
pct=$((current_secs * 100 / ${DURATION%.*}))
elapsed=$(($(date +%s) - STEP1_TIME))
if [[ $current_secs -gt 0 ]]; then
eta=$(( (${DURATION%.*} - current_secs) * elapsed / current_secs ))
else
eta=0
fi
printf "\r Progress: %3d%% | ETA: %ds " "$pct" "$eta"
fi
done
echo ""
rm "$TEMP_FILE"
else
mv "$TEMP_FILE" "$OUTPUT"
fi
STEP2_TIME=$(date +%s)
echo "Step 3/3: Analyzing output..."
ffmpeg -progress pipe:1 -i "$OUTPUT" -af loudnorm=print_format=json -f null - 2>&1 | \
while IFS='=' read -r key value; do
if [[ "$key" == "out_time_us" && -n "$value" && "$value" != "N/A" ]]; then
current_secs=$((value / 1000000))
pct=$((current_secs * 100 / ${DURATION%.*}))
printf "\r Analyzing: %3d%% " "$pct"
fi
done
echo ""
FINAL_STATS=$(ffmpeg -i "$OUTPUT" -af loudnorm=print_format=json -f null - 2>&1 | grep -A12 "input_i")
echo ""
echo "=== Output Stats ==="
echo "$FINAL_STATS" | grep -E "input_i|input_tp|input_lra" | sed 's/[",]//g; s/^[ \t]*/ /'
TOTAL_TIME=$(($(date +%s) - START_TIME))
echo ""
echo "Done: $OUTPUT"
echo "Total time: ${TOTAL_TIME}s"
ls -lh "$OUTPUT" | awk '{print "File size: " $5}'
#!/bin/bash
# mix-meta: Create and manage .mix.json metadata files
# Usage:
# mix-meta init <name> [artist] - Create new metadata file
# mix-meta stems <file.wav> [file2.wav] - Analyze and add stems
# mix-meta track <id> <name> <start> <end> [section] - Add/update track
# mix-meta get <path> - Get value (jq path)
# mix-meta set <path> <value> - Set value (jq path)
# mix-meta show - Pretty print current metadata
set -euo pipefail
MIXFILE="${MIXFILE:-$(pwd)/project.mix.json}"
cmd_init() {
local name="${1:?Usage: mix-meta init <name> [artist]}"
local artist="${2:-Unknown}"
cat > "$MIXFILE" <<EOF
{
"version": 1,
"project": {
"name": "$name",
"artist": "$artist",
"date": "$(date -I)",
"duration_s": 0,
"sample_rate": 48000,
"bit_depth": 16
},
"stems": [],
"tracks": [],
"transitions": [],
"analysis": {},
"mastering": {
"target_lufs": -14,
"eq_low_cut_hz": 20,
"eq_high_cut_hz": 20000,
"bass_mono_hz": 200,
"limiter_ceiling_db": -0.3
}
}
EOF
echo "Created: $MIXFILE"
}
cmd_stems() {
if [[ $# -eq 0 ]]; then
echo "Usage: mix-meta stems <file.wav> [file2.wav ...]" >&2
exit 1
fi
local stems="[]"
local max_dur=0
local idx=1
for file in "$@"; do
if [[ ! -f "$file" ]]; then
echo "Warning: $file not found" >&2
continue
fi
local stats=$(sox "$file" -n stats 2>&1)
local dur=$(echo "$stats" | grep "Length s" | awk '{print $3}')
local rms=$(echo "$stats" | grep "RMS lev dB" | head -1 | awk '{print $4}')
local peak=$(echo "$stats" | grep "Pk lev dB" | head -1 | awk '{print $4}')
local rms_pk=$(echo "$stats" | grep "RMS Pk dB" | head -1 | awk '{print $4}')
local basename=$(basename "$file")
# Track max duration
if (( $(echo "$dur > $max_dur" | bc -l) )); then
max_dur=$dur
fi
# Infer role from index
local role="unknown"
case $idx in
1) role="kick" ;;
2) role="snare" ;;
3) role="hihat" ;;
4) role="bass" ;;
5) role="lead" ;;
6) role="lead2" ;;
7) role="breaks" ;;
8) role="breaks" ;;
9|10|11|12) role="ambient" ;;
esac
stems=$(echo "$stems" | jq --arg id "d$idx" \
--arg file "$basename" \
--arg role "$role" \
--argjson rms "$rms" \
--argjson peak "$peak" \
--argjson rms_pk "$rms_pk" \
'. + [{id: $id, file: $file, role: $role, rms_db: $rms, peak_db: $peak, rms_peak_db: $rms_pk}]')
echo "Added: d$idx ($basename) - $role, RMS: ${rms}dB"
((idx++))
done
# Update mixfile
local tmp=$(mktemp)
jq --argjson stems "$stems" --argjson dur "$max_dur" \
'.stems = $stems | .project.duration_s = $dur' "$MIXFILE" > "$tmp"
mv "$tmp" "$MIXFILE"
echo "Updated $MIXFILE with $((idx-1)) stems"
}
cmd_track() {
local id="${1:?Usage: mix-meta track <id> <name> <start_s> <end_s> [section] [tidal_file]}"
local name="${2:?}"
local start="${3:?}"
local end="${4:?}"
local section="${5:-}"
local tidal="${6:-}"
local tmp=$(mktemp)
jq --argjson id "$id" \
--arg name "$name" \
--argjson start "$start" \
--argjson end "$end" \
--arg section "$section" \
--arg tidal "$tidal" \
'
.tracks = [.tracks[] | select(.id != $id)] + [{
id: $id,
name: $name,
start_s: $start,
end_s: $end,
section: $section,
tidal_file: $tidal,
transition_in: "auto",
transition_out: "auto"
}] | .tracks |= sort_by(.id)
' "$MIXFILE" > "$tmp"
mv "$tmp" "$MIXFILE"
echo "Track $id: $name ($start - $end)"
}
cmd_get() {
local path="${1:?Usage: mix-meta get <jq-path>}"
jq -r "$path" "$MIXFILE"
}
cmd_set() {
local path="${1:?Usage: mix-meta set <jq-path> <value>}"
local value="${2:?}"
local tmp=$(mktemp)
jq "$path = $value" "$MIXFILE" > "$tmp"
mv "$tmp" "$MIXFILE"
}
cmd_show() {
if [[ ! -f "$MIXFILE" ]]; then
echo "No mixfile found at: $MIXFILE" >&2
exit 1
fi
jq '.' "$MIXFILE"
}
# Main dispatch
case "${1:-show}" in
init) shift; cmd_init "$@" ;;
stems) shift; cmd_stems "$@" ;;
track) shift; cmd_track "$@" ;;
get) shift; cmd_get "$@" ;;
set) shift; cmd_set "$@" ;;
show) cmd_show ;;
*) echo "Unknown command: $1" >&2; exit 1 ;;
esac
#!/bin/bash
# mix-normalize: Analyze and normalize audio per-track based on mix.json
# Usage:
# mix-normalize --from-mix <mixfile.json> --input <audio.wav> [options]
#
# Options:
# --from-mix FILE Read track boundaries from mix.json
# --input FILE Input audio file to analyze/normalize
# --target-lufs N Target LUFS level (default: -14 for streaming)
# --analyze-only Just show per-track levels, don't process
# --output FILE Output filename (required unless --analyze-only)
set -euo pipefail
MIXFILE=""
INPUT=""
OUTPUT=""
TARGET_LUFS=-14
ANALYZE_ONLY=false
while [[ $# -gt 0 ]]; do
case "$1" in
--from-mix)
MIXFILE="$2"
shift 2
;;
--input)
INPUT="$2"
shift 2
;;
--output)
OUTPUT="$2"
shift 2
;;
--target-lufs)
TARGET_LUFS="$2"
shift 2
;;
--analyze-only)
ANALYZE_ONLY=true
shift
;;
*)
echo "Unknown option: $1" >&2
exit 1
;;
esac
done
if [[ -z "$MIXFILE" ]] || [[ -z "$INPUT" ]]; then
echo "Usage: mix-normalize --from-mix <mixfile.json> --input <audio.wav> [options]" >&2
exit 1
fi
if [[ "$ANALYZE_ONLY" == false ]] && [[ -z "$OUTPUT" ]]; then
echo "Error: --output required unless --analyze-only" >&2
exit 1
fi
echo "=== Per-Track Loudness Analysis ==="
echo "Target: ${TARGET_LUFS} LUFS"
echo ""
printf "%-4s %-22s %8s %8s %8s %8s\n" "#" "TRACK" "START" "END" "LUFS" "ADJUST"
printf "%-4s %-22s %8s %8s %8s %8s\n" "---" "-----" "-----" "---" "----" "------"
# Read tracks and analyze each segment
jq -r '.tracks | sort_by(.id) | .[] | "\(.id)|\(.name)|\(.start_s)|\(.end_s)"' "$MIXFILE" | \
while IFS='|' read -r id name start_s end_s; do
dur=$((end_s - start_s))
# Measure LUFS for this segment
lufs=$(ffmpeg -i "$INPUT" -ss "$start_s" -t "$dur" -af loudnorm=print_format=json -f null - 2>&1 | \
grep "input_i" | sed 's/.*: "\([^"]*\)".*/\1/' | head -1)
if [[ -n "$lufs" ]]; then
adjust=$(echo "$TARGET_LUFS - $lufs" | bc -l)
printf "%-4s %-22s %8s %8s %8.1f %+8.1f dB\n" "$id" "${name:0:22}" \
"$(printf '%d:%02d' $((start_s/60)) $((start_s%60)))" \
"$(printf '%d:%02d' $((end_s/60)) $((end_s%60)))" \
"$lufs" "$adjust"
else
printf "%-4s %-22s %8s %8s %8s %8s\n" "$id" "${name:0:22}" \
"$(printf '%d:%02d' $((start_s/60)) $((start_s%60)))" \
"$(printf '%d:%02d' $((end_s/60)) $((end_s%60)))" \
"ERR" "-"
fi
done
if [[ "$ANALYZE_ONLY" == true ]]; then
echo ""
echo "(analyze-only mode, no processing)"
exit 0
fi
# For now, just normalize the whole file
# TODO: implement per-segment gain adjustment
echo ""
echo "Normalizing to ${TARGET_LUFS} LUFS..."
ffmpeg -y -i "$INPUT" -af loudnorm=I=${TARGET_LUFS}:TP=-1:LRA=11 "$OUTPUT" 2>/dev/null
echo "Output: $OUTPUT"
#!/bin/bash
# mix-render: Render stems to a mixed output with optional processing
# Usage:
# mix-render --from-mix <mixfile.json> [options]
# mix-render <stem1.wav> <stem2.wav> ... -o output.wav
#
# Options:
# --from-mix FILE Read stems from mix.json
# -o, --output FILE Output filename (default: mix_output.wav)
# --trim-silence Trim silence from start/end
# --normalize Normalize output to -1dB peak
# --fade-in SEC Add fade in (default: 0)
# --fade-out SEC Add fade out (default: 2)
# --format FMT Output format: wav, flac, mp3 (default: wav)
# --dry-run Show what would be done without executing
set -euo pipefail
# Defaults
MIXFILE=""
OUTPUT="mix_output.flac"
TRIM_SILENCE=false
NORMALIZE=false
FADE_IN=0
FADE_OUT=2
FORMAT="flac"
DRY_RUN=false
FILES=()
# Parse args
while [[ $# -gt 0 ]]; do
case "$1" in
--from-mix)
MIXFILE="$2"
shift 2
;;
-o|--output)
OUTPUT="$2"
shift 2
;;
--trim-silence)
TRIM_SILENCE=true
shift
;;
--normalize)
NORMALIZE=true
shift
;;
--fade-in)
FADE_IN="$2"
shift 2
;;
--fade-out)
FADE_OUT="$2"
shift 2
;;
--format)
FORMAT="$2"
shift 2
;;
--dry-run)
DRY_RUN=true
shift
;;
-*)
echo "Unknown option: $1" >&2
exit 1
;;
*)
if [[ -f "$1" ]]; then
FILES+=("$1")
fi
shift
;;
esac
done
# Load stems from mix.json if specified
if [[ -n "$MIXFILE" ]]; then
if [[ ! -f "$MIXFILE" ]]; then
echo "Error: Mix file not found: $MIXFILE" >&2
exit 1
fi
MIXDIR=$(dirname "$MIXFILE")
while IFS= read -r stem; do
if [[ -f "$MIXDIR/$stem" ]]; then
FILES+=("$MIXDIR/$stem")
fi
done < <(jq -r '.stems[].file' "$MIXFILE")
echo "Loaded ${#FILES[@]} stems from $MIXFILE"
fi
if [[ ${#FILES[@]} -eq 0 ]]; then
echo "Usage: mix-render --from-mix <mixfile.json> [options]" >&2
echo " mix-render <stem1.wav> <stem2.wav> ... -o output.wav" >&2
exit 1
fi
# Build sox command
SOX_ARGS=()
# Input files (mix)
SOX_ARGS+=("-m")
for f in "${FILES[@]}"; do
SOX_ARGS+=("$f")
done
# Output - ensure correct extension
OUT_BASE="${OUTPUT%.*}"
case "$FORMAT" in
wav) OUTPUT="${OUT_BASE}.wav"; SOX_ARGS+=("$OUTPUT") ;;
flac) OUTPUT="${OUT_BASE}.flac"; SOX_ARGS+=("$OUTPUT") ;;
mp3) OUTPUT="${OUT_BASE}.mp3"; SOX_ARGS+=("-t" "mp3" "$OUTPUT") ;;
esac
# Effects chain
EFFECTS=()
# Trim silence
if [[ "$TRIM_SILENCE" == true ]]; then
EFFECTS+=("silence" "1" "0.1" "0.1%" "reverse" "silence" "1" "0.1" "0.1%" "reverse")
fi
# Normalize (always recommended after mixing as sox -m divides by input count)
if [[ "$NORMALIZE" == true ]]; then
EFFECTS+=("norm" "-1")
else
# Even without explicit normalize, compensate for sox -m level reduction
EFFECTS+=("gain" "10") # Roughly compensate for 12 stems
fi
# Fades
if (( $(echo "$FADE_IN > 0" | bc -l) )); then
EFFECTS+=("fade" "t" "$FADE_IN")
fi
if (( $(echo "$FADE_OUT > 0" | bc -l) )); then
# Get duration for fade out
DUR=$(sox "${FILES[0]}" -n stat 2>&1 | grep "Length" | awk '{print $3}')
EFFECTS+=("fade" "t" "0" "$DUR" "$FADE_OUT")
fi
if [[ "$DRY_RUN" == true ]]; then
echo "Would run:"
echo "sox ${SOX_ARGS[*]} ${EFFECTS[*]}"
else
echo "Mixing ${#FILES[@]} stems..."
echo "Output: $OUTPUT"
sox "${SOX_ARGS[@]}" "${EFFECTS[@]}"
# Show output stats
echo ""
echo "=== Output stats ==="
sox "$OUTPUT" -n stats 2>&1 | grep -E "Length|RMS lev|Pk lev"
fi
#!/bin/bash
# onset-detect: Find first audio onset (first "good note") in a file
# Usage: onset-detect <audio_file> [threshold_db=-40] [min_duration=0.1]
#
# Returns the timestamp of the first significant audio onset
# Useful for trimming silence at the start of a recording
set -euo pipefail
FILE="${1:?Usage: onset-detect <audio_file> [threshold_db=-40] [min_duration=0.1]}"
THRESHOLD="${2:--40}"
MIN_DUR="${3:-0.1}"
if [[ ! -f "$FILE" ]]; then
echo "Error: File not found: $FILE" >&2
exit 1
fi
# Method 1: Use ffmpeg silencedetect to find end of initial silence
ONSET=$(ffmpeg -i "$FILE" -af "silencedetect=noise=${THRESHOLD}dB:d=${MIN_DUR}" -f null - 2>&1 | \
grep "silence_end" | head -1 | sed 's/.*silence_end: \([0-9.]*\).*/\1/')
if [[ -n "$ONSET" ]]; then
# Format nicely
ONSET_INT=${ONSET%.*}
ONSET_MS=$(echo "$ONSET" | sed 's/.*\.//' | head -c3)
printf "First onset: %.3fs (%02d:%02d.%s)\n" "$ONSET" $((ONSET_INT/60)) $((ONSET_INT%60)) "$ONSET_MS"
# Also show what's at that point
echo ""
echo "Audio at onset point:"
sox "$FILE" -n trim "$ONSET" 1 stats 2>&1 | grep -E "RMS lev|Pk lev"
# Output just the timestamp for piping
echo ""
echo "ONSET_TIMESTAMP=$ONSET"
else
echo "No onset detected (file may start with audio or be silent)"
echo "ONSET_TIMESTAMP=0"
fi
#!/bin/bash
# silence-detect: Find silence gaps in audio files
# Usage: silence-detect <audio_file> [min_duration=2] [noise_threshold=-40dB]
#
# Output: silence regions with start, end, and duration
set -euo pipefail
FILE="${1:?Usage: silence-detect <audio_file> [min_duration=2] [noise_db=-40]}"
MIN_DUR="${2:-2}"
NOISE_DB="${3:--40}"
if [[ ! -f "$FILE" ]]; then
echo "Error: File not found: $FILE" >&2
exit 1
fi
echo "# Silence detection for: $(basename "$FILE")"
echo "# Min duration: ${MIN_DUR}s, Noise threshold: ${NOISE_DB}dB"
echo "#"
echo "# START END DURATION"
ffmpeg -i "$FILE" -af "silencedetect=noise=${NOISE_DB}dB:d=${MIN_DUR}" -f null - 2>&1 | \
grep -E "silence_start|silence_end" | \
awk '
/silence_start/ { start = $NF }
/silence_end/ {
split($0, a, "silence_end: ")
split(a[2], b, " ")
end = b[1]
dur = end - start
printf "%02d:%02d:%02d %02d:%02d:%02d %6.1fs\n", \
int(start/3600), int((start%3600)/60), int(start%60), \
int(end/3600), int((end%3600)/60), int(end%60), dur
}'
#!/bin/bash
# stem-stats: Analyze audio stems and output key statistics
# Usage: stem-stats <audio_file> [audio_file2 ...]
#
# Output: duration, RMS, peak levels, bit depth for each file
set -euo pipefail
if [[ $# -eq 0 ]]; then
echo "Usage: stem-stats <audio_file> [audio_file2 ...]" >&2
exit 1
fi
printf "%-40s %10s %10s %10s %10s %8s\n" "FILE" "DURATION" "RMS_dB" "PEAK_dB" "RMS_PK_dB" "BITS"
printf "%-40s %10s %10s %10s %10s %8s\n" "----" "--------" "------" "-------" "---------" "----"
for FILE in "$@"; do
if [[ ! -f "$FILE" ]]; then
echo "Warning: File not found: $FILE" >&2
continue
fi
stats=$(sox "$FILE" -n stats 2>&1)
duration=$(echo "$stats" | grep "Length s" | awk '{print $3}')
rms=$(echo "$stats" | grep "RMS lev dB" | head -1 | awk '{print $4}')
peak=$(echo "$stats" | grep "Pk lev dB" | head -1 | awk '{print $4}')
rms_pk=$(echo "$stats" | grep "RMS Pk dB" | head -1 | awk '{print $4}')
bits=$(echo "$stats" | grep "Bit-depth" | head -1 | awk '{print $2}')
# Format duration as HH:MM:SS
dur_int=${duration%.*}
dur_fmt=$(printf '%02d:%02d:%02d' $((dur_int/3600)) $(((dur_int%3600)/60)) $((dur_int%60)))
printf "%-40s %10s %10s %10s %10s %8s\n" "$(basename "$FILE")" "$dur_fmt" "$rms" "$peak" "$rms_pk" "$bits"
done
#!/bin/bash
# transition-detect: Detect track transitions via energy/spectral changes
# Usage:
# transition-detect <audio_file> [sensitivity=6] [interval=10]
# transition-detect --multi <file1> <file2> ... [-- sensitivity interval]
# transition-detect --from-mix [mixfile.json] [-- sensitivity interval]
#
# Detects both:
# - Silence gaps (traditional transitions) - requires ALL stems quiet
# - Energy jumps (livecoding-style hot swaps)
#
# In multi mode, combines energy from all stems - better for livecoding sets
# where kick might drop but bass continues.
set -euo pipefail
# Defaults
SENSITIVITY=6
INTERVAL=10
MULTI_MODE=false
FILES=()
# Parse args
while [[ $# -gt 0 ]]; do
case "$1" in
--from-mix)
# Load stems from .mix.json
MIXFILE="${2:-${MIXFILE:-project.mix.json}}"
if [[ -f "$MIXFILE" ]]; then
MIXDIR=$(dirname "$MIXFILE")
while IFS= read -r stem; do
if [[ -f "$MIXDIR/$stem" ]]; then
FILES+=("$MIXDIR/$stem")
fi
done < <(jq -r '.stems[].file' "$MIXFILE")
MULTI_MODE=true
echo "# Loaded ${#FILES[@]} stems from $MIXFILE" >&2
else
echo "Error: Mix file not found: $MIXFILE" >&2
exit 1
fi
shift 2 2>/dev/null || shift
;;
--multi)
MULTI_MODE=true
shift
;;
--)
shift
SENSITIVITY="${1:-6}"
INTERVAL="${2:-10}"
break
;;
-*)
echo "Unknown option: $1" >&2
exit 1
;;
*)
if [[ -f "$1" ]]; then
FILES+=("$1")
elif [[ "$MULTI_MODE" == false && ${#FILES[@]} -eq 0 ]]; then
# Single file mode, remaining args are sensitivity/interval
FILES+=("$1")
SENSITIVITY="${2:-6}"
INTERVAL="${3:-10}"
break
else
# Might be sensitivity/interval in multi mode without --
if [[ ${#FILES[@]} -gt 0 ]] && [[ "$1" =~ ^[0-9]+$ ]]; then
SENSITIVITY="$1"
INTERVAL="${2:-10}"
break
fi
fi
shift
;;
esac
done
if [[ ${#FILES[@]} -eq 0 ]]; then
echo "Usage: transition-detect <audio_file> [sensitivity] [interval]" >&2
echo " transition-detect --multi <file1> <file2> ... [-- sensitivity interval]" >&2
echo " transition-detect --from-mix [mixfile.json] [-- sensitivity interval]" >&2
exit 1
fi
# Get duration from first file
DURATION=$(sox "${FILES[0]}" -n stat 2>&1 | grep "Length" | awk '{print int($3)}')
echo "# Transition detection"
echo "# Files: ${#FILES[@]} ($(basename "${FILES[0]}")$([ ${#FILES[@]} -gt 1 ] && echo ", ..."))"
echo "# Duration: ${DURATION}s, Sensitivity: ${SENSITIVITY}dB, Interval: ${INTERVAL}s"
echo "# Mode: $([ "$MULTI_MODE" == true ] || [ ${#FILES[@]} -gt 1 ] && echo "multi-stem (combined energy)" || echo "single-stem")"
echo "#"
echo "# TYPE TIME RMS_dB DELTA NOTES"
get_combined_rms() {
local start=$1
local dur=$2
local max_rms=-100
for file in "${FILES[@]}"; do
local rms=$(sox "$file" -n trim "$start" "$dur" stats 2>&1 | grep "RMS lev dB" | awk '{print $4}')
if [[ "$rms" != "-inf" ]] && (( $(echo "$rms > $max_rms" | bc -l 2>/dev/null || echo 0) )); then
max_rms=$rms
fi
done
echo "$max_rms"
}
is_all_silent() {
local start=$1
local dur=$2
local threshold=${3:--50}
for file in "${FILES[@]}"; do
local rms=$(sox "$file" -n trim "$start" "$dur" stats 2>&1 | grep "RMS lev dB" | awk '{print $4}')
if [[ "$rms" != "-inf" ]] && (( $(echo "$rms > $threshold" | bc -l 2>/dev/null || echo 0) )); then
return 1 # Not silent
fi
done
return 0 # All silent
}
prev_rms=""
prev_time=0
for ((i=0; i<DURATION; i+=INTERVAL)); do
if [[ ${#FILES[@]} -gt 1 ]] || [[ "$MULTI_MODE" == true ]]; then
rms=$(get_combined_rms "$i" "$INTERVAL")
else
rms=$(sox "${FILES[0]}" -n trim "$i" "$INTERVAL" stats 2>&1 | grep "RMS lev dB" | awk '{print $4}')
fi
# Handle silence (very low RMS)
if [[ "$rms" == "-inf" ]] || [[ "$rms" == "-100" ]] || (( $(echo "$rms < -60" | bc -l 2>/dev/null || echo 0) )); then
rms_val=-70
else
rms_val=$rms
fi
if [[ -n "$prev_rms" ]]; then
if [[ "$prev_rms" == "-inf" ]] || (( $(echo "$prev_rms < -60" | bc -l 2>/dev/null || echo 0) )); then
prev_val=-70
else
prev_val=$prev_rms
fi
delta=$(echo "$rms_val - $prev_val" | bc -l)
abs_delta=$(echo "if ($delta < 0) -1*$delta else $delta" | bc -l)
# Detect transitions
if (( $(echo "$abs_delta > $SENSITIVITY" | bc -l) )); then
time_fmt=$(printf '%02d:%02d:%02d' $((i/3600)) $(((i%3600)/60)) $((i%60)))
if (( $(echo "$delta > 0" | bc -l) )); then
type="ENERGY_UP"
note="Track starts or builds"
else
type="ENERGY_DN"
note="Track ends or drops"
fi
printf "%-10s %s %7.1f %+6.1f %s\n" "$type" "$time_fmt" "$rms_val" "$delta" "$note"
fi
# Detect TRUE silence (all stems quiet)
if [[ ${#FILES[@]} -gt 1 ]]; then
if is_all_silent "$i" "$INTERVAL" && ! is_all_silent "$prev_time" "$INTERVAL"; then
time_fmt=$(printf '%02d:%02d:%02d' $((i/3600)) $(((i%3600)/60)) $((i%60)))
printf "%-10s %s %7.1f %+6.1f %s\n" "SILENCE" "$time_fmt" "$rms_val" "$delta" "All stems silent"
fi
elif (( $(echo "$rms_val < -50 && $prev_val > -40" | bc -l) )); then
time_fmt=$(printf '%02d:%02d:%02d' $((i/3600)) $(((i%3600)/60)) $((i%60)))
printf "%-10s %s %7.1f %+6.1f %s\n" "SILENCE" "$time_fmt" "$rms_val" "$delta" "Silence begins"
fi
fi
prev_rms=$rms
prev_time=$i
done
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment