Skip to content

Instantly share code, notes, and snippets.

@lovelaced
Created March 30, 2026 20:51
Show Gist options
  • Select an option

  • Save lovelaced/f42278c320341c1d82be813b261c7c9b to your computer and use it in GitHub Desktop.

Select an option

Save lovelaced/f42278c320341c1d82be813b261c7c9b to your computer and use it in GitHub Desktop.
suno sfx generation + trimmer

ffmpeg Pitfalls for Short Audio Trimming

Hard-won lessons from building the SFX trimmer pipeline. These apply to any ffmpeg work with sub-second audio clips.

1. The -ss + -af Interaction Bug

Problem: When -ss (seek) and -af (audio filter chain) are used in the same command, the filters operate on the pre-seek timeline. A fade-out set at 0.38s into a 0.4s clip will be applied at 0.38s of the original file, not the trimmed output.

Result: The audio in the trim window gets zeroed out by the fade, producing silence.

Solution: Use atrim inside the filter graph. This keeps seeking and filtering in the same timeline:

ffmpeg -i input.wav \
  -af "atrim=start=0.5:duration=0.4,asetpts=PTS-STARTPTS,afade=t=out:st=0.38:d=0.02" \
  -acodec pcm_s16le output.wav

The asetpts=PTS-STARTPTS resets timestamps after the trim so the fade operates on the correct timeline.

2. ebur128 Is Wrong for Short Clips

Problem: The ebur128 filter's Momentary loudness (M) uses a fixed 400ms sliding window per the EBU R128 standard. This cannot be changed. For files under 2s, many unrelated files will report the same peak timestamp because they hit the same quantization boundary.

Evidence: In our batch of 23 files, 12 reported peak=0.3000s -- all landing on the same 400ms window edge.

Solution: Use astats=metadata=1:reset=1 which resets statistics per codec frame (~24ms for 48kHz audio). Parse with ametadata=print:

ffmpeg -i input.wav \
  -af "astats=metadata=1:reset=1,ametadata=print:key=lavfi.astats.Overall.RMS_level:file=-" \
  -f null - 2>/dev/null

Available keys: RMS_level (perceived loudness), Peak_level (transient detection), Crest_factor (peak-to-RMS ratio).

3. dynaudnorm Destroys Short Clips

Problem: dynaudnorm divides audio into frames (default 500ms) and applies gain smoothing over a Gaussian window (default 31 frames). For a 0.15s clip:

  • Only ~1-3 analysis frames exist
  • Gaussian smoothing has almost nothing to work with
  • The filter can boost quiet sections to full scale while crushing transients
  • Sub-200ms clips often come out as silence

Solution: Two-pass peak normalization with volumedetect + volume:

# Pass 1: measure
MAX_VOL=$(ffmpeg -i input.wav -af "volumedetect" -f null /dev/null 2>&1 \
  | grep "max_volume" | awk '{print $5}')

# Pass 2: apply
GAIN=$(echo "-1.0 - $MAX_VOL" | bc -l)
ffmpeg -i input.wav -af "volume=${GAIN}dB" output.wav

This works at any clip length, preserves waveform shape, and is deterministic.

4. loudnorm Is Also Unreliable for Short Clips

Problem: loudnorm targets integrated loudness using a 400ms gating window with 75% overlap. Clips shorter than ~400ms don't provide enough data for meaningful measurement. The filter technically runs but the target loudness won't reflect perceived loudness.

When to use: Only for clips 1s and above where you need perceptual loudness matching. For a mixed batch of 0.15s-8s SFX, peak normalization is more consistent.

5. MP3 Seeking Is Frame-Based

Problem: MP3 frames are ~26ms at 44.1kHz (1152 samples). Seeking with -ss lands on the nearest frame boundary, not the exact sample. For a 0.15s clip, the start point can be off by up to 26ms -- a 17% relative error.

Solution: Always work in WAV/PCM domain for trimming. Decode MP3 to WAV first, or better yet, download WAV from the source. Only encode to MP3 as the final step.

6. Suno WAV Files Have Quirky Headers

Problem: Some WAV files from Suno have metadata or header structures that cause ffmpeg's stream copy and seeking to behave incorrectly. Seeking to a valid position returns silence.

Solution: Force decode during trimming by specifying -acodec pcm_s16le on output, or use the atrim filter approach which always decodes.

7. Waveform Color Format

Problem: ffmpeg's showwavespic filter does not accept shorthand hex colors like #4af.

Solution: Use full 0xRRGGBB format: colors=0x44aaff.

Summary: The Reliable Pipeline

WAV input
  -> atrim (trim in filter graph, not -ss)
  -> asetpts=PTS-STARTPTS (reset timestamps)
  -> afade in + afade out (prevent clicks)
  -> pcm_s16le WAV intermediate
  -> volumedetect (measure peak)
  -> volume (apply gain to hit -1 dBFS)
  -> libmp3lame -q:a 2 (final encode)

Every step is simple, deterministic, and works at any clip length.

name suno-sfx-trimmer
description Generate Suno Sounds prompts for game/app SFX and auto-trim the output to precise durations using ffmpeg. Use when user asks to create sound effects, write Suno prompts for SFX, trim audio files to target durations, batch process game audio, or normalize sound effects. Triggers on "Suno sound effects", "SFX prompts", "trim audio", "sound effect generation", "game audio", "booster pack sounds", "normalize SFX". Do NOT use for music production, full song generation, or non-SFX audio work.
metadata
author version category tags
burrito
1.0.0
audio
suno
sfx
audio
ffmpeg
game-audio

Suno SFX Trimmer

End-to-end workflow for generating short sound effects with Suno Sounds and auto-trimming them to exact durations using ffmpeg. Built for game UI, app interactions, and interactive experiences.

When to Use

  • Writing Suno Sounds prompts optimized for SFX (not music)
  • Batch trimming AI-generated audio to precise durations
  • Normalizing a set of sound effects to consistent loudness
  • Building an SFX spec for interactive experiences (games, apps, pack openings)

Prerequisites

  • ffmpeg (with libmp3lame) installed and on PATH
  • Suno account with access to the Sounds feature (Create > Custom > Sounds)
  • Raw audio files as WAV (not MP3 -- Suno offers WAV downloads, always prefer them)

Part 1: Writing Suno Sounds Prompts

Prompt Structure

Suno Sounds prompts should be concise and use recognizable audio vocabulary. Each prompt should specify:

  1. Type: One Shot (single sound) or Loop (seamless repeating)
  2. Key: Always specify a musical key, even for non-tonal SFX -- it anchors the resonant frequency
  3. Prompt text: 1-2 sentences of clear, evocative description
  4. Duration note: Suno has no precise duration control, so generate long and trim in post

Key Scheme Strategy

Use a consistent key scheme so layered sounds never clash:

  • Minor key (e.g., C minor) for tension, atmosphere, anticipation
  • Major key (e.g., C major) for rewards, positive moments, resolution
  • Keep everything in the same root note (e.g., all in C) so simultaneous sounds harmonize

Prompt Best Practices

DO:

  • Use evocative, physical descriptions: "glass-shattering sparkle", "treasure chest bursting open"
  • Name specific sound qualities: "whoosh", "chime", "rumble", "shimmer", "crinkle"
  • Describe the emotional quality: "satisfying", "ethereal", "weighty", "crisp"
  • Use analogies: "like tiny metal confetti hitting a surface"

DON'T:

  • Use Hz frequency ranges (Suno ignores "40-60Hz" -- say "deep sub-bass" instead)
  • Use overly technical audio terms (no "8-12kHz transient" -- say "bright crystalline top end")
  • Write prompts longer than ~200 characters
  • Use contradictory descriptors

Example Prompts

Type: One Shot | Key: C minor
Prompt: Deep bass boom with bright glass-shattering sparkle on top. Heavy sub-bass
hit layered with sharp crystalline chime transient. Magical impact, powerful but
contained. Like a treasure chest bursting open.
Type: One Shot | Key: C major
Prompt: Three-note ascending arpeggio C-E-G with crystalline sparkle burst at the
peak. Musical phrase rising into a wash of bright shimmering particles. Exciting
and celebratory.
Type: Loop | Key: C minor
Prompt: Quiet high-frequency twinkling sparkle. Tiny crystal bells catching light.
Magical shimmering texture, very soft and delicate. Seamless ambient shimmer.

Suno Sounds Workflow

  1. Go to Suno > Create > Custom > Sounds
  2. Set Type (One Shot or Loop)
  3. Set Key as specified
  4. Paste the prompt text
  5. Generate -- Suno produces 2 takes per prompt
  6. Listen to both, download the best as WAV
  7. Name the file to match your spec (e.g., reveal-boom.wav)

Part 2: Auto-Trimming with trim-sfx.sh

How It Works

The trimmer script (trim-sfx.sh) processes each WAV file through this pipeline:

  1. Peak detection via astats -- finds the loudest moment using per-frame RMS analysis (~24ms resolution)
  2. Window placement -- positions the trim window so the peak is ~20% in (captures attack + sustain/tail)
  3. Trim via atrim filter -- sample-accurate extraction in the filter graph
  4. Fade in/out -- 5ms fade-in, configurable fade-out (10-300ms) to prevent clicks
  5. Peak normalize -- two-pass volumedetect + volume to hit -1 dBFS
  6. Encode to MP3 via libmp3lame at quality 2
  7. Waveform PNGs generated for visual QA (raw vs trimmed)

Usage

./trim-sfx.sh [input_dir] [output_dir]
# Defaults: ./raw -> ./sfx
# Waveforms written to ./waveforms/

Place WAV files in ./raw/ with names matching the target config in the script (e.g., reveal-boom.wav). Run the script. Trimmed MP3s appear in ./sfx/, waveform PNGs in ./waveforms/.

Adding/Modifying Sound Targets

Edit the get_target() function in trim-sfx.sh. Format is "duration:fade_out" in seconds:

get_target() {
  case "$1" in
    my-new-sound)  echo "0.5:0.03" ;;  # 0.5s duration, 30ms fade-out
    # ...
  esac
}

Fade-out guidelines:

  • Very short sounds (< 0.2s): 10ms fade
  • Short sounds (0.2-0.5s): 20ms fade
  • Medium sounds (0.5-1.5s): 30-50ms fade
  • Long sounds (> 1.5s): 100-300ms fade

Visual QA

After running, check waveforms/ for before/after PNGs:

  • *-raw.png shows the full Suno output with audio placement
  • *-trimmed.png shows the extracted, normalized clip
  • If a trimmed waveform looks empty, the peak detection may have found the wrong region -- check the raw waveform to understand the file's structure

Critical Technical Decisions (and Why)

These were learned through debugging. Consult references/ffmpeg-pitfalls.md for full details.

Use atrim filter, NOT -ss flag for trimming

Never combine -ss (seek) with -af (audio filters) in the same ffmpeg command. The fade filter gets applied to the pre-seek timeline, zeroing out audio at the wrong position. Use atrim inside the filter graph instead:

# BAD: -ss + -af interaction produces silence
ffmpeg -i input.wav -ss 0.5 -t 0.4 -af "afade=..." output.wav

# GOOD: atrim keeps everything in the filter graph
ffmpeg -i input.wav -af "atrim=start=0.5:duration=0.4,asetpts=PTS-STARTPTS,afade=..." output.wav

Use astats for peak detection, NOT ebur128

ebur128 uses a fixed 400ms sliding window (mandated by the EBU R128 standard). For sub-second SFX, this window is too wide -- it quantizes all results to the same boundary. astats=reset=1 gives per-frame analysis at ~24ms resolution.

Use two-pass peak normalization, NOT dynaudnorm or loudnorm

  • dynaudnorm produces silence on clips under ~500ms. It needs a Gaussian window of multiple frames and has insufficient context for short SFX.
  • loudnorm (EBU R128) needs at least 400ms for meaningful measurement. Unreliable for sub-second clips.
  • volumedetect + volume works perfectly at any length. Simple, deterministic, preserves waveform shape.

Always use WAV input, not MP3

MP3 has frame-based seeking (~26ms granularity), encoder delay padding, and generation loss on re-encode. WAV is sample-accurate. Suno offers WAV downloads -- always use them.

Layering SFX

Many game moments play multiple sounds simultaneously. Design your SFX to layer:

  • Frequency separation: bass impact + mid-range body + high sparkle = full spectrum
  • Temporal offset: stagger by 50-100ms so transients don't collide
  • Rarity escalation: more layers, richer harmonics, longer tails = rarer/more important
  • Consistent key: everything in the same key so simultaneous playback harmonizes

Common Issues

Trimmed file is silent

The Suno WAV has the actual sound at the end of the file. The peak detection should find it, but if the window math is wrong, check waveforms/*-raw.png to see where the audio actually is.

Peak detection finds noise instead of the sound

Lower the RMS threshold or try Peak_level instead of RMS_level in the astats key for transient-heavy sounds.

Sound is too short after trimming

Suno generated less audio than the target duration. Re-generate with a prompt that implies longer duration, or adjust the target in get_target().

#!/usr/bin/env bash
#
# trim-sfx.sh — Trim Suno Sounds WAV output to target durations, export MP3
#
# Usage:
# ./trim-sfx.sh [input_dir] [output_dir]
#
# input_dir: folder with raw Suno WAVs (default: ./raw)
# output_dir: folder for trimmed MP3 output (default: ./sfx)
#
# Pipeline per file:
# 1. Per-frame RMS analysis (astats) to find the loudest moment
# 2. Trim a window around the peak in PCM domain (sample-accurate)
# 3. Apply tiny fade-in/out to prevent clicks
# 4. Two-pass peak normalize to -1 dBFS
# 5. Encode to MP3 (libmp3lame -q:a 2)
# 6. Generate before/after waveform PNGs for visual QA
set -euo pipefail
INPUT_DIR="${1:-./raw}"
OUTPUT_DIR="${2:-./sfx}"
WAVE_DIR="./waveforms"
TMP_DIR=$(mktemp -d)
trap 'rm -rf "$TMP_DIR"' EXIT
if ! command -v ffmpeg &>/dev/null; then
echo "Error: ffmpeg is required but not found." >&2
exit 1
fi
mkdir -p "$OUTPUT_DIR" "$WAVE_DIR"
# Returns "duration:fade_out" for a given filename stem, or empty if unknown.
get_target() {
case "$1" in
ambient-tone) echo "8.0:0.3" ;;
pack-enter) echo "0.4:0.02" ;;
pack-land) echo "0.3:0.02" ;;
idle-shimmer) echo "4.0:0.1" ;;
tear-start) echo "0.15:0.01" ;;
tear-rip) echo "0.9:0.02" ;;
tear-sparkle) echo "0.7:0.02" ;;
tear-scraps) echo "0.4:0.02" ;;
reveal-boom) echo "0.6:0.03" ;;
reveal-whoosh) echo "0.5:0.02" ;;
reveal-shimmer) echo "1.2:0.05" ;;
reveal-rumble) echo "0.15:0.01" ;;
pack-drop) echo "0.4:0.02" ;;
cards-fan) echo "0.4:0.02" ;;
card-flip) echo "0.15:0.01" ;;
card-zoom) echo "0.25:0.02" ;;
card-swipe) echo "0.25:0.02" ;;
rarity-common) echo "0.4:0.02" ;;
rarity-uncommon) echo "0.6:0.03" ;;
rarity-rare) echo "1.0:0.05" ;;
rarity-legendary) echo "2.5:0.1" ;;
summary-jingle) echo "1.0:0.05" ;;
summary-taps) echo "0.5:0.02" ;;
*) echo "" ;;
esac
}
# Find the timestamp of peak RMS loudness using per-frame astats.
find_peak_time() {
local file="$1"
local peak_time
peak_time=$(ffmpeg -i "$file" \
-af "astats=metadata=1:reset=1,ametadata=print:key=lavfi.astats.Overall.RMS_level:file=-" \
-f null - 2>/dev/null \
| awk '
/^frame:/ {
n = split($0, p, " ")
for (i = 1; i <= n; i++)
if (p[i] ~ /^pts_time:/)
{ split(p[i], t, ":"); time = t[2] + 0 }
}
/^lavfi\.astats\.Overall\.RMS_level=/ {
split($0, a, "=")
val = a[2] + 0
if (val > max || NR <= 2) { max = val; max_time = time }
}
END { printf "%.4f", max_time }
') || true
echo "${peak_time:-0.0}"
}
# Get duration of a file in seconds
file_duration() {
ffprobe -v error -show_entries format=duration -of csv=p=0 "$1" 2>/dev/null
}
# Generate a waveform PNG
make_waveform() {
local file="$1" out="$2"
ffmpeg -y -hide_banner -loglevel error \
-i "$file" \
-filter_complex "showwavespic=s=800x120:colors=0x44aaff" \
-frames:v 1 "$out"
}
echo "=== Booster Pack SFX Trimmer ==="
echo "Input: $INPUT_DIR (WAV)"
echo "Output: $OUTPUT_DIR (MP3)"
echo "Waves: $WAVE_DIR"
echo ""
processed=0
skipped=0
for file in "$INPUT_DIR"/*.wav; do
[ -f "$file" ] || continue
filename=$(basename "$file")
stem="${filename%.wav}"
target=$(get_target "$stem")
if [ -z "$target" ]; then
echo "SKIP $filename (no target duration defined)"
skipped=$((skipped + 1))
continue
fi
duration="${target%%:*}"
fade_out="${target##*:}"
raw_dur=$(file_duration "$file")
# Find the loudest moment
peak_time=$(find_peak_time "$file")
# Place trim window: peak at ~20% into the window
offset_before=$(printf "%.4f" "$(echo "$duration * 0.2" | bc -l)")
start=$(printf "%.4f" "$(echo "$peak_time - $offset_before" | bc -l)")
# Clamp start >= 0
if [ "$(echo "$start < 0" | bc -l)" = "1" ]; then
start="0.0000"
fi
# Clamp so we don't run past end of file
max_start=$(printf "%.4f" "$(echo "$raw_dur - $duration" | bc -l)")
if [ "$(echo "$max_start < 0" | bc -l)" = "1" ]; then
max_start="0.0000"
fi
if [ "$(echo "$start > $max_start" | bc -l)" = "1" ]; then
start="$max_start"
fi
fade_in="0.005"
fade_start=$(printf "%.4f" "$(echo "$duration - $fade_out" | bc -l)")
echo "TRIM $stem raw=${raw_dur}s peak=${peak_time}s start=${start}s dur=${duration}s"
# Waveform of raw input
make_waveform "$file" "$WAVE_DIR/${stem}-raw.png"
# Trim + fades using atrim filter (avoids -ss + -af interaction bug)
tmp_trimmed="$TMP_DIR/${stem}.wav"
ffmpeg -y -hide_banner -loglevel error \
-i "$file" \
-af "atrim=start=${start}:duration=${duration},asetpts=PTS-STARTPTS,afade=t=in:st=0:d=${fade_in},afade=t=out:st=${fade_start}:d=${fade_out}" \
-acodec pcm_s16le \
"$tmp_trimmed"
# Two-pass peak normalize to -1 dBFS, encode to MP3
max_vol=$(ffmpeg -i "$tmp_trimmed" -af "volumedetect" -f null /dev/null 2>&1 \
| grep "max_volume" | awk '{print $5}')
if [ -n "$max_vol" ]; then
gain=$(printf "%.2f" "$(echo "-1.0 - $max_vol" | bc -l)")
else
gain="0.00"
fi
ffmpeg -y -hide_banner -loglevel error \
-i "$tmp_trimmed" \
-af "volume=${gain}dB" \
-codec:a libmp3lame -q:a 2 \
"$OUTPUT_DIR/${stem}.mp3"
# Waveform of trimmed output
make_waveform "$OUTPUT_DIR/${stem}.mp3" "$WAVE_DIR/${stem}-trimmed.png"
rm -f "$tmp_trimmed"
processed=$((processed + 1))
done
echo ""
echo "Done. $processed trimmed, $skipped skipped."
echo "Output in: $OUTPUT_DIR/"
echo "Waveforms in: $WAVE_DIR/"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment