
Create Sound
- 43 installs
- 502 repo stars
- Updated April 26, 2026
- raphaelsalaja/audio
Helps with ai & agent building tasks during AI-assisted development.
About
create-sound is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- create-sound
- AI & Agent Building
- AI-coding skill
Create Sound by the numbers
- 43 all-time installs (skills.sh)
- +2 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #7,921 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/raphaelsalaja/audio --skill create-soundAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 43 |
|---|---|
| repo stars | ★ 502 |
| Last updated | April 26, 2026 |
| Repository | raphaelsalaja/audio ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Create Sound
Generated fromrules/*.mdbysrc/build.mjs. Do not edit by hand.
Pick a generation path with pipeline-detect-input, then walk the matching section.
1. Generation Pipeline
_Procedural steps the agent runs end-to-end. Start here when handling any create-sound request._
1.1 Detect input mode and route the request _(CRITICAL)_
Decide which path to run based on what the user provided.
| Input | Path |
|---|---|
| Prompt only (no audio attachment) | Skip interpret-*. Go to pipeline-pick-base-layer. |
| Audio file only | Run all interpret-* rules. Skip event-* / mood-*. |
| Both prompt and audio | Run interpret-* first, then treat the prompt as a refinement layer over the measured SoundDefinition. |
Detecting audio
Look for attached files matching *.wav, *.mp3, *.flac, *.ogg, or any path the user references that resolves to an audio file. A JSON manifest (*.json next to a sprite) is also an audio-path signal.
Refinement examples (prompt + audio)
| Prompt qualifier | Refinement on measured definition |
|---|---|
| "warmer" | add filter: { type: "lowpass", frequency: 2500 } |
| "shorter" / "punchier" | clamp envelope.decay to <= 0.06 |
| "brighter" | drop or raise any lowpass cutoff |
| "with reverb" | append effects: [{ type: "reverb", decay: 0.5, mix: 0.15 }] |
| "lower octave" | halve source.frequency (or both start/end) |
Output of this step
Produce an internal note like:
Input: prompt + audio
Plan: run interpret-* on out/click.wav, then refine with mood-warm.Then proceed to the next pipeline step.
1.2 Pick a base layer from the prompt's event class _(CRITICAL)_
Tokenize the prompt and find the strongest event-class signal. Match against the event-* rules.
Token map
| Tokens in prompt | Event rule |
|---|---|
| click, tap, key, press, button | event-click / event-tap |
| tick, scroll, snap, focus | event-tick |
| success, complete, win, achievement, level-up, confetti | event-success / event-complete |
| error, fail, wrong, invalid, delete, destroy | event-error |
| modal, dialog, popup, drawer, sheet, sidebar, dropdown, menu | event-modal-open / event-modal-close |
| swoosh, slide, transition, page, tab | event-swoosh / event-whoosh |
| notification, alert, ding, bell, mention, badge | event-notification |
| toggle, switch, on, off | event-toggle |
Direction tokens (open vs close)
- "open", "appear", "in", "show", "expand", "confirm" -> ascending pitch.
- "close", "dismiss", "out", "hide", "collapse", "cancel" -> descending pitch.
Output
A starting SoundDefinition literal copied from the chosen event rule's example. The next step (pipeline-apply-mood) will mutate it.
If no event class fires confidently, default to event-click and let mood adjectives do the work.
1.3 Apply mood adjectives onto the base layer _(HIGH)_
After pipeline-pick-base-layer produces a starting SoundDefinition, scan the prompt for adjective tokens and apply each mood-* rule's mutation in order.
Order of application
1. Source-shape adjectives (warm, bright, glassy, metallic, lofi, retro, organic) - mutate source.type, source.fm, or add filter. 2. Envelope adjectives (punchy, airy) - mutate envelope.attack / envelope.decay. 3. Effect adjectives (reverby, delayed, crushed) - append to effects.
Conflict resolution
warm+bright-> the later token wins.lofi+glassy-> apply both, but capeffectsat 2 entries.punchy+airy-> they're orthogonal (envelope vs source); both apply.
Refinement on existing definition (audio + prompt path)
When the input mode is prompt + audio, treat each adjective as a refinement on the measured definition rather than from scratch:
| Adjective | Refinement |
|---|---|
| warmer | add or lower filter.frequency (lowpass at ~2500 Hz) |
| brighter | remove lowpass or raise its cutoff above 6 kHz |
| punchier | clamp envelope.decay <= 0.06, set envelope.attack: 0 |
| longer | extend envelope.decay and add release if missing |
| crisper | raise gain slightly and add fm: { ratio: 0.5, depth: 50 } |
Output
A mutated SoundDefinition. Hand off to pipeline-decide-layering.
1.4 Decide single-layer vs multi-layer _(MEDIUM-HIGH)_
| Event class | Default |
|---|---|
| click, tap, tick, hover, focus, swoosh | 1 layer (Layer) |
| toggle, copy, send, sync | 2 layers (paired pitches with delay) |
| success, complete, level-up, confetti | 3+ layers (chord with cascading delay) |
| error, delete | 2 layers (sawtooth + square) |
See layer-single, layer-octave-pair, layer-ascending-chord, layer-click-plus-body for the concrete shapes.
Promoting a single Layer to MultiLayerSound
If the prompt or refinement requires more than one layer, wrap:
{
layers: [<existing layer>, <new layer>],
// optional global effects, e.g. sidechain compressor, master EQ
}Per-layer gain values should sum to no more than ~0.6 (see validate-gain-budget).
Demoting MultiLayerSound to a single Layer
If only one layer survives mood application, emit the inner Layer directly rather than a one-element MultiLayerSound. Both validate, but the single-layer form is the canonical compact shape.
1.5 Emit, optionally render, optionally round-trip _(HIGH)_
1. Emit
Always return a TypeScript snippet ready to paste into a .web-kits/<patch>.ts file:
import type { SoundDefinition } from "@web-kits/audio";
export const myClick: SoundDefinition = {
source: { type: "sine", frequency: 1300, fm: { ratio: 0.5, depth: 60 } },
envelope: { decay: 0.012, release: 0.004 },
gain: 0.18,
};Plus a one-line rationale that names the prompt tokens you acted on:
"click" -> base from event-click; "warm" -> kept default sine, no extra filter needed at 1.3 kHz.2. Optional preview render
If the user asked for a WAV (or you want to grade your own output), use `packages/audio/src/offline.ts`:
import { renderToWav } from "@web-kits/audio";
import { writeFile } from "node:fs/promises";
const blob = await renderToWav(myClick, { duration: 0.3 });
await writeFile("preview.wav", Buffer.from(await blob.arrayBuffer()));duration should be attack + decay + release + 0.05 (small tail) or longer if reverb is present.
3. Optional round-trip validation
If you generated from a prompt and want to confirm the result matches intent, run the interpret-* rules against the rendered WAV and diff measured vs intended values:
| Field | Acceptable drift |
|---|---|
| Fundamental Hz | ±5% |
| Attack | ±2 ms |
| Decay | ±10% |
| Spectral centroid | ±20% of expected for the chosen waveform |
If drift exceeds tolerance, refine the definition (often by raising/lowering gain, tightening envelope, or adjusting filter.frequency) and render again.
2. Audio Interpretation
_FFT analysis sub-steps that fire when the user shares an audio file._
2.1 Acquire and split source audio _(HIGH)_
The user shared a single file or a sprite (one file containing many sounds). Before any FFT work, get one mono WAV per sound on disk.
Sprite from an npm package
npm pack <package-name> --pack-destination /tmp
tar -xzf /tmp/<package-name>-*.tgz -C /tmpLook for the MP3/WAV plus any JSON manifest mapping sound names to time offsets.
Manifest-driven slicing
ffmpeg -i sprite.mp3 \
-ss <start_seconds> -t <duration_seconds> \
-acodec pcm_s16le -ar 44100 \
output/<name>.wavSilence-detection slicing (no manifest)
ffmpeg -i sprite.mp3 -af silencedetect=noise=-40dB:d=0.05 -f null -Read the silence_start/silence_end lines and slice between gaps.
Output convention
Per-sound WAVs go in out/<name>.wav (mono, 44.1 kHz, 16-bit PCM). Downstream interpret rules call analyze.load_mono(path) from src/analyze.py.
2.2 Extract fundamental frequency and pitch sweep _(HIGH)_
Sample the spectrum at multiple time slices to detect both the static pitch and any sweep.
from analyze import load_mono, analyze_slice
sample_rate, data = load_mono("out/click.wav")
slices = [0, 5, 10, 20, 50] # ms
freqs_over_time = [analyze_slice(data, sample_rate, t) for t in slices]Mapping
| Observation | Output |
|---|---|
| All slices within ±5% | source.frequency: <Hz> (static) |
| Decreasing across slices | source.frequency: { start: <high>, end: <low> } |
| Increasing across slices | source.frequency: { start: <low>, end: <high> } |
Tips
- Skip the first 1-2 ms if the onset is a click transient; it pollutes the FFT.
- For very short sounds (< 20 ms) use fewer slices and a smaller window.
- Use a Hanning window before FFT (already applied in
analyze_slice) to reduce spectral leakage.
2.3 Extract ADSR envelope from amplitude _(HIGH)_
Smooth the time-domain amplitude, find onset/peak/sustain/end, and derive each ADSR stage.
from analyze import load_mono, extract_envelope
sample_rate, data = load_mono("out/click.wav")
env = extract_envelope(data, sample_rate)
# -> { "attack": 0.0008, "decay": 0.012, "sustain": 0.0, "release": 0.005 }Output shape
The dict maps 1:1 to the Envelope type:
envelope: {
attack: env.attack, // 0 if percussive
decay: env.decay,
sustain: env.sustain, // 0 for transient sounds, 0-1 for sustained
release: env.release,
}Heuristics
sustain < 0.01-> drop the field; the sound is percussive.attack < 0.001-> setattack: 0.release < 0.005-> clamp to0.005to avoid clicks at the end.
2.4 Classify oscillator waveform from harmonics _(HIGH)_
Compare the amplitude of the first 8 harmonics against the fundamental.
import numpy as np
from scipy.fft import rfft, rfftfreq
from analyze import classify_waveform
segment = data[:int(sample_rate * 0.02)].astype(float)
segment *= np.hanning(len(segment))
spectrum = np.abs(rfft(segment))
freqs = rfftfreq(len(segment), 1 / sample_rate)
waveform = classify_waveform(spectrum, freqs, fundamental_freq)
# -> "sine" | "triangle" | "square" | "sawtooth" | "wavetable"Mapping
| Pattern | source.type |
|---|---|
| Fundamental only, harmonics < -40 dB | sine |
| Odd harmonics rolling off as 1/n | triangle |
| Odd harmonics at roughly equal amplitude | square |
| All harmonics rolling off as 1/n | sawtooth |
| Custom harmonic profile (none of the above) | wavetable |
| No clear harmonic structure, broadband energy | noise |
When to fall back to wavetable
If the harmonic profile doesn't match a clean oscillator, extract the harmonic series instead:
from analyze import extract_harmonics
harmonics = extract_harmonics(spectrum, freqs, fundamental_freq, num_harmonics=16)
# -> { source: { type: "wavetable", harmonics, frequency: fundamental_freq } }Noise color
For broadband signals with no fundamental, classify by spectral slope:
from analyze import classify_noise_color
color = classify_noise_color(spectrum, freqs) # "white" | "pink" | "brown"
# -> { source: { type: "noise", color } }2.5 Detect filter type, cutoff, and resonance _(MEDIUM-HIGH)_
Compare the measured spectrum against the expected spectrum for the identified oscillator.
Cutoff via spectral centroid
from analyze import spectral_centroid
centroid = spectral_centroid(spectrum, freqs)Expected centroids at a 440 Hz fundamental: sine ~440, triangle ~880, sawtooth ~2200, square ~1760. If the measured centroid is significantly lower than expected, a lowpass is present; estimate cutoff at the -3 dB point.
Filter type from rolloff
| Observation | filter.type |
|---|---|
| High-frequency rolloff steeper than the source would produce | lowpass |
| Low-frequency rolloff | highpass |
| Narrow band of frequencies passes through | bandpass |
| Narrow notch removed | notch |
| Resonant peak near cutoff | High resonance |
Resonance (Q)
from analyze import estimate_resonance
q = estimate_resonance(spectrum, freqs, cutoff_hz)
# Returns 0.1 - 20.0Filter envelope
If brightness changes over time (bright attack fading to dull), there's a filter envelope:
from analyze import detect_filter_envelope
env = detect_filter_envelope(data, sample_rate)
# -> { "peak": 4000, "resting": 800, "decay_ms": 50 } or NoneMaps to:
filter: {
type: "lowpass",
frequency: env.resting,
envelope: { attack: 0, peak: env.peak, decay: env.decay_ms / 1000 },
}2.6 Detect post-source effects _(MEDIUM)_
Each detector returns a confidence-flavored hint, not a guarantee. Effects are harder to extract than source/envelope - report low confidence when ambiguous.
Reverb
from analyze import detect_reverb
result = detect_reverb(data, sample_rate, envelope_end_ms=120)
# -> { "type": "reverb", "decay": 0.6 } or NoneDelay (autocorrelation)
from analyze import detect_delay
result = detect_delay(data, sample_rate)
# -> { "type": "delay", "time": 0.25, "feedback": 0.3 } or NoneFM synthesis
Spectral sidebands at non-integer ratios of the fundamental indicate FM:
from analyze import detect_fm
fm = detect_fm(spectrum, freqs, fundamental_freq)
# -> { "fm": { "ratio": 0.5, "depth": 80 } } or NoneMaps to source.fm: { ratio, depth } (not a separate effect).
Tremolo and vibrato
Periodic amplitude or frequency modulation in the 1-20 Hz band suggests tremolo/vibrato. Track amplitude or pitch over time and call detect_lfo (see interpret-detect-lfo).
Bitcrusher / distortion
| Time-domain signature | Effect |
|---|---|
| Stepped/quantized waveform with aliasing artifacts | bitcrusher |
| Flat-topped waveform with added harmonics | distortion |
Chorus / flanger / phaser
Comb-filter pattern that sweeps over time produces moving notches in the spectrum. Hard to disambiguate algorithmically; flag for human review.
2.7 Detect LFO modulation _(LOW-MEDIUM)_
An LFO is sub-audio (0.1-20 Hz) periodic modulation of a parameter. Track the parameter over time, then run detect_lfo.
from analyze import detect_lfo
# 1. Track amplitude (or pitch, or spectral centroid) at regular intervals
window_ms = 10
samples_per_window = int(sample_rate * window_ms / 1000)
amp_over_time = [
float(np.max(np.abs(data[i:i + samples_per_window])))
for i in range(0, len(data) - samples_per_window, samples_per_window)
]
# 2. Detect periodicity
lfo = detect_lfo(np.array(amp_over_time), 1000 / window_ms)
# -> { "frequency": 5.0, "depth": 0.12 } or NoneMapping by tracked parameter
| Parameter tracked | LFO target |
|---|---|
| Amplitude | gain |
| Pitch | frequency or detune |
| Spectral centroid | filter.frequency |
| Pan position | pan |
Output
lfo: { type: "sine", frequency: lfo.frequency, depth: lfo.depth, target: "gain" }Pick type based on the shape of the modulation: smooth sinusoid -> sine, sharp ramp -> sawtooth, hard switching -> square.
2.8 Detect multi-layer sounds and stereo positioning _(MEDIUM)_
Multiple fundamentals -> MultiLayerSound
Inspect peaks in the spectrum. If two or more strong peaks are not integer multiples of one shared fundamental, the sound is layered.
from scipy.signal import find_peaks
peaks, props = find_peaks(spectrum, height=float(np.max(spectrum)) * 0.2)
peak_freqs = sorted(freqs[peaks])
# Check pairwise ratios. If no shared fundamental explains all peaks, treat as layered.For each detected fundamental, run the full pipeline (frequency, envelope, waveform, filter, effects) and emit one Layer per fundamental:
{
layers: [
{ source: { ... }, envelope: { ... }, gain: 0.2 },
{ source: { ... }, envelope: { ... }, gain: 0.15, delay: 0.04 },
]
}The earlier layer typically gets delay: 0 (omitted); subsequent layers offset their delay to match the measured onset gap.
Stereo and pan
from analyze import analyze_stereo
stereo = analyze_stereo(data)
# -> { "pan": 0.3, "stereo_width": 0.7 }pan magnitude | Output |
|---|---|
< 0.05 | omit (pan: 0 is default) |
0.05 - 1 | pan: <value> |
stereo_width > 0.5 with |pan| < 0.05 suggests a stereo effect (chorus, dual-layer). Consider splitting into two layers panned -0.5 / +0.5.
Fallback
If a sound is unsynthesizable (complex transients, recorded material, irreducible texture), fall back to:
{ source: { type: "sample", url: "..." } }and note that the original audio file should be used directly rather than re-synthesized.
3. UI Event Recipes
_Concrete SoundDefinition templates per UI event class. Used by the prompt path as the base layer._
3.1 Click - sine + low FM, very short decay _(HIGH)_
A short ascending sine sweep with light FM. The sweep gives the click "snap"; the FM adds harmonic body without making it metallic.
Incorrect (decay too long, sounds like a chime):
{ source: { type: "sine", frequency: 1300 }, envelope: { decay: 0.5 }, gain: 0.18 }Correct:
{
source: { type: "sine", frequency: { start: 200, end: 700 }, fm: { ratio: 0.5, depth: 80 } },
envelope: { attack: 0, decay: 0.06, sustain: 0, release: 0.02 },
gain: 0.25,
}Reference: .web-kits/core.ts click.
3.2 Complete - four-note ascending arpeggio _(MEDIUM-HIGH)_
Same C-major triad as success, but with C6 added on top and tighter 15 ms delay increments so the notes blur into a single gesture rather than reading as discrete pitches.
Reference: .web-kits/core.ts complete.
3.3 Error - layered sawtooth + square with descending sweep _(HIGH)_
Two descending sweeps stacked an octave apart. Lowpass filters keep the result from being abrasive. Same shape works for delete (slightly longer decay).
Incorrect (no filter, sounds like a buzzer):
{ source: { type: "sawtooth", frequency: { start: 320, end: 140 } }, envelope: { decay: 0.25 }, gain: 0.22 }Reference: .web-kits/core.ts error, _delete.
3.4 Modal-close - downward sine sweep _(MEDIUM)_
The inverse of modalOpen. Range is narrower because dismiss should feel less assertive than the entrance. Slightly lower gain for the same reason.
For drawer-close use 800 -> 350. For dropdown-close use 900 -> 500.
Reference: .web-kits/core.ts modalClose, drawerClose, dropdownClose.
3.5 Modal-open - upward sine sweep _(MEDIUM)_
A single sine sweeping from ~430 Hz up to ~1400 Hz over 80 ms. No FM, no filter; the cleanness signals "appearing".
For drawer-open use a slightly lower start (~350 Hz) and lower gain (~0.08). For dropdown-open use a smaller range (500 -> 1200) and decay ~60 ms.
Reference: .web-kits/core.ts modalOpen, drawerOpen, dropdownOpen.
3.6 Notification - FM-rich sine with light reverb _(HIGH)_
Two FM bells a fifth apart with 100 ms delay between them. The fm.ratio: 1.5 gives an inharmonic shimmer; the matched reverb on each layer glues them together.
For ding: single layer, fm.ratio: 3.5, reverb decay: 0.8. For mention: lower fundamental (660 Hz), fm.ratio: 2.5, slightly more attack.
Reference: .web-kits/core.ts notification, ding, mention, badge.
3.7 Success - ascending three-note sine chord _(HIGH)_
Three sine layers at C5 / E5 / G5 with delay cascading 0.07 s between them. The top note has a small upward sweep (G5 -> A5) so the chord resolves "upward" instead of just stopping.
Layer gains sum to 0.45, comfortably under the 0.6 budget.
Reference: .web-kits/core.ts success.
3.8 Swoosh - white noise through a sweeping bandpass _(MEDIUM)_
White noise is shaped by a bandpass filter whose center frequency sweeps from 300 Hz up to 4 kHz. The sweep direction is the gesture: peak above resting = upward swoosh, peak below resting (e.g., resting 2500, peak 400) = downward.
For slide-up use a similar shape with peak 3500. For slide-down flip to pink noise with envelope: { decay: 0.12, peak: 500 } (no attack on the filter envelope).
Reference: .web-kits/core.ts swoosh, slide, slideUp, slideDown.
3.9 Tap - static high sine + FM, ultra short _(HIGH)_
Single high pitch (no sweep), aggressive FM, decay under 20 ms. This is the "key-press" archetype.
Incorrect (frequency too low, sounds like a thump):
{ source: { type: "sine", frequency: 200 }, envelope: { decay: 0.015 }, gain: 0.2 }Correct:
{
source: { type: "sine", frequency: 1300, fm: { ratio: 0.5, depth: 100 } },
envelope: { attack: 0, decay: 0.015, sustain: 0, release: 0.005 },
gain: 0.2,
}Reference: .web-kits/core.ts tap, keyPress.
3.10 Tick - faintest possible sine _(MEDIUM)_
Highest frequency in the tap family. Decay under 15 ms. gain capped at ~0.15 because ticks fire often and must not dominate.
For scroll-snap reduce gain to 0.08; for focus/blur reduce to 0.04-0.06.
Reference: .web-kits/core.ts tick, scrollSnap, focus, blur.
3.11 Toggle - paired sines with delay (direction matters) _(MEDIUM)_
Two short sines: C7 (2093 Hz) and G7 (3136 Hz), 25 ms apart.
toggle-on: low note first, then high (ascending = enabling).toggle-off: high note first, then low (descending = disabling).
The same architecture works for copy (1200 Hz then 1400 Hz, 40 ms gap) and sync (C5 then G5).
Reference: .web-kits/core.ts toggleOn, toggleOff, copy, sync.
3.12 Whoosh - longer, slower swoosh for full-page transitions _(LOW-MEDIUM)_
Same architecture as swoosh but everything stretches. Filter attack is 4x longer (0.04 s vs 0.01 s) so the gesture starts gently. Slightly higher gain because it spans a longer time window.
pageEnter uses bandpass peak 3000 with white noise; pageExit uses pink noise with the bandpass envelope inverted (decay only, peak 400).
Reference: .web-kits/core.ts whoosh, pageEnter, pageExit.
4. Mood Vocabulary
_Adjective-to-knob mappings layered onto the base recipe._
4.1 Airy - noise source + bandpass with high peak _(LOW-MEDIUM)_
Mutation:
- Replace
sourcewith{ type: "noise", color: "white" }. - Replace
filterwith bandpass envelope reaching a high peak (4-6 kHz). - Lengthen
envelope.attackto 0.02-0.04 s so the result fades in rather than snapping. - Lower
gainto 0.08-0.12.
If the base was tonal (sine, triangle, etc.), this mood replaces the source entirely - it's a structural change.
4.2 Bright - no lowpass, optional FM sparkle _(MEDIUM)_
Mutation:
- Remove any
filterof typelowpass, or raise its cutoff above 6 kHz. - If the base used
triangle, upgrade tosinewithfm: { ratio: 2.5, depth: 50 }for sparkle. - Slight
gainbump (+0.02) is fine but stay under the budget.
4.3 Glassy - high FM ratio + reverb _(MEDIUM)_
Mutation:
source.type: "sine".source.fm: { ratio: 3.5, depth: 200-300 }.- Append
effects: [{ type: "reverb", decay: 0.7, damping: 0.5, mix: 0.15 }]. - Extend
envelope.decayto at least 0.3 s so the bell can ring.
Reference: .web-kits/core.ts ding, sparkle, star.
4.4 Lo-fi - bitcrusher + lowpass _(MEDIUM)_
Mutation:
- Add
filter: { type: "lowpass", frequency: 1500 }. - Append
effects: [{ type: "bitcrusher", bits: 6-8, mix: 0.7-1 }]. - Optionally drop
gainby 0.02 because bitcrushing adds perceived loudness.
Combines well with mood-retro.
4.5 Metallic - inharmonic FM ratio _(MEDIUM)_
Mutation:
source.type: "sine"(orsquarefor a harsher result).source.fm: { ratio: 2.76, depth: 300-400 }- 2.76 is the inharmonic ratio used bybadgein.web-kits/core.tsand reads as bell-metal.- Short release; metallic shouldn't sustain.
Avoid stacking with mood-warm - they cancel each other out.
Reference: .web-kits/core.ts badge.
4.6 Organic - triangle + slight detune + light reverb _(LOW-MEDIUM)_
Mutation:
source.type: "triangle".- Add
source.detune: 5-10for very slight pitch wobble. - Bump
envelope.attackfrom 0 to 0.003-0.008 s so the onset isn't a hard click. - Append a small reverb (
mix: 0.05-0.1).
Combines well with mood-warm. Avoid combining with mood-metallic or mood-lofi - they fight the natural feel.
4.7 Punchy - zero attack, very short decay _(MEDIUM)_
Mutation:
envelope.attack: 0.envelope.decay: <= 0.06.envelope.sustain: 0.envelope.release: <= 0.015.gainbump of +0.05 is fine because the energy lives in a shorter window.
Orthogonal to source-shape moods - apply on top of warm/bright/glassy/metallic.
4.8 Retro - square or sawtooth + lowpass + bitcrusher _(MEDIUM)_
Mutation:
source.type: "square"(or"sawtooth").- Add
filter: { type: "lowpass", frequency: 3000 }to soften aliasing. - Append
effects: [{ type: "bitcrusher", bits: 8, sampleRateReduction: 2-4, mix: 1 }].
Pairs naturally with rising or stepped pitch sweeps (coins, power-ups).
4.9 Warm - lowpass + light reverb _(MEDIUM)_
Mutation applied on top of the base recipe:
- Add
filter: { type: "lowpass", frequency: 2500 }(or 2-3 kHz). - Optionally add
effects: [{ type: "reverb", decay: 0.4, mix: 0.1 }]. - If the base used
sawtoothorsquare, downgrade totriangleso the source itself is rounder.
If the base already had a lowpass, lower its cutoff by ~30%.
5. Layering Patterns
_When to use one layer vs two vs a chord stack._
5.1 Ascending chord - 3-4 layers with cascading delay _(MEDIUM)_
3-4 sine layers spelling out a major triad (C-E-G or C-E-G-C). delay increments by ~70 ms for "feels like notes" or ~15 ms for "feels like one gesture".
Top layer gets a small upward sweep so the chord resolves rather than stops.
Cap layer count at 4. Layer gains should sum to <= 0.6. If a layer has sustain > 0, all layers should have similar sustain values to avoid staggered ringing.
5.2 Click + body - transient layer over a sustained tone _(MEDIUM)_
Two layers fired simultaneously (no delay):
1. High-frequency transient (3-5 kHz) with sub-10 ms decay - the "stick". 2. Lower-frequency body (80-300 Hz) with longer decay - the "drum".
Used for: send buttons, hard confirms, drum-like UI feedback, anything that needs perceived weight. Both layers use the same source type (usually sine) so they read as one event.
Gains should be roughly balanced (transient slightly quieter than body).
5.3 Octave pair - two layers an octave apart with delay _(MEDIUM)_
Two layers a fifth or octave apart, separated by 20-50 ms delay. Direction (low first vs high first) encodes "on" vs "off", "open" vs "close", etc.
Layer gains should sum to less than 0.5. Both envelopes should match so the second beat doesn't sound disconnected.
If you find yourself reaching for >2 layers, jump to layer-ascending-chord instead.
5.4 Single layer - emit Layer directly _(HIGH)_
When the recipe needs only one source, emit the Layer shape directly (not wrapped in { layers: [...] }). The engine accepts both, but the bare-Layer form is the canonical compact representation.
const sound: SoundDefinition = {
source: { type: "sine", frequency: 1300 },
envelope: { decay: 0.012, release: 0.004 },
gain: 0.18,
};Use this for: click, tap, tick, hover, focus, blur, scroll-snap, single-tone notifications, simple swooshes.
6. Effect Recipes
_When and how to reach for each effect type._
6.1 Bandpass noise swoosh - filter envelope is the gesture _(MEDIUM)_
Recipe is on the layer's filter, not its effects:
filter: {
type: "bandpass",
frequency: <resting Hz>,
resonance: 1-3,
envelope: { attack: 0.01-0.04, peak: <target Hz>, decay: 0.08-0.2 },
}- Peak above resting -> upward swoosh.
- Peak below resting -> downward swoosh.
- Higher
resonance(>2) makes it whistle-like; lower (<1.5) is broader.
Source should be noise (white for sharp, pink for soft). Source amplitude envelope just gates the noise window.
6.2 Bitcrusher - retro / lofi finish _(LOW-MEDIUM)_
bits: 4-8. Lower = more crunchy. Below 4 turns into noise.sampleRateReduction: 1 (off) to 8 (heavy aliasing). Combine withbits: 8for that 8-bit console sound.mix: usually 1. Mixing bitcrush with the dry signal sounds muddy.
Best paired with square or sawtooth sources and a lowpass to soften the aliasing edges.
Avoid stacking with effect-reverb-tail - the quantization noise gets smeared.
6.3 FM bell - high ratio, high depth _(MEDIUM)_
source.fm: { ratio, depth } is structural, not an effect node. To get a bell:
ratio: 2.5-3.5 for harmonic-bell, 2.76 for the "badge" inharmonic clang.depth: 150-400. Higher depth = more strident.envelope.decay: at least 0.3 s so the bell can ring.
For a bright "ding", use ratio: 3.5, depth: 250 and add reverb (decay: 0.7, mix: 0.15).
For a dull "thud" with body, use ratio: 0.5, depth: 200 and a short envelope.
Pair with mood-glassy or mood-metallic.
6.4 Lowpass warmth - the safest filter to add _(MEDIUM)_
filter: { type: "lowpass", frequency: 2500, resonance: 0.7 }frequency: 1500-3000 Hz for "warm". Below 1000 starts muffling the sound.resonance: omit or set 0.7-1.5. Above 2 the cutoff itself starts to whistle.
Stacks safely with reverb, FM, and most moods. The fastest way to remove harshness from any source.
For dynamic warmth (bright attack -> warm sustain), add a filter envelope:
filter: {
type: "lowpass",
frequency: 2500,
envelope: { attack: 0, peak: 6000, decay: 0.08 },
}6.5 Reverb tail - small space, low mix _(MEDIUM)_
Default UI reverb:
decay: 0.3-0.6 s.damping: 0.4-0.6 (kills high frequencies in the tail; without this the reverb sounds metallic).mix: 0.08-0.15. Anything above 0.2 starts to feel like a music production effect.
For per-layer reverb on bell-like sounds (notification, ding), put the reverb inside the layer's effects array so each note rings independently. For shared reverb on chords/transitions, put it on the top-level effects of the MultiLayerSound.
Avoid stacking reverb with delay - choose one.
7. Output Validation
_Checks every emitted SoundDefinition must pass before returning to the user._
7.1 Duration cap - 1 s for transients, 3 s absolute max _(MEDIUM)_
Estimated total duration:
estimated = (envelope.attack ?? 0)
+ envelope.decay
+ (envelope.release ?? 0)
+ max(0, longestEffectTail) // reverb decay, delay time * 4Targets:
- Click / tap / tick / hover / focus: <= 0.1 s.
- Toggle / copy / sync: <= 0.2 s.
- Modal / drawer / dropdown open/close: <= 0.3 s.
- Success / complete / notification: <= 0.8 s.
- Whoosh / page transition: <= 0.5 s.
Hard ceiling: 3 s. Anything longer should not be a UI sound.
The validate script computes the estimated duration and flags layers that exceed 3 s.
7.2 Envelope sanity - no zero decay, no infinite sustain without release _(HIGH)_
Required:
envelope.decay > 0(always). Set to 0.005 minimum.- If
envelope.sustain > 0,envelope.releasemust be present and> 0.
Recommended:
envelope.attack: 0 for percussive, 0.003-0.05 for sustained tones, up to 0.1 for ambient sounds.envelope.decay + envelope.release: <= 2 s for any UI sound. Above that, you're writing music, not interface feedback.envelope.sustain: 0 for transients, 0.03-0.15 for "rings out" tones, 0.3-0.7 only for held loops.
The validate script flags decay <= 0, sustain > 0 without release, and total durations above 3 s.
7.3 Frequency bounds - 20 Hz to 20 kHz, both ends meaningful _(HIGH)_
Hard bounds:
source.frequency(or bothstart/endof a sweep): 20 Hz <= f <= 20000 Hz.filter.frequency: 20 Hz <= f <= 20000 Hz.filter.envelope.peak: same range asfilter.frequency.
Recommended UI bounds:
- Tonal sources: 80 Hz <= f <= 8000 Hz.
- High transient layers (clicks, sticks): up to 5 kHz.
- Sub layers (body, drum): 60-200 Hz.
Anything above 8 kHz risks being inaudible on phone speakers; anything below 60 Hz risks being inaudible on laptop speakers.
The validate script flags any frequency outside the hard bounds.
7.4 Gain budget - keep total layer gain under 0.6 _(HIGH)_
Single layer:
gainbetween 0.04 and 0.3 for typical UI events.- Background ticks/scroll-snaps: 0.04-0.10.
- Mid-importance (click, tap, hover): 0.12-0.20.
- High-importance (success, notification): 0.16-0.25.
Multi-layer:
- Sum of all
layer.gainvalues must be <= 0.6. - If you exceed it, scale every layer proportionally rather than picking one to lower.
If a sound includes a heavy reverb (mix > 0.15) or distortion, lower the gain budget by 20%.
The validate script flags both individual layers above 0.4 and totals above 0.6.
7.5 Schema conformance - validate against patch.schema.json _(CRITICAL)_
Every emitted SoundDefinition must validate against packages/audio/schemas/patch.schema.json (#/$defs/SoundDefinition).
Common mistakes:
- Missing
decayinenvelope(required). - Missing
targetinlfo(required). - Setting
panoutside[-1, 1]. - Using a
filter.typethat isn't one oflowpass | highpass | bandpass | notch | allpass | peaking | lowshelf | highshelf | iir. - Adding a top-level field that isn't in
LayerorMultiLayerSound(e.g.name,description). The schema isadditionalProperties: false. - Confusing
MultiLayerSound.effects(chain on the mixed bus) withLayer.effects(chain on a single layer).
The validate script invokes the JSON Schema validator on every rule's example field. Any violation aborts the build.
{
"name": "create-sound",
"version": "0.1.0",
"organization": "@web-kits/audio",
"abstract": "Generate a SoundDefinition for @web-kits/audio from any input - a natural-language prompt, an audio file the user shares, or both. Use when the user says \"create a sound\", \"/create-sound\", \"design a sound for X\", shares a WAV/MP3/sprite, or asks to reverse-engineer a sample. Optionally renders a WAV preview and round-trip-validates the result."
}
{
"name": "@web-kits/skill-create-sound",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Cursor agent skill: generate a SoundDefinition for @web-kits/audio from a prompt or audio file.",
"scripts": {
"build": "node src/build.mjs",
"validate": "node src/validate.mjs",
"extract-tests": "node src/extract-tests.mjs",
"dev": "node src/build.mjs && node src/validate.mjs"
}
}
create-sound
Cursor agent skill that produces a SoundDefinition for @web-kits/audio from any input — a natural-language prompt, an audio file the user shares, or both.
The skill follows the vercel-labs/agent-skills layout: each rule is its own short markdown file in rules/, and a build step compiles them into a single SKILL.md that Cursor autoloads.
Layout
metadata.json # name, version, organization, abstract
README.md # this file
SKILL.md # GENERATED entry point Cursor reads
test-cases.json # GENERATED LLM eval cases
rules/
_sections.md # section metadata (titles, order, descriptions)
_template.md # copy this to add a new rule
pipeline-*.md # procedural steps (input routing, emit, render)
interpret-*.md # FFT analysis sub-steps (audio path)
event-*.md # one per UI event class (prompt path)
mood-*.md # one per mood adjective (prompt path)
layer-*.md # layering patterns (shared)
effect-*.md # effect recipes (shared)
validate-*.md # output checks (shared)
src/
build.mjs # rules/*.md -> SKILL.md
validate.mjs # frontmatter + section prefix + example shape
extract-tests.mjs # rule examples -> test-cases.json
analyze.py # FFT helpers reused by interpret-* rulesSKILL.md is generated, not hand-edited.
Workflow
# Add or edit a rule under rules/, then:
pnpm --dir skills/create-sound build # rebuild SKILL.md
pnpm --dir skills/create-sound validate # check frontmatter + examples
pnpm --dir skills/create-sound extract-tests # rebuild test-cases.jsonAdding a new rule
1. Copy rules/_template.md to rules/<prefix>-<short-name>.md. 2. Pick the prefix that matches the section (see rules/_sections.md). 3. Fill in the frontmatter and content. 4. For prompt-path rules, include a concrete prompt and example so it contributes a test case. 5. Run pnpm dev to rebuild and validate.
Sections
Filename prefix → section. Defined once in rules/_sections.md.
| Prefix | Section |
|---|---|
pipeline- | Generation pipeline |
interpret- | FFT analysis when the input is audio |
event- | UI event recipes (prompt path) |
mood- | Mood / adjective vocabulary (prompt path) |
layer- | Layering patterns (shared) |
effect- | Effect recipes (shared) |
validate- | Output validation (shared) |
Source surface
The skill is grounded in real shapes from the codebase:
- `packages/audio/src/types.ts` — full
SoundDefinitionsurface. - `packages/audio/schemas/patch.schema.json` — JSON Schema used by
validate. - `packages/audio/src/offline.ts` —
renderToWavfor previews and round-trip checks. - `.web-kits/core.ts` — every
event-*recipe template is taken from this first-party patch.
Sections
Section metadata consumed by src/build.mjs. Filename prefix maps to one entry below.
sections:
- prefix: pipeline
title: Generation Pipeline
description: Procedural steps the agent runs end-to-end. Start here when handling any create-sound request.
- prefix: interpret
title: Audio Interpretation
description: FFT analysis sub-steps that fire when the user shares an audio file.
- prefix: event
title: UI Event Recipes
description: Concrete SoundDefinition templates per UI event class. Used by the prompt path as the base layer.
- prefix: mood
title: Mood Vocabulary
description: Adjective-to-knob mappings layered onto the base recipe.
- prefix: layer
title: Layering Patterns
description: When to use one layer vs two vs a chord stack.
- prefix: effect
title: Effect Recipes
description: When and how to reach for each effect type.
- prefix: validate
title: Output Validation
description: Checks every emitted SoundDefinition must pass before returning to the user.Short rule title here
Brief explanation of when and why to apply this rule.
Incorrect (describe what's wrong):
{ source: { type: "sine", frequency: 1300 }, envelope: { decay: 0.5 }, gain: 0.18 }Correct:
{ source: { type: "sine", frequency: 1300 }, envelope: { decay: 0.012, release: 0.004 }, gain: 0.18 }Reference: .web-kits/core.ts <sound-name>.
Frontmatter fields
title(required) - short human-readable label.impact(required) - one of CRITICAL, HIGH, MEDIUM-HIGH, MEDIUM, LOW-MEDIUM, LOW.impactDescription(optional) - one-line context.tags(optional) - comma-separated.prompt(optional) - input the rule should match. Used byextract-tests.mjs.example(optional) - JSON SoundDefinition the rule should produce. Used byvalidate.mjsandextract-tests.mjs.inputAudio(optional, interpret-* only) - relative path to a WAV the rule should analyze.
Filename
<section-prefix>-<short-name>.md. Section prefixes are listed in _sections.md.
Bandpass noise swoosh - filter envelope is the gesture
Recipe is on the layer's filter, not its effects:
filter: {
type: "bandpass",
frequency: <resting Hz>,
resonance: 1-3,
envelope: { attack: 0.01-0.04, peak: <target Hz>, decay: 0.08-0.2 },
}- Peak above resting -> upward swoosh.
- Peak below resting -> downward swoosh.
- Higher
resonance(>2) makes it whistle-like; lower (<1.5) is broader.
Source should be noise (white for sharp, pink for soft). Source amplitude envelope just gates the noise window.
Bitcrusher - retro / lofi finish
bits: 4-8. Lower = more crunchy. Below 4 turns into noise.sampleRateReduction: 1 (off) to 8 (heavy aliasing). Combine withbits: 8for that 8-bit console sound.mix: usually 1. Mixing bitcrush with the dry signal sounds muddy.
Best paired with square or sawtooth sources and a lowpass to soften the aliasing edges.
Avoid stacking with effect-reverb-tail - the quantization noise gets smeared.
FM bell - high ratio, high depth
source.fm: { ratio, depth } is structural, not an effect node. To get a bell:
ratio: 2.5-3.5 for harmonic-bell, 2.76 for the "badge" inharmonic clang.depth: 150-400. Higher depth = more strident.envelope.decay: at least 0.3 s so the bell can ring.
For a bright "ding", use ratio: 3.5, depth: 250 and add reverb (decay: 0.7, mix: 0.15).
For a dull "thud" with body, use ratio: 0.5, depth: 200 and a short envelope.
Pair with mood-glassy or mood-metallic.
Lowpass warmth - the safest filter to add
filter: { type: "lowpass", frequency: 2500, resonance: 0.7 }frequency: 1500-3000 Hz for "warm". Below 1000 starts muffling the sound.resonance: omit or set 0.7-1.5. Above 2 the cutoff itself starts to whistle.
Stacks safely with reverb, FM, and most moods. The fastest way to remove harshness from any source.
For dynamic warmth (bright attack -> warm sustain), add a filter envelope:
filter: {
type: "lowpass",
frequency: 2500,
envelope: { attack: 0, peak: 6000, decay: 0.08 },
}Reverb tail - small space, low mix
Default UI reverb:
decay: 0.3-0.6 s.damping: 0.4-0.6 (kills high frequencies in the tail; without this the reverb sounds metallic).mix: 0.08-0.15. Anything above 0.2 starts to feel like a music production effect.
For per-layer reverb on bell-like sounds (notification, ding), put the reverb inside the layer's effects array so each note rings independently. For shared reverb on chords/transitions, put it on the top-level effects of the MultiLayerSound.
Avoid stacking reverb with delay - choose one.
Click - sine + low FM, very short decay
A short ascending sine sweep with light FM. The sweep gives the click "snap"; the FM adds harmonic body without making it metallic.
Incorrect (decay too long, sounds like a chime):
{ source: { type: "sine", frequency: 1300 }, envelope: { decay: 0.5 }, gain: 0.18 }Correct:
{
source: { type: "sine", frequency: { start: 200, end: 700 }, fm: { ratio: 0.5, depth: 80 } },
envelope: { attack: 0, decay: 0.06, sustain: 0, release: 0.02 },
gain: 0.25,
}Reference: .web-kits/core.ts click.
Complete - four-note ascending arpeggio
Same C-major triad as success, but with C6 added on top and tighter 15 ms delay increments so the notes blur into a single gesture rather than reading as discrete pitches.
Reference: .web-kits/core.ts complete.
Error - layered sawtooth + square with descending sweep
Two descending sweeps stacked an octave apart. Lowpass filters keep the result from being abrasive. Same shape works for delete (slightly longer decay).
Incorrect (no filter, sounds like a buzzer):
{ source: { type: "sawtooth", frequency: { start: 320, end: 140 } }, envelope: { decay: 0.25 }, gain: 0.22 }Reference: .web-kits/core.ts error, _delete.
Modal-close - downward sine sweep
The inverse of modalOpen. Range is narrower because dismiss should feel less assertive than the entrance. Slightly lower gain for the same reason.
For drawer-close use 800 -> 350. For dropdown-close use 900 -> 500.
Reference: .web-kits/core.ts modalClose, drawerClose, dropdownClose.
Modal-open - upward sine sweep
A single sine sweeping from ~430 Hz up to ~1400 Hz over 80 ms. No FM, no filter; the cleanness signals "appearing".
For drawer-open use a slightly lower start (~350 Hz) and lower gain (~0.08). For dropdown-open use a smaller range (500 -> 1200) and decay ~60 ms.
Reference: .web-kits/core.ts modalOpen, drawerOpen, dropdownOpen.
Notification - FM-rich sine with light reverb
Two FM bells a fifth apart with 100 ms delay between them. The fm.ratio: 1.5 gives an inharmonic shimmer; the matched reverb on each layer glues them together.
For ding: single layer, fm.ratio: 3.5, reverb decay: 0.8. For mention: lower fundamental (660 Hz), fm.ratio: 2.5, slightly more attack.
Reference: .web-kits/core.ts notification, ding, mention, badge.
Success - ascending three-note sine chord
Three sine layers at C5 / E5 / G5 with delay cascading 0.07 s between them. The top note has a small upward sweep (G5 -> A5) so the chord resolves "upward" instead of just stopping.
Layer gains sum to 0.45, comfortably under the 0.6 budget.
Reference: .web-kits/core.ts success.
Swoosh - white noise through a sweeping bandpass
White noise is shaped by a bandpass filter whose center frequency sweeps from 300 Hz up to 4 kHz. The sweep direction is the gesture: peak above resting = upward swoosh, peak below resting (e.g., resting 2500, peak 400) = downward.
For slide-up use a similar shape with peak 3500. For slide-down flip to pink noise with envelope: { decay: 0.12, peak: 500 } (no attack on the filter envelope).
Reference: .web-kits/core.ts swoosh, slide, slideUp, slideDown.
Tap - static high sine + FM, ultra short
Single high pitch (no sweep), aggressive FM, decay under 20 ms. This is the "key-press" archetype.
Incorrect (frequency too low, sounds like a thump):
{ source: { type: "sine", frequency: 200 }, envelope: { decay: 0.015 }, gain: 0.2 }Correct:
{
source: { type: "sine", frequency: 1300, fm: { ratio: 0.5, depth: 100 } },
envelope: { attack: 0, decay: 0.015, sustain: 0, release: 0.005 },
gain: 0.2,
}Reference: .web-kits/core.ts tap, keyPress.
Tick - faintest possible sine
Highest frequency in the tap family. Decay under 15 ms. gain capped at ~0.15 because ticks fire often and must not dominate.
For scroll-snap reduce gain to 0.08; for focus/blur reduce to 0.04-0.06.
Reference: .web-kits/core.ts tick, scrollSnap, focus, blur.
Toggle - paired sines with delay (direction matters)
Two short sines: C7 (2093 Hz) and G7 (3136 Hz), 25 ms apart.
toggle-on: low note first, then high (ascending = enabling).toggle-off: high note first, then low (descending = disabling).
The same architecture works for copy (1200 Hz then 1400 Hz, 40 ms gap) and sync (C5 then G5).
Reference: .web-kits/core.ts toggleOn, toggleOff, copy, sync.
Whoosh - longer, slower swoosh for full-page transitions
Same architecture as swoosh but everything stretches. Filter attack is 4x longer (0.04 s vs 0.01 s) so the gesture starts gently. Slightly higher gain because it spans a longer time window.
pageEnter uses bandpass peak 3000 with white noise; pageExit uses pink noise with the bandpass envelope inverted (decay only, peak 400).
Reference: .web-kits/core.ts whoosh, pageEnter, pageExit.
Classify oscillator waveform from harmonics
Compare the amplitude of the first 8 harmonics against the fundamental.
import numpy as np
from scipy.fft import rfft, rfftfreq
from analyze import classify_waveform
segment = data[:int(sample_rate * 0.02)].astype(float)
segment *= np.hanning(len(segment))
spectrum = np.abs(rfft(segment))
freqs = rfftfreq(len(segment), 1 / sample_rate)
waveform = classify_waveform(spectrum, freqs, fundamental_freq)
# -> "sine" | "triangle" | "square" | "sawtooth" | "wavetable"Mapping
| Pattern | source.type |
|---|---|
| Fundamental only, harmonics < -40 dB | sine |
| Odd harmonics rolling off as 1/n | triangle |
| Odd harmonics at roughly equal amplitude | square |
| All harmonics rolling off as 1/n | sawtooth |
| Custom harmonic profile (none of the above) | wavetable |
| No clear harmonic structure, broadband energy | noise |
When to fall back to wavetable
If the harmonic profile doesn't match a clean oscillator, extract the harmonic series instead:
from analyze import extract_harmonics
harmonics = extract_harmonics(spectrum, freqs, fundamental_freq, num_harmonics=16)
# -> { source: { type: "wavetable", harmonics, frequency: fundamental_freq } }Noise color
For broadband signals with no fundamental, classify by spectral slope:
from analyze import classify_noise_color
color = classify_noise_color(spectrum, freqs) # "white" | "pink" | "brown"
# -> { source: { type: "noise", color } }Detect post-source effects
Each detector returns a confidence-flavored hint, not a guarantee. Effects are harder to extract than source/envelope - report low confidence when ambiguous.
Reverb
from analyze import detect_reverb
result = detect_reverb(data, sample_rate, envelope_end_ms=120)
# -> { "type": "reverb", "decay": 0.6 } or NoneDelay (autocorrelation)
from analyze import detect_delay
result = detect_delay(data, sample_rate)
# -> { "type": "delay", "time": 0.25, "feedback": 0.3 } or NoneFM synthesis
Spectral sidebands at non-integer ratios of the fundamental indicate FM:
from analyze import detect_fm
fm = detect_fm(spectrum, freqs, fundamental_freq)
# -> { "fm": { "ratio": 0.5, "depth": 80 } } or NoneMaps to source.fm: { ratio, depth } (not a separate effect).
Tremolo and vibrato
Periodic amplitude or frequency modulation in the 1-20 Hz band suggests tremolo/vibrato. Track amplitude or pitch over time and call detect_lfo (see interpret-detect-lfo).
Bitcrusher / distortion
| Time-domain signature | Effect |
|---|---|
| Stepped/quantized waveform with aliasing artifacts | bitcrusher |
| Flat-topped waveform with added harmonics | distortion |
Chorus / flanger / phaser
Comb-filter pattern that sweeps over time produces moving notches in the spectrum. Hard to disambiguate algorithmically; flag for human review.
Detect filter type, cutoff, and resonance
Compare the measured spectrum against the expected spectrum for the identified oscillator.
Cutoff via spectral centroid
from analyze import spectral_centroid
centroid = spectral_centroid(spectrum, freqs)Expected centroids at a 440 Hz fundamental: sine ~440, triangle ~880, sawtooth ~2200, square ~1760. If the measured centroid is significantly lower than expected, a lowpass is present; estimate cutoff at the -3 dB point.
Filter type from rolloff
| Observation | filter.type |
|---|---|
| High-frequency rolloff steeper than the source would produce | lowpass |
| Low-frequency rolloff | highpass |
| Narrow band of frequencies passes through | bandpass |
| Narrow notch removed | notch |
| Resonant peak near cutoff | High resonance |
Resonance (Q)
from analyze import estimate_resonance
q = estimate_resonance(spectrum, freqs, cutoff_hz)
# Returns 0.1 - 20.0Filter envelope
If brightness changes over time (bright attack fading to dull), there's a filter envelope:
from analyze import detect_filter_envelope
env = detect_filter_envelope(data, sample_rate)
# -> { "peak": 4000, "resting": 800, "decay_ms": 50 } or NoneMaps to:
filter: {
type: "lowpass",
frequency: env.resting,
envelope: { attack: 0, peak: env.peak, decay: env.decay_ms / 1000 },
}Detect LFO modulation
An LFO is sub-audio (0.1-20 Hz) periodic modulation of a parameter. Track the parameter over time, then run detect_lfo.
from analyze import detect_lfo
# 1. Track amplitude (or pitch, or spectral centroid) at regular intervals
window_ms = 10
samples_per_window = int(sample_rate * window_ms / 1000)
amp_over_time = [
float(np.max(np.abs(data[i:i + samples_per_window])))
for i in range(0, len(data) - samples_per_window, samples_per_window)
]
# 2. Detect periodicity
lfo = detect_lfo(np.array(amp_over_time), 1000 / window_ms)
# -> { "frequency": 5.0, "depth": 0.12 } or NoneMapping by tracked parameter
| Parameter tracked | LFO target |
|---|---|
| Amplitude | gain |
| Pitch | frequency or detune |
| Spectral centroid | filter.frequency |
| Pan position | pan |
Output
lfo: { type: "sine", frequency: lfo.frequency, depth: lfo.depth, target: "gain" }Pick type based on the shape of the modulation: smooth sinusoid -> sine, sharp ramp -> sawtooth, hard switching -> square.
Extract ADSR envelope from amplitude
Smooth the time-domain amplitude, find onset/peak/sustain/end, and derive each ADSR stage.
from analyze import load_mono, extract_envelope
sample_rate, data = load_mono("out/click.wav")
env = extract_envelope(data, sample_rate)
# -> { "attack": 0.0008, "decay": 0.012, "sustain": 0.0, "release": 0.005 }Output shape
The dict maps 1:1 to the Envelope type:
envelope: {
attack: env.attack, // 0 if percussive
decay: env.decay,
sustain: env.sustain, // 0 for transient sounds, 0-1 for sustained
release: env.release,
}Heuristics
sustain < 0.01-> drop the field; the sound is percussive.attack < 0.001-> setattack: 0.release < 0.005-> clamp to0.005to avoid clicks at the end.
Extract fundamental frequency and pitch sweep
Sample the spectrum at multiple time slices to detect both the static pitch and any sweep.
from analyze import load_mono, analyze_slice
sample_rate, data = load_mono("out/click.wav")
slices = [0, 5, 10, 20, 50] # ms
freqs_over_time = [analyze_slice(data, sample_rate, t) for t in slices]Mapping
| Observation | Output |
|---|---|
| All slices within ±5% | source.frequency: <Hz> (static) |
| Decreasing across slices | source.frequency: { start: <high>, end: <low> } |
| Increasing across slices | source.frequency: { start: <low>, end: <high> } |
Tips
- Skip the first 1-2 ms if the onset is a click transient; it pollutes the FFT.
- For very short sounds (< 20 ms) use fewer slices and a smaller window.
- Use a Hanning window before FFT (already applied in
analyze_slice) to reduce spectral leakage.
Acquire and split source audio
The user shared a single file or a sprite (one file containing many sounds). Before any FFT work, get one mono WAV per sound on disk.
Sprite from an npm package
npm pack <package-name> --pack-destination /tmp
tar -xzf /tmp/<package-name>-*.tgz -C /tmpLook for the MP3/WAV plus any JSON manifest mapping sound names to time offsets.
Manifest-driven slicing
ffmpeg -i sprite.mp3 \
-ss <start_seconds> -t <duration_seconds> \
-acodec pcm_s16le -ar 44100 \
output/<name>.wavSilence-detection slicing (no manifest)
ffmpeg -i sprite.mp3 -af silencedetect=noise=-40dB:d=0.05 -f null -Read the silence_start/silence_end lines and slice between gaps.
Output convention
Per-sound WAVs go in out/<name>.wav (mono, 44.1 kHz, 16-bit PCM). Downstream interpret rules call analyze.load_mono(path) from src/analyze.py.
Detect multi-layer sounds and stereo positioning
Multiple fundamentals -> MultiLayerSound
Inspect peaks in the spectrum. If two or more strong peaks are not integer multiples of one shared fundamental, the sound is layered.
from scipy.signal import find_peaks
peaks, props = find_peaks(spectrum, height=float(np.max(spectrum)) * 0.2)
peak_freqs = sorted(freqs[peaks])
# Check pairwise ratios. If no shared fundamental explains all peaks, treat as layered.For each detected fundamental, run the full pipeline (frequency, envelope, waveform, filter, effects) and emit one Layer per fundamental:
{
layers: [
{ source: { ... }, envelope: { ... }, gain: 0.2 },
{ source: { ... }, envelope: { ... }, gain: 0.15, delay: 0.04 },
]
}The earlier layer typically gets delay: 0 (omitted); subsequent layers offset their delay to match the measured onset gap.
Stereo and pan
from analyze import analyze_stereo
stereo = analyze_stereo(data)
# -> { "pan": 0.3, "stereo_width": 0.7 }pan magnitude | Output |
|---|---|
< 0.05 | omit (pan: 0 is default) |
0.05 - 1 | pan: <value> |
stereo_width > 0.5 with |pan| < 0.05 suggests a stereo effect (chorus, dual-layer). Consider splitting into two layers panned -0.5 / +0.5.
Fallback
If a sound is unsynthesizable (complex transients, recorded material, irreducible texture), fall back to:
{ source: { type: "sample", url: "..." } }and note that the original audio file should be used directly rather than re-synthesized.
Ascending chord - 3-4 layers with cascading delay
3-4 sine layers spelling out a major triad (C-E-G or C-E-G-C). delay increments by ~70 ms for "feels like notes" or ~15 ms for "feels like one gesture".
Top layer gets a small upward sweep so the chord resolves rather than stops.
Cap layer count at 4. Layer gains should sum to <= 0.6. If a layer has sustain > 0, all layers should have similar sustain values to avoid staggered ringing.
Click + body - transient layer over a sustained tone
Two layers fired simultaneously (no delay):
1. High-frequency transient (3-5 kHz) with sub-10 ms decay - the "stick". 2. Lower-frequency body (80-300 Hz) with longer decay - the "drum".
Used for: send buttons, hard confirms, drum-like UI feedback, anything that needs perceived weight. Both layers use the same source type (usually sine) so they read as one event.
Gains should be roughly balanced (transient slightly quieter than body).
Octave pair - two layers an octave apart with delay
Two layers a fifth or octave apart, separated by 20-50 ms delay. Direction (low first vs high first) encodes "on" vs "off", "open" vs "close", etc.
Layer gains should sum to less than 0.5. Both envelopes should match so the second beat doesn't sound disconnected.
If you find yourself reaching for >2 layers, jump to layer-ascending-chord instead.
Single layer - emit Layer directly
When the recipe needs only one source, emit the Layer shape directly (not wrapped in { layers: [...] }). The engine accepts both, but the bare-Layer form is the canonical compact representation.
const sound: SoundDefinition = {
source: { type: "sine", frequency: 1300 },
envelope: { decay: 0.012, release: 0.004 },
gain: 0.18,
};Use this for: click, tap, tick, hover, focus, blur, scroll-snap, single-tone notifications, simple swooshes.
Airy - noise source + bandpass with high peak
Mutation:
- Replace
sourcewith{ type: "noise", color: "white" }. - Replace
filterwith bandpass envelope reaching a high peak (4-6 kHz). - Lengthen
envelope.attackto 0.02-0.04 s so the result fades in rather than snapping. - Lower
gainto 0.08-0.12.
If the base was tonal (sine, triangle, etc.), this mood replaces the source entirely - it's a structural change.
Bright - no lowpass, optional FM sparkle
Mutation:
- Remove any
filterof typelowpass, or raise its cutoff above 6 kHz. - If the base used
triangle, upgrade tosinewithfm: { ratio: 2.5, depth: 50 }for sparkle. - Slight
gainbump (+0.02) is fine but stay under the budget.
Glassy - high FM ratio + reverb
Mutation:
source.type: "sine".source.fm: { ratio: 3.5, depth: 200-300 }.- Append
effects: [{ type: "reverb", decay: 0.7, damping: 0.5, mix: 0.15 }]. - Extend
envelope.decayto at least 0.3 s so the bell can ring.
Reference: .web-kits/core.ts ding, sparkle, star.
Lo-fi - bitcrusher + lowpass
Mutation:
- Add
filter: { type: "lowpass", frequency: 1500 }. - Append
effects: [{ type: "bitcrusher", bits: 6-8, mix: 0.7-1 }]. - Optionally drop
gainby 0.02 because bitcrushing adds perceived loudness.
Combines well with mood-retro.
Metallic - inharmonic FM ratio
Mutation:
source.type: "sine"(orsquarefor a harsher result).source.fm: { ratio: 2.76, depth: 300-400 }- 2.76 is the inharmonic ratio used bybadgein.web-kits/core.tsand reads as bell-metal.- Short release; metallic shouldn't sustain.
Avoid stacking with mood-warm - they cancel each other out.
Reference: .web-kits/core.ts badge.
Organic - triangle + slight detune + light reverb
Mutation:
source.type: "triangle".- Add
source.detune: 5-10for very slight pitch wobble. - Bump
envelope.attackfrom 0 to 0.003-0.008 s so the onset isn't a hard click. - Append a small reverb (
mix: 0.05-0.1).
Combines well with mood-warm. Avoid combining with mood-metallic or mood-lofi - they fight the natural feel.
Punchy - zero attack, very short decay
Mutation:
envelope.attack: 0.envelope.decay: <= 0.06.envelope.sustain: 0.envelope.release: <= 0.015.gainbump of +0.05 is fine because the energy lives in a shorter window.
Orthogonal to source-shape moods - apply on top of warm/bright/glassy/metallic.
Retro - square or sawtooth + lowpass + bitcrusher
Mutation:
source.type: "square"(or"sawtooth").- Add
filter: { type: "lowpass", frequency: 3000 }to soften aliasing. - Append
effects: [{ type: "bitcrusher", bits: 8, sampleRateReduction: 2-4, mix: 1 }].
Pairs naturally with rising or stepped pitch sweeps (coins, power-ups).
Warm - lowpass + light reverb
Mutation applied on top of the base recipe:
- Add
filter: { type: "lowpass", frequency: 2500 }(or 2-3 kHz). - Optionally add
effects: [{ type: "reverb", decay: 0.4, mix: 0.1 }]. - If the base used
sawtoothorsquare, downgrade totriangleso the source itself is rounder.
If the base already had a lowpass, lower its cutoff by ~30%.
Apply mood adjectives onto the base layer
After pipeline-pick-base-layer produces a starting SoundDefinition, scan the prompt for adjective tokens and apply each mood-* rule's mutation in order.
Order of application
1. Source-shape adjectives (warm, bright, glassy, metallic, lofi, retro, organic) - mutate source.type, source.fm, or add filter. 2. Envelope adjectives (punchy, airy) - mutate envelope.attack / envelope.decay. 3. Effect adjectives (reverby, delayed, crushed) - append to effects.
Conflict resolution
warm+bright-> the later token wins.lofi+glassy-> apply both, but capeffectsat 2 entries.punchy+airy-> they're orthogonal (envelope vs source); both apply.
Refinement on existing definition (audio + prompt path)
When the input mode is prompt + audio, treat each adjective as a refinement on the measured definition rather than from scratch:
| Adjective | Refinement |
|---|---|
| warmer | add or lower filter.frequency (lowpass at ~2500 Hz) |
| brighter | remove lowpass or raise its cutoff above 6 kHz |
| punchier | clamp envelope.decay <= 0.06, set envelope.attack: 0 |
| longer | extend envelope.decay and add release if missing |
| crisper | raise gain slightly and add fm: { ratio: 0.5, depth: 50 } |
Output
A mutated SoundDefinition. Hand off to pipeline-decide-layering.
Decide single-layer vs multi-layer
| Event class | Default |
|---|---|
| click, tap, tick, hover, focus, swoosh | 1 layer (Layer) |
| toggle, copy, send, sync | 2 layers (paired pitches with delay) |
| success, complete, level-up, confetti | 3+ layers (chord with cascading delay) |
| error, delete | 2 layers (sawtooth + square) |
See layer-single, layer-octave-pair, layer-ascending-chord, layer-click-plus-body for the concrete shapes.
Promoting a single Layer to MultiLayerSound
If the prompt or refinement requires more than one layer, wrap:
{
layers: [<existing layer>, <new layer>],
// optional global effects, e.g. sidechain compressor, master EQ
}Per-layer gain values should sum to no more than ~0.6 (see validate-gain-budget).
Demoting MultiLayerSound to a single Layer
If only one layer survives mood application, emit the inner Layer directly rather than a one-element MultiLayerSound. Both validate, but the single-layer form is the canonical compact shape.
Detect input mode and route the request
Decide which path to run based on what the user provided.
| Input | Path |
|---|---|
| Prompt only (no audio attachment) | Skip interpret-*. Go to pipeline-pick-base-layer. |
| Audio file only | Run all interpret-* rules. Skip event-* / mood-*. |
| Both prompt and audio | Run interpret-* first, then treat the prompt as a refinement layer over the measured SoundDefinition. |
Detecting audio
Look for attached files matching *.wav, *.mp3, *.flac, *.ogg, or any path the user references that resolves to an audio file. A JSON manifest (*.json next to a sprite) is also an audio-path signal.
Refinement examples (prompt + audio)
| Prompt qualifier | Refinement on measured definition |
|---|---|
| "warmer" | add filter: { type: "lowpass", frequency: 2500 } |
| "shorter" / "punchier" | clamp envelope.decay to <= 0.06 |
| "brighter" | drop or raise any lowpass cutoff |
| "with reverb" | append effects: [{ type: "reverb", decay: 0.5, mix: 0.15 }] |
| "lower octave" | halve source.frequency (or both start/end) |
Output of this step
Produce an internal note like:
Input: prompt + audio
Plan: run interpret-* on out/click.wav, then refine with mood-warm.Then proceed to the next pipeline step.
Emit, optionally render, optionally round-trip
1. Emit
Always return a TypeScript snippet ready to paste into a .web-kits/<patch>.ts file:
import type { SoundDefinition } from "@web-kits/audio";
export const myClick: SoundDefinition = {
source: { type: "sine", frequency: 1300, fm: { ratio: 0.5, depth: 60 } },
envelope: { decay: 0.012, release: 0.004 },
gain: 0.18,
};Plus a one-line rationale that names the prompt tokens you acted on:
"click" -> base from event-click; "warm" -> kept default sine, no extra filter needed at 1.3 kHz.2. Optional preview render
If the user asked for a WAV (or you want to grade your own output), use `packages/audio/src/offline.ts`:
import { renderToWav } from "@web-kits/audio";
import { writeFile } from "node:fs/promises";
const blob = await renderToWav(myClick, { duration: 0.3 });
await writeFile("preview.wav", Buffer.from(await blob.arrayBuffer()));duration should be attack + decay + release + 0.05 (small tail) or longer if reverb is present.
3. Optional round-trip validation
If you generated from a prompt and want to confirm the result matches intent, run the interpret-* rules against the rendered WAV and diff measured vs intended values:
| Field | Acceptable drift |
|---|---|
| Fundamental Hz | ±5% |
| Attack | ±2 ms |
| Decay | ±10% |
| Spectral centroid | ±20% of expected for the chosen waveform |
If drift exceeds tolerance, refine the definition (often by raising/lowering gain, tightening envelope, or adjusting filter.frequency) and render again.
Pick a base layer from the prompt's event class
Tokenize the prompt and find the strongest event-class signal. Match against the event-* rules.
Token map
| Tokens in prompt | Event rule |
|---|---|
| click, tap, key, press, button | event-click / event-tap |
| tick, scroll, snap, focus | event-tick |
| success, complete, win, achievement, level-up, confetti | event-success / event-complete |
| error, fail, wrong, invalid, delete, destroy | event-error |
| modal, dialog, popup, drawer, sheet, sidebar, dropdown, menu | event-modal-open / event-modal-close |
| swoosh, slide, transition, page, tab | event-swoosh / event-whoosh |
| notification, alert, ding, bell, mention, badge | event-notification |
| toggle, switch, on, off | event-toggle |
Direction tokens (open vs close)
- "open", "appear", "in", "show", "expand", "confirm" -> ascending pitch.
- "close", "dismiss", "out", "hide", "collapse", "cancel" -> descending pitch.
Output
A starting SoundDefinition literal copied from the chosen event rule's example. The next step (pipeline-apply-mood) will mutate it.
If no event class fires confidently, default to event-click and let mood adjectives do the work.
Duration cap - 1 s for transients, 3 s absolute max
Estimated total duration:
estimated = (envelope.attack ?? 0)
+ envelope.decay
+ (envelope.release ?? 0)
+ max(0, longestEffectTail) // reverb decay, delay time * 4Targets:
- Click / tap / tick / hover / focus: <= 0.1 s.
- Toggle / copy / sync: <= 0.2 s.
- Modal / drawer / dropdown open/close: <= 0.3 s.
- Success / complete / notification: <= 0.8 s.
- Whoosh / page transition: <= 0.5 s.
Hard ceiling: 3 s. Anything longer should not be a UI sound.
The validate script computes the estimated duration and flags layers that exceed 3 s.
Envelope sanity - no zero decay, no infinite sustain without release
Required:
envelope.decay > 0(always). Set to 0.005 minimum.- If
envelope.sustain > 0,envelope.releasemust be present and> 0.
Recommended:
envelope.attack: 0 for percussive, 0.003-0.05 for sustained tones, up to 0.1 for ambient sounds.envelope.decay + envelope.release: <= 2 s for any UI sound. Above that, you're writing music, not interface feedback.envelope.sustain: 0 for transients, 0.03-0.15 for "rings out" tones, 0.3-0.7 only for held loops.
The validate script flags decay <= 0, sustain > 0 without release, and total durations above 3 s.
Frequency bounds - 20 Hz to 20 kHz, both ends meaningful
Hard bounds:
source.frequency(or bothstart/endof a sweep): 20 Hz <= f <= 20000 Hz.filter.frequency: 20 Hz <= f <= 20000 Hz.filter.envelope.peak: same range asfilter.frequency.
Recommended UI bounds:
- Tonal sources: 80 Hz <= f <= 8000 Hz.
- High transient layers (clicks, sticks): up to 5 kHz.
- Sub layers (body, drum): 60-200 Hz.
Anything above 8 kHz risks being inaudible on phone speakers; anything below 60 Hz risks being inaudible on laptop speakers.
The validate script flags any frequency outside the hard bounds.
Gain budget - keep total layer gain under 0.6
Single layer:
gainbetween 0.04 and 0.3 for typical UI events.- Background ticks/scroll-snaps: 0.04-0.10.
- Mid-importance (click, tap, hover): 0.12-0.20.
- High-importance (success, notification): 0.16-0.25.
Multi-layer:
- Sum of all
layer.gainvalues must be <= 0.6. - If you exceed it, scale every layer proportionally rather than picking one to lower.
If a sound includes a heavy reverb (mix > 0.15) or distortion, lower the gain budget by 20%.
The validate script flags both individual layers above 0.4 and totals above 0.6.
Schema conformance - validate against patch.schema.json
Every emitted SoundDefinition must validate against packages/audio/schemas/patch.schema.json (#/$defs/SoundDefinition).
Common mistakes:
- Missing
decayinenvelope(required). - Missing
targetinlfo(required). - Setting
panoutside[-1, 1]. - Using a
filter.typethat isn't one oflowpass | highpass | bandpass | notch | allpass | peaking | lowshelf | highshelf | iir. - Adding a top-level field that isn't in
LayerorMultiLayerSound(e.g.name,description). The schema isadditionalProperties: false. - Confusing
MultiLayerSound.effects(chain on the mixed bus) withLayer.effects(chain on a single layer).
The validate script invokes the JSON Schema validator on every rule's example field. Any violation aborts the build.
"""
Shared FFT helpers for the interpret-* rules in skills/create-sound.
Ported from skills/interpret-sounds/reference.md so each interpret rule
can `from analyze import ...` instead of duplicating the math.
Dependencies: numpy, scipy. ffmpeg is required as a CLI for source
acquisition (handled outside this module).
"""
from __future__ import annotations
import numpy as np
from scipy.fft import rfft, rfftfreq
from scipy.signal import find_peaks
def load_mono(path: str):
"""Read a WAV file and return (sample_rate, mono_samples)."""
from scipy.io import wavfile
sample_rate, data = wavfile.read(path)
if data.ndim > 1:
data = data[:, 0]
return sample_rate, data
def analyze_slice(data, sample_rate: int, start_ms: float, window_ms: float = 10):
"""FFT a short window and return the peak frequency in Hz, or None if empty."""
start = int(sample_rate * start_ms / 1000)
end = start + int(sample_rate * window_ms / 1000)
segment = data[start:end].astype(float)
if len(segment) == 0:
return None
segment *= np.hanning(len(segment))
spectrum = np.abs(rfft(segment))
freqs = rfftfreq(len(segment), 1 / sample_rate)
peak_idx = np.argmax(spectrum[1:]) + 1
return freqs[peak_idx]
def extract_envelope(data, sample_rate: int, noise_floor_db: float = -40):
"""Estimate ADSR (attack, decay, sustain, release) from time-domain amplitude."""
amplitude = np.abs(data.astype(float))
window = int(sample_rate * 0.001)
smoothed = np.convolve(amplitude, np.ones(window) / window, mode="same")
noise_floor = np.max(smoothed) * 10 ** (noise_floor_db / 20)
peak_idx = int(np.argmax(smoothed))
peak_amp = smoothed[peak_idx]
onset = int(np.argmax(smoothed > noise_floor))
attack_s = (peak_idx - onset) / sample_rate
active_end = len(smoothed) - 1 - int(np.argmax(smoothed[::-1] > noise_floor))
mid_start = peak_idx + int((active_end - peak_idx) * 0.3)
mid_end = peak_idx + int((active_end - peak_idx) * 0.7)
if mid_end > mid_start:
sustain_amp = float(np.mean(smoothed[mid_start:mid_end]))
sustain_ratio = sustain_amp / peak_amp if peak_amp > 0 else 0
else:
sustain_ratio = 0
if sustain_ratio > 0.01:
sustain_threshold = peak_amp * sustain_ratio * 1.1
decay_end = peak_idx + int(np.argmax(smoothed[peak_idx:] < sustain_threshold))
decay_s = (decay_end - peak_idx) / sample_rate
else:
decay_s = (active_end - peak_idx) / sample_rate
release_start = active_end - int((active_end - peak_idx) * 0.1)
release_s = (active_end - release_start) / sample_rate
return {
"attack": round(attack_s, 4),
"decay": round(decay_s, 4),
"sustain": round(max(0, min(1, sustain_ratio)), 3),
"release": round(max(0.005, release_s), 4),
}
def classify_waveform(spectrum, freqs, fundamental_freq: float) -> str:
"""Classify oscillator type from harmonic ratios."""
harmonics = []
for n in range(2, 9):
target = fundamental_freq * n
idx = int(np.argmin(np.abs(freqs - target)))
harmonics.append(spectrum[idx])
fund_amp = spectrum[int(np.argmin(np.abs(freqs - fundamental_freq)))]
if fund_amp == 0:
return "noise"
ratios = [h / fund_amp for h in harmonics]
if all(r < 0.01 for r in ratios):
return "sine"
odd_only = all(ratios[i] < 0.05 for i in [0, 2, 4])
if odd_only and ratios[1] > 0.05:
return "square" if ratios[1] > 0.3 else "triangle"
if all(r > 0.01 for r in ratios[:4]):
return "sawtooth"
return "wavetable"
def spectral_centroid(spectrum, freqs):
s = float(np.sum(spectrum))
return float(np.sum(freqs * spectrum) / s) if s > 0 else 0.0
def estimate_resonance(spectrum, freqs, cutoff_hz: float, bandwidth_hz: float = 200):
cutoff_region = (freqs > cutoff_hz - bandwidth_hz) & (freqs < cutoff_hz + bandwidth_hz)
if not np.any(cutoff_region):
return 1.0
peak_in_region = float(np.max(spectrum[cutoff_region]))
baseline_mask = (freqs > cutoff_hz * 0.3) & (freqs < cutoff_hz * 0.7)
baseline = float(np.mean(spectrum[baseline_mask])) if np.any(baseline_mask) else 0.0
if baseline == 0:
return 1.0
return round(max(0.1, min(20.0, peak_in_region / baseline)), 1)
def detect_filter_envelope(data, sample_rate: int, slices_ms=(0, 5, 10, 20, 50, 100)):
centroids = []
for t in slices_ms:
start = int(sample_rate * t / 1000)
end = start + int(sample_rate * 0.01)
segment = data[start:end].astype(float)
if len(segment) == 0:
break
segment *= np.hanning(len(segment))
spectrum = np.abs(rfft(segment))
freqs = rfftfreq(len(segment), 1 / sample_rate)
centroids.append(spectral_centroid(spectrum, freqs))
if len(centroids) < 2:
return None
if centroids[0] > centroids[-1] * 1.5:
return {
"peak": round(centroids[0]),
"resting": round(centroids[-1]),
"decay_ms": slices_ms[len(centroids) - 1],
}
return None
def detect_reverb(data, sample_rate: int, envelope_end_ms: float):
start = int(sample_rate * envelope_end_ms / 1000)
tail = data[start:].astype(float)
if len(tail) == 0:
return None
amplitude = np.abs(tail)
noise_floor = float(np.max(np.abs(data.astype(float)))) * 0.001
tail_end = int(np.argmax(amplitude[::-1] > noise_floor))
tail_duration_s = (len(tail) - tail_end) / sample_rate
if tail_duration_s > 0.05:
return {"type": "reverb", "decay": round(tail_duration_s, 2)}
return None
def detect_delay(data, sample_rate: int, min_delay_ms: int = 20, max_delay_ms: int = 1000):
signal = data.astype(float)
signal = signal / (float(np.max(np.abs(signal))) + 1e-10)
min_lag = int(sample_rate * min_delay_ms / 1000)
max_lag = int(sample_rate * max_delay_ms / 1000)
max_lag = min(max_lag, len(signal) - 1)
autocorr = np.correlate(signal[: max_lag * 2], signal[: max_lag * 2], mode="full")
autocorr = autocorr[len(autocorr) // 2 :]
peaks, _ = find_peaks(autocorr[min_lag:max_lag], height=0.1 * autocorr[0])
if len(peaks) > 0:
delay_samples = int(peaks[0]) + min_lag
delay_time = delay_samples / sample_rate
feedback = autocorr[delay_samples] / autocorr[0] if autocorr[0] > 0 else 0
return {
"type": "delay",
"time": round(delay_time, 3),
"feedback": round(max(0, min(0.95, float(feedback))), 2),
}
return None
def detect_lfo(parameter_over_time, sample_rate_of_measurements: float):
centered = parameter_over_time - np.mean(parameter_over_time)
spectrum = np.abs(rfft(centered))
freqs = rfftfreq(len(centered), 1 / sample_rate_of_measurements)
lfo_mask = (freqs > 0.1) & (freqs < 20)
if not np.any(lfo_mask):
return None
lfo_spectrum = spectrum.copy()
lfo_spectrum[~lfo_mask] = 0
peak_idx = int(np.argmax(lfo_spectrum))
if spectrum[peak_idx] > float(np.mean(spectrum)) * 3:
rate = float(freqs[peak_idx])
depth = float(np.max(parameter_over_time) - np.min(parameter_over_time)) / 2
return {"frequency": round(rate, 1), "depth": round(depth, 4)}
return None
def analyze_stereo(data):
if data.ndim < 2:
return {"pan": 0, "stereo_width": 0}
left = data[:, 0].astype(float)
right = data[:, 1].astype(float)
l_rms = float(np.sqrt(np.mean(left ** 2)))
r_rms = float(np.sqrt(np.mean(right ** 2)))
if l_rms + r_rms == 0:
return {"pan": 0, "stereo_width": 0}
pan = (r_rms - l_rms) / (r_rms + l_rms)
correlation = float(np.corrcoef(left, right)[0, 1])
stereo_width = 1.0 - abs(correlation)
return {"pan": round(pan, 2), "stereo_width": round(stereo_width, 2)}
def extract_harmonics(spectrum, freqs, fundamental_freq: float, num_harmonics: int = 16):
fund_amp = spectrum[int(np.argmin(np.abs(freqs - fundamental_freq)))]
if fund_amp == 0:
return [1.0] + [0.0] * (num_harmonics - 1)
harmonics = []
for n in range(1, num_harmonics + 1):
target = fundamental_freq * n
if target > freqs[-1]:
harmonics.append(0.0)
else:
idx = int(np.argmin(np.abs(freqs - target)))
harmonics.append(round(float(spectrum[idx] / fund_amp), 4))
return harmonics
def classify_noise_color(spectrum, freqs) -> str:
mask = (freqs > 100) & (freqs < 10000)
log_freqs = np.log10(freqs[mask])
log_power = 20 * np.log10(spectrum[mask] + 1e-10)
slope = float(np.polyfit(log_freqs, log_power, 1)[0])
if abs(slope) < 1.5:
return "white"
if abs(slope) < 4.5:
return "pink"
return "brown"
def detect_fm(spectrum, freqs, fundamental_freq: float):
peak_indices, _ = find_peaks(spectrum, height=float(np.max(spectrum)) * 0.05)
peak_freqs = freqs[peak_indices]
non_harmonic = []
for f in peak_freqs:
ratio = f / fundamental_freq
if abs(ratio - round(ratio)) > 0.05:
non_harmonic.append(float(f))
if len(non_harmonic) >= 2:
spacings = np.diff(sorted(non_harmonic))
mod_freq = float(np.median(spacings))
depth = len(non_harmonic)
return {
"fm": {
"ratio": round(mod_freq / fundamental_freq, 2),
"depth": min(1000, round(depth * 100)),
}
}
return None
#!/usr/bin/env node
// Build SKILL.md from rules/*.md.
// Run: node src/build.mjs (or `pnpm --dir skills/create-sound build`)
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import {
parseFrontmatter,
prefixOf,
readRuleFiles,
readSections,
RULES_DIR,
SKILL_ROOT,
} from "./lib.mjs";
const meta = JSON.parse(
await readFile(join(SKILL_ROOT, "metadata.json"), "utf8"),
);
const sections = await readSections();
const sectionByPrefix = new Map(sections.map((s) => [s.prefix, s]));
const ruleFiles = await readRuleFiles();
const grouped = new Map();
for (const s of sections) grouped.set(s.prefix, []);
for (const file of ruleFiles) {
const prefix = prefixOf(file);
if (!prefix || !sectionByPrefix.has(prefix)) {
console.warn(`build: skipping ${file} (no matching section prefix)`);
continue;
}
const raw = await readFile(join(RULES_DIR, file), "utf8");
const { data, body } = parseFrontmatter(raw);
grouped.get(prefix).push({ file, data, body });
}
for (const [, items] of grouped) {
items.sort((a, b) => {
const ao = parseOrder(a.data.order);
const bo = parseOrder(b.data.order);
if (ao !== bo) return ao - bo;
return (a.data.title ?? a.file).localeCompare(b.data.title ?? b.file);
});
}
function parseOrder(value) {
const n = Number(value);
return Number.isFinite(n) ? n : Number.POSITIVE_INFINITY;
}
const lines = [];
lines.push("---");
lines.push("name: create-sound");
lines.push("description: >-");
const desc = meta.abstract.replace(/\s+/g, " ").trim();
const wrapped = wrapText(desc, 78).map((l) => ` ${l}`);
lines.push(...wrapped);
lines.push("---");
lines.push("");
lines.push("# Create Sound");
lines.push("");
lines.push(
`> Generated from \`rules/*.md\` by \`src/build.mjs\`. Do not edit by hand.`,
);
lines.push("");
lines.push(
"Pick a generation path with `pipeline-detect-input`, then walk the matching section.",
);
lines.push("");
let sectionIdx = 0;
for (const section of sections) {
sectionIdx++;
const items = grouped.get(section.prefix) ?? [];
if (items.length === 0) continue;
lines.push(`## ${sectionIdx}. ${section.title}`);
lines.push("");
lines.push(`_${section.description}_`);
lines.push("");
let ruleIdx = 0;
for (const { data, body } of items) {
ruleIdx++;
const number = `${sectionIdx}.${ruleIdx}`;
const impact = data.impact ? ` _(${data.impact})_` : "";
lines.push(`### ${number} ${data.title ?? "(untitled)"}${impact}`);
lines.push("");
lines.push(stripFirstHeading(body).trimEnd());
lines.push("");
}
}
const outPath = join(SKILL_ROOT, "SKILL.md");
await writeFile(outPath, `${lines.join("\n").trimEnd()}\n`);
console.log(
`build: wrote ${outPath} (${ruleFiles.length} rules across ${sections.length} sections)`,
);
function wrapText(text, width) {
const words = text.split(/\s+/);
const out = [];
let line = "";
for (const w of words) {
if (!line.length) {
line = w;
} else if (line.length + 1 + w.length <= width) {
line += ` ${w}`;
} else {
out.push(line);
line = w;
}
}
if (line.length) out.push(line);
return out;
}
function stripFirstHeading(body) {
const lines = body.split("\n");
while (lines.length && !lines[0].trim()) lines.shift();
if (lines.length && lines[0].startsWith("## ")) {
lines.shift();
while (lines.length && !lines[0].trim()) lines.shift();
}
let inFence = false;
return lines
.map((line) => {
if (/^```/.test(line)) {
inFence = !inFence;
return line;
}
if (inFence) return line;
const m = line.match(/^(#{2,5})\s/);
if (!m) return line;
return `#${line}`;
})
.join("\n");
}
#!/usr/bin/env node
// Extract test cases from rule frontmatter into test-cases.json.
// Each rule with `prompt` + `example` becomes a prompt-path case.
// Each rule with `inputAudio` + `example` becomes an audio-path case.
// Run: node src/extract-tests.mjs (or `pnpm --dir skills/create-sound extract-tests`)
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import {
parseFrontmatter,
prefixOf,
readRuleFiles,
RULES_DIR,
SKILL_ROOT,
} from "./lib.mjs";
const ruleFiles = await readRuleFiles();
const cases = [];
for (const file of ruleFiles) {
const prefix = prefixOf(file);
const raw = await readFile(join(RULES_DIR, file), "utf8");
const { data } = parseFrontmatter(raw);
const id = file.replace(/\.md$/, "");
if (data.inputAudio && data.example) {
cases.push({
id,
kind: "audio",
section: prefix,
title: data.title,
inputAudio: data.inputAudio,
expected: safeParse(data.example, file),
});
continue;
}
if (data.prompt && data.example) {
cases.push({
id,
kind: "prompt",
section: prefix,
title: data.title,
prompt: data.prompt,
expected: safeParse(data.example, file),
});
}
}
const outPath = join(SKILL_ROOT, "test-cases.json");
await writeFile(
outPath,
`${JSON.stringify({ generated: true, count: cases.length, cases }, null, 2)}\n`,
);
console.log(
`extract-tests: wrote ${outPath} (${cases.length} case(s) from ${ruleFiles.length} rule(s))`,
);
function safeParse(text, file) {
try {
return JSON.parse(text);
} catch (err) {
throw new Error(`extract-tests: ${file} example is not valid JSON: ${err.message}`);
}
}
// Tiny helpers shared by build/validate/extract-tests.
// Hand-rolled to avoid any external deps.
import { readdir, readFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
export const HERE = dirname(fileURLToPath(import.meta.url));
export const SKILL_ROOT = resolve(HERE, "..");
export const RULES_DIR = join(SKILL_ROOT, "rules");
export const SECTIONS_FILE = join(RULES_DIR, "_sections.md");
export async function readJson(path) {
return JSON.parse(await readFile(path, "utf8"));
}
export async function readRuleFiles() {
const entries = await readdir(RULES_DIR);
return entries
.filter((name) => name.endsWith(".md") && !name.startsWith("_"))
.sort();
}
/**
* Parse a markdown file with YAML-ish frontmatter.
* We only need a small subset: scalar string/number/bool, comma-separated lists,
* and the `example: |` pipe block (raw multi-line string).
*/
export function parseFrontmatter(raw) {
if (!raw.startsWith("---\n")) {
return { data: {}, body: raw };
}
const end = raw.indexOf("\n---\n", 4);
if (end === -1) return { data: {}, body: raw };
const fmText = raw.slice(4, end);
const body = raw.slice(end + 5);
const data = {};
const lines = fmText.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line.trim() || line.trim().startsWith("#")) continue;
const colonIdx = line.indexOf(":");
if (colonIdx === -1) continue;
const key = line.slice(0, colonIdx).trim();
let value = line.slice(colonIdx + 1).trim();
if (value === "|" || value === ">") {
// Multi-line block. Collect indented lines until dedent.
const blockLines = [];
let j = i + 1;
let baseIndent = null;
for (; j < lines.length; j++) {
const next = lines[j];
if (!next.length) {
blockLines.push("");
continue;
}
const indent = next.match(/^( *)/)[1].length;
if (baseIndent === null) baseIndent = indent;
if (indent < baseIndent && next.trim().length > 0) break;
blockLines.push(next.slice(baseIndent));
}
data[key] = blockLines.join("\n").replace(/\s+$/, "");
i = j - 1;
continue;
}
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
data[key] = value;
}
return { data, body: body.replace(/^\n+/, "") };
}
export async function readSections() {
const raw = await readFile(SECTIONS_FILE, "utf8");
// The file is markdown; the YAML lives between the first ```yaml fence pair.
const match = raw.match(/```yaml\n([\s\S]*?)```/);
if (!match) throw new Error("_sections.md is missing a ```yaml fence");
const yaml = match[1];
const sections = [];
let current = null;
for (const line of yaml.split("\n")) {
if (/^\s*-\s*prefix:/.test(line)) {
if (current) sections.push(current);
current = { prefix: line.split(":")[1].trim() };
} else if (/^\s+title:/.test(line) && current) {
current.title = line.slice(line.indexOf(":") + 1).trim();
} else if (/^\s+description:/.test(line) && current) {
current.description = line.slice(line.indexOf(":") + 1).trim();
}
}
if (current) sections.push(current);
return sections;
}
export function prefixOf(filename) {
const idx = filename.indexOf("-");
return idx === -1 ? null : filename.slice(0, idx);
}
#!/usr/bin/env node
// Validate every rule's frontmatter, section prefix, and (where present) example.
// Run: node src/validate.mjs (or `pnpm --dir skills/create-sound validate`)
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import {
parseFrontmatter,
prefixOf,
readRuleFiles,
readSections,
RULES_DIR,
} from "./lib.mjs";
const REQUIRED = ["title", "impact"];
const ALLOWED_IMPACTS = new Set([
"CRITICAL",
"HIGH",
"MEDIUM-HIGH",
"MEDIUM",
"LOW-MEDIUM",
"LOW",
]);
const sections = await readSections();
const knownPrefixes = new Set(sections.map((s) => s.prefix));
const ruleFiles = await readRuleFiles();
const errors = [];
const warnings = [];
for (const file of ruleFiles) {
const prefix = prefixOf(file);
if (!knownPrefixes.has(prefix)) {
errors.push(`${file}: prefix "${prefix}" is not in _sections.md`);
continue;
}
const raw = await readFile(join(RULES_DIR, file), "utf8");
const { data } = parseFrontmatter(raw);
for (const key of REQUIRED) {
if (!data[key]) errors.push(`${file}: missing required frontmatter "${key}"`);
}
if (data.impact && !ALLOWED_IMPACTS.has(data.impact)) {
errors.push(
`${file}: impact "${data.impact}" is not one of ${[...ALLOWED_IMPACTS].join(", ")}`,
);
}
if (data.example) {
const exampleErrors = validateExample(data.example, file, prefix);
errors.push(...exampleErrors);
} else if (
prefix === "event" ||
prefix === "mood" ||
prefix === "layer" ||
prefix === "effect"
) {
warnings.push(
`${file}: rules in section "${prefix}" should normally include an \`example\` block`,
);
}
}
for (const w of warnings) console.warn(`warn: ${w}`);
if (errors.length) {
for (const e of errors) console.error(`error: ${e}`);
console.error(`\nvalidate: ${errors.length} error(s).`);
process.exit(1);
}
console.log(
`validate: ${ruleFiles.length} rule(s) ok (${warnings.length} warning(s)).`,
);
function validateExample(example, file, prefix) {
let parsed;
try {
parsed = JSON.parse(example);
} catch (err) {
return [`${file}: example is not valid JSON: ${err.message}`];
}
const errs = [];
const layers = "layers" in parsed ? parsed.layers : [parsed];
if (!Array.isArray(layers) || layers.length === 0) {
errs.push(`${file}: example has no layers`);
return errs;
}
let totalGain = 0;
for (const [i, layer] of layers.entries()) {
if (!layer.source) {
errs.push(`${file}: layer[${i}] missing required "source"`);
continue;
}
const freqErr = checkFrequency(layer.source.frequency, file, i);
if (freqErr) errs.push(freqErr);
if (layer.envelope) {
if (typeof layer.envelope.decay !== "number" || layer.envelope.decay <= 0) {
errs.push(`${file}: layer[${i}].envelope.decay must be > 0`);
}
if (
typeof layer.envelope.sustain === "number" &&
layer.envelope.sustain > 0 &&
(typeof layer.envelope.release !== "number" || layer.envelope.release <= 0)
) {
errs.push(
`${file}: layer[${i}] sustain > 0 requires release > 0 (validate-envelope-sanity)`,
);
}
}
if (layer.filter) {
const filters = Array.isArray(layer.filter) ? layer.filter : [layer.filter];
for (const [fi, f] of filters.entries()) {
if (typeof f.frequency === "number") {
if (f.frequency < 20 || f.frequency > 20000) {
errs.push(
`${file}: layer[${i}].filter[${fi}].frequency ${f.frequency} out of bounds (20-20000)`,
);
}
}
}
}
if (typeof layer.gain === "number") {
if (layer.gain < 0 || layer.gain > 0.6) {
errs.push(
`${file}: layer[${i}].gain ${layer.gain} outside recommended 0-0.6 (validate-gain-budget)`,
);
}
totalGain += layer.gain;
}
}
if (layers.length > 1 && totalGain > 0.6) {
errs.push(
`${file}: total layer gain ${totalGain.toFixed(2)} exceeds 0.6 budget (validate-gain-budget)`,
);
}
return errs;
}
function checkFrequency(value, file, layerIdx) {
if (value === undefined) return null; // noise/sample have no frequency
if (typeof value === "number") {
if (value < 20 || value > 20000) {
return `${file}: layer[${layerIdx}].source.frequency ${value} out of bounds (20-20000)`;
}
return null;
}
if (value && typeof value === "object" && "start" in value && "end" in value) {
if (value.start < 20 || value.start > 20000) {
return `${file}: layer[${layerIdx}].source.frequency.start ${value.start} out of bounds`;
}
if (value.end < 20 || value.end > 20000) {
return `${file}: layer[${layerIdx}].source.frequency.end ${value.end} out of bounds`;
}
return null;
}
return `${file}: layer[${layerIdx}].source.frequency has unexpected shape`;
}