From productivity-skills
Use this skill whenever a user has an audio file (.wav/.mp3/.flac/etc.) that needs to loop as background on a website or web page — hero ambience, landing-page atmosphere, portfolio mood audio, ambient wind/rain/ocean/forest/breeze/pad beds, or any "play quietly behind the page while users read/scroll/interact" use case. Trigger on intents like making a clip loop without audible gaps or boundary artifacts on a site, fixing a click/tick/pop/bump at the loop boundary, fixing stereo bias or L/R imbalance on a web-bound ambient clip, normalizing loudness (LUFS) for web delivery, encoding a loop for web playback, or fading audio in on first user click/gesture — in any web framework (Next.js, Vue, Astro, Nuxt, plain HTML). The skill outputs a gapless FLAC plus a paste-in Web Audio snippet that unlocks on first interaction. Skip for general audio editing (cuts, mixing, effects), music/podcast mastering, or transcription.
How this skill is triggered — by the user, by Claude, or both
Slash command
/productivity-skills:audio-loop <input.wav> [options] — e.g. /audio-loop breeze.wav -t -28When to use
When the user has an audio clip that needs to loop without audible artifacts on a web page, or when `<audio loop>` is producing an audible gap or tick at each iteration. Keywords — audio, loop, ambient, hero, background, breeze, wind, rain, atmosphere, soundscape, seamless, gapless, flac, web audio, loudness, lufs, normalize, stereo balance, ffmpeg. For video loops use `/video-loop` (sibling — parallel architecture, crossfade + MP4/WebM encode). Skip for composing, mixing, or mastering music/podcasts, and for transcription — looping an existing music bed for a page stays in scope.
<input.wav> [options] — e.g. /audio-loop breeze.wav -t -28This skill is limited to the following tools:
The summary Claude sees in its skill listing — used to decide when to auto-load this skill
Produce a web-ready seamless audio loop from any source clip: auto-correct stereo balance, normalize loudness, encode lossless FLAC so `AudioBufferSourceNode{loop:true}` plays it sample-accurate and gapless, emit a drop-in Web Audio snippet that unlocks playback on the first user gesture.
Produce a web-ready seamless audio loop from any source clip: auto-correct stereo balance, normalize loudness, encode lossless FLAC so AudioBufferSourceNode{loop:true} plays it sample-accurate and gapless, emit a drop-in Web Audio snippet that unlocks playback on the first user gesture.
All ffmpeg work happens in scripts/audio-loop.sh — this skill validates the source, orchestrates the pipeline, and turns the script's summary into a report plus a ready-to-paste JS snippet.
| Flag | Default | Description |
|---|---|---|
-t <LUFS> | -28 | Integrated loudness target (ambient web default) |
-v <0..1> | 0.6 | Target volume baked into the emitted JS snippet |
-o <dir> | input dir | Output directory |
-s | off | Save to ~/.claude/output/{project}/audio-loop/{slug}/ |
-S | off | Force no-save |
-B | off | Disable stereo balance auto-correction |
Where each flag is handled. -t, -o, and -B pass through to scripts/audio-loop.sh. -v is skill-only — the agent reads it from $ARGUMENTS and interpolates it into the TARGET constant of the emitted JS snippet; the script never sees it. -s / -S follow the repo save-mode convention — the agent translates -s into an -o <save_path> passed to the script, where <save_path> is ~/.claude/output/{project}/audio-loop/{slug}/ ({project} = kebab-cased basename of the git toplevel, else cwd; create it $HOME-expanded, report the fully-expanded absolute path — no tilde, no magic).
Deliberately no crossfade flag. If the source WAV has a real sample-level discontinuity at the loop boundary, that's source editing — outside this skill's scope. If the user is hearing a bump with FLAC + Web Audio, see Diagnostic by negative result below.
command -v ffmpeg ffprobe. Missing → stop and ask the user to install (macOS: ! brew install ffmpeg, Debian/Ubuntu: ! sudo apt install ffmpeg). Never auto-install.
The script reads duration, sample rate, channel count, and per-channel RMS via astats. Surface these early — the user sees the starting point before any processing.
If the two channels differ by more than 1 dB RMS, the ear locks onto the louder side on sustained ambient content. The script auto-corrects with a pan filter unless -B is set:
pan=stereo|c0=FL|c1=<gain>*FR # if R is louder
pan=stereo|c0=<gain>*FL|c1=FR # if L is louder
Where gain = 10^(-|delta_dB| / 20). The script computes delta_dB from the astats pass, picks the right direction, and wires the filter accordingly. Below the 1 dB threshold the imbalance isn't reliably perceptible on sustained ambient content — no filter is applied.
$SKILL_DIR = this skill's folder — ${CLAUDE_SKILL_DIR} in Claude Code, the directory containing this SKILL.md elsewhere.
bash "$SKILL_DIR"/scripts/audio-loop.sh <input> [flags]
The script chains: probe → (optional pan correction) → loudnorm=I=<target>:TP=-2:LRA=7 → aresample=<source_rate> (crucial — see Rules) → encode FLAC (-c:a flac -compression_level 8). It emits RESULT: key=value lines on stdout.
Parse RESULT: lines and compose:
-v targetReport template:
| File | Size | Codec | Integrated LUFS | True peak |
|------|------|-------|-----------------|-----------|
| `<stem>.flac` | X MB | FLAC | -28.0 | -2.0 dBFS |
Stereo balance: L -19.70 dB / R -19.71 dB — centred (Δ 0.01 dB)
Duration: 6.50 s · Sample rate: 48 kHz · Channels: 2
Web Audio snippet (paste-in-page):
<script>
(() => {
const AUDIO_URL = '/audio/<stem>.flac'; // adjust to where you serve the file
const TARGET = <v-value>;
const FADE_MS = 700;
let ctx, gain, bufferPromise, entered = false;
function preload() {
if (ctx) return;
const Ctor = window.AudioContext || window.webkitAudioContext;
if (!Ctor) return;
ctx = new Ctor();
gain = ctx.createGain();
gain.gain.value = 0;
gain.connect(ctx.destination);
bufferPromise = fetch(AUDIO_URL)
.then(r => r.arrayBuffer())
.then(ab => ctx.decodeAudioData(ab));
}
async function unlock() {
if (entered || !ctx) return;
try { await ctx.resume(); } catch { return; }
if (ctx.state !== 'running') return;
let buf;
try { buf = await bufferPromise; } catch { return; }
if (entered) return; // second guard — a concurrent unlock() may have finished during the await
const src = ctx.createBufferSource();
src.buffer = buf;
src.loop = true;
src.connect(gain);
src.start(0);
entered = true;
const now = ctx.currentTime;
gain.gain.setValueAtTime(0, now);
gain.gain.linearRampToValueAtTime(TARGET, now + FADE_MS / 1000);
}
const IGNORED = new Set(['Shift','Control','Alt','Meta','Tab','Escape','CapsLock']);
const onKeydown = e => {
if (IGNORED.has(e.key)) return;
document.removeEventListener('keydown', onKeydown, { capture: true });
unlock();
};
preload();
document.addEventListener('pointerdown', () => unlock(), { capture: true, once: true });
document.addEventListener('keydown', onKeydown, { capture: true });
})();
</script>
For scroll-tied volume or any multi-channel control surface on top of the baseline, see references/scroll-tied-pattern.md — it documents the multiplicative factors architecture (gain = TARGET × fadeInFactor × scrollVolumeFactor) so additional control dimensions compose cleanly.
<audio loop> or AAC)Useful context for debugging "I still hear a tick every few seconds" reports.
<audio loop> resets its decoder between iterations — the AAC pipeline contributes a few milliseconds of priming + MDCT-boundary artifacts that read as an audible gap on short loops (under 10 s). AudioBufferSourceNode{loop:true} is sample-accurate by spec: it wraps from loopEnd straight into loopStart with no decoder reset, and the buffer it loops is whatever decodeAudioData returned.
That means the codec baked into the decoded buffer still matters. AAC's priming samples (typically 2048 samples ≈ 43 ms at 48 kHz) are embedded in the buffer on many browser decoders — the loop wraps into those priming samples and the ear hears it. FLAC is lossless and has no priming, so the decoded buffer is byte-identical to the source WAV — genuinely seamless. The trade is file size: FLAC is typically 6–8× larger than AAC 128 kbps on noise-heavy content, but still modest at a few hundred KB to low single-digit MB for ambient loops.
If the user reports a lingering bump and tried a crossfade that made it worse, the discontinuity isn't at the signal layer — a real crossfade would smooth a sample-level click. It's at the codec layer (priming, MDCT, or similar). The fix is switching format, not masking. This is exactly why this skill has no crossfade flag — the absence pushes toward the right diagnosis.
Modern browsers block audible playback without a prior user gesture. The emitted snippet attaches one-time pointerdown and filtered keydown listeners on document — any real interaction anywhere on the page unlocks the audio, no splash screen or dedicated button required. mousemove, scroll, and wheel are not gestures per the spec; don't try to hook into them. pointermove isn't either. On reload the unlock must happen again — this is a per-navigation browser constraint with no workaround short of the user granting the origin autoplay privilege explicitly.
aresample after loudnorm. loudnorm silently upsamples to 192 kHz for its measurement pass; without the trailing aresample the FLAC is 4× the size it should be.-28 LUFS for ambient web audio (quiet-enough-to-not-intrude, loud-enough-to-hear over UI sounds). Louder targets (e.g. -18 for hero music) are a user call — pass -t explicitly.-o <dir> to write elsewhere.Guides creation, editing, and verification of skills for AI coding agents using test-driven development with subagent scenarios. Use when authoring or debugging skills.
npx claudepluginhub coroboros/agent-skills --plugin productivity-skills