SLOPSHOPPER

sfx-gen

Generate sound effects from text prompts with a local Stable Audio model (sa3.cpp), no API, no Python

newpanebandguardcommandtoast
★ 1v0.1.0MITupdated 2026-10-10nkapila6/sfx-gen
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sfx-gen
│ ┃ Sound effects ✕ › fix the failing auth test and add an audit log call │ ┃ No clips yet. Try /sfx a whoosh across the │ ┃ screen ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /sfx │ ⎿ sfx-gen: Usage: /sfx <description> [--duration N] [--seed N] │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Sound effects
No clips yet. Try /sfx a whoosh across the screen
README

sfx-gen

A Claude Code mod that generates sound effects from a text prompt using a local Stable Audio model. No API key, no Python at inference time.

It shells out to sa3.cpp, a C++/ggml port of Stable Audio 3, and hands the WAV back to Claude (and plays it).

What it adds

  • /sfx <description> [--duration N] [--seed N] - generate a sound effect from the command line
  • A sfx tool Claude can call, so Claude can generate foley for a video or sound-design task without you prompting each clip
  • A "Sound effects" pane (/sfx-pane) listing your clips with Play and Share mp4 buttons, and a row above the prompt showing progress and the last clip
  • Share mp4 renders a waveform video to ~/Downloads and copies the file, so you can paste it straight into WhatsApp (needs ffmpeg)

Clips are kept across sessions (last 50, paths only, via $.store). The WAVs themselves live wherever the session ran, so a clip from a deleted folder won't play anymore.

Prerequisites

  1. git, cmake, and a C++17 compiler on PATH.
  2. Build sa3.cpp:
git clone --recurse-submodules https://github.com/betweentwomidnights/sa3.cpp.git
cd sa3.cpp
./build.sh metal   # or: cpu / cuda / vulkan / hip
  1. Download the model weights (curl only, no Python):
./models.sh --variant small-sfx --encoding q5_k_m --ae-encoding q5_k_m

Configure

The mod needs sa3_dir set to your sa3.cpp checkout. Any of these work:

1. Per-session flag (simplest, no files changed)

claude --plugin-dir /Users/nkapila6/dev/cc-mod/sfx-gen

Then /config, find the sfx-gen rows, set sa3_dir. The value is stored in your settings and reused next session.

2. Environment variable (works headless, claude -p too)

export SA3_CPP_DIR=/path/to/sa3.cpp
claude --plugin-dir /path/to/sfx-gen

SA3_CPP_DIR is read at generation time, so it must be exported in the same shell before launching Claude.

3. Persistent install (always-on, no flag)

Add to ~/.claude/settings.json:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/sfx-gen"
  },
  "pluginConfigs": {
    "sfx-gen@inline": {
      "options": {
        "sa3_dir": "/path/to/sa3.cpp"
      }
    }
  }
}

pluginConfigs keys take the plugin name plus @inline for a --plugin-dir/CLAUDE_CODE_PLUGIN_DIRS load, and the config values go under options. With this, the mod loads in every session without a flag.

Config

OptionDefaultMeaning
sa3_dir(required)Path to the sa3.cpp checkout
modelsmall-sfxsmall-sfx, small-music, or medium
deviceautoauto, cpu, or metal
encodingq5_k_mMust match what models.sh fetched
duration8Default clip length in seconds

Usage

/sfx a whoosh across the screen
/sfx footsteps on gravel --duration 6 --seed 42

Or ask Claude in natural language ("generate a sound effect of a metal impact") and it will call the sfx tool.

License

MIT. Model weights are under the Stability AI Community License.

Source 2 files
hooks/register.tsx 369 lines
1// sfx-gen: generate sound effects from text with a local Stable Audio model.
2// Shells out to sa3.cpp's sa3-generate binary. No Python, no API key.
3
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Register } from 'claude-code'
6
7import type { Clip } from '../types'
8
9type Engine = EngineInterface
10
11const PANE = 'sfx'
12const PANE_TITLE = 'Sound effects'
13const STORE_KEY = 'clips'
14const MAX_CLIPS = 50
15
16const clipsRef = { plugin: 'sfx-gen', key: 'clips' } as const
17const clips = atom(clipsRef, [])
18const pending = atom({ plugin: 'sfx-gen', key: 'pending' } as const, null)
19const dismissed = atom({ plugin: 'sfx-gen', key: 'dismissed' } as const, null)
20
21const DEFAULTS = {
22  model: 'small-sfx',
23  device: 'auto',
24  encoding: 'q5_k_m',
25  duration: 8,
26}
27
28type Config = typeof DEFAULTS & { sa3Dir?: string }
29let config: Config = { ...DEFAULTS }
30
31type GenOpts = {
32  prompt: string
33  duration?: number | string | null
34  seed?: number | string | null
35  steps?: number | string | null
36}
37
38type GenResult =
39  | { ok: true; clip: Clip; base64: string }
40  | { ok: false; error: string }
41
42function pickBinary(root: string) {
43  return root ? root + '/build-metal/bin/sa3-generate' : ''
44}
45
46// Parse a prompt string, pulling out --duration/--dur, --seed, --steps flags.
47function parseArgs(raw: string | undefined) {
48  let text = (raw || '').trim()
49  const grab = (re: RegExp) => {
50    const m = text.match(re)
51    if (!m) return null
52    text = text.replace(re, ' ').trim()
53    return m[1] ?? null
54  }
55  const duration = grab(/(?:^|\s)--(?:duration|dur)[ =](-?\d+(?:\.\d+)?)/i) ?? grab(/(?:^|\s)-d[ =](\d+(?:\.\d+)?)/i)
56  const seed = grab(/(?:^|\s)--seed[ =](\d+)/i)
57  const steps = grab(/(?:^|\s)--steps[ =](\d+)/i)
58  return { prompt: text, duration, seed, steps }
59}
60
61function errText(err: unknown) {
62  return err instanceof Error ? err.message : String(err)
63}
64
65function slug(text: string) {
66  return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'sfx'
67}
68
69async function firstExisting($: Engine, paths: string[]) {
70  for (const p of paths) if (await $.fs.exists(p)) return p
71  return null
72}
73
74async function saveClips($: Engine, list: Clip[]) {
75  try {
76    await $.store.set(STORE_KEY, list)
77  } catch (err) {
78    $.ui.toast('sfx-gen: could not persist clips: ' + errText(err))
79  }
80}
81
82async function addClip($: Engine, clip: Clip) {
83  await update($, clips, list => [...(list ?? []), clip].slice(-MAX_CLIPS))
84  await saveClips($, await read($, clips))
85}
86
87async function generate($: Engine, opts: GenOpts): Promise<GenResult> {
88  const envDir = await $.env.get('SA3_CPP_DIR')
89  const sa3Dir = (config.sa3Dir || envDir || '').trim()
90  const { model, encoding, device } = config
91  const duration = Number(opts.duration ?? config.duration)
92  const prompt = (opts.prompt || '').trim()
93
94  if (!prompt) return { ok: false, error: 'empty prompt' }
95
96  const bin =
97    (await firstExisting($, [sa3Dir + '/build-metal/bin/sa3-generate', sa3Dir + '/build/bin/sa3-generate'])) ??
98    pickBinary(sa3Dir)
99  const modelsDir = sa3Dir + '/models'
100
101  if (!(await $.fs.exists(bin))) {
102    return { ok: false, error: `sa3-generate not found at ${bin}. Set the sa3_dir config or install sa3.cpp.` }
103  }
104  if (!(await $.fs.exists(modelsDir))) {
105    return { ok: false, error: `models dir not found: ${modelsDir}. Run sa3.cpp's ./models.sh first.` }
106  }
107
108  const cwd = await $.session.cwd()
109  const id = String(Date.now())
110  const outPath = `${cwd}/.sfx-${id}.wav`
111
112  const argv = [
113    bin,
114    '--model', model,
115    '--encoding', encoding,
116    '--models-dir', modelsDir,
117    '--prompt', prompt,
118    '--duration', String(duration),
119    '--out', outPath,
120  ]
121  if (opts.seed != null) argv.push('--seed', String(opts.seed))
122  if (opts.steps != null) argv.push('--steps', String(opts.steps))
123
124  const init: { timeoutMs: number; env?: Record<string, string> } = { timeoutMs: 180000 }
125  if (device === 'cpu') init.env = { SA3_DEVICE: 'cpu' }
126
127  const started = Date.now()
128  $.ui.status(`generating ${duration}s sfx with ${model} (${device})...`)
129  await update($, pending, () => ({ prompt, duration, model, device }))
130
131  try {
132    let run
133    try {
134      run = await $.process.run(argv, init)
135    } catch (err) {
136      return { ok: false, error: `sa3-generate failed to start: ${errText(err)}` }
137    }
138    if (run.exitCode !== 0) {
139      return { ok: false, error: `sa3-generate exited ${run.exitCode}: ${run.stderr || run.stdout}` }
140    }
141
142    let bytes
143    try {
144      bytes = await $.fs.read(outPath, { as: 'bytes' })
145    } catch (err) {
146      return { ok: false, error: `wrote ${outPath} but could not read it back: ${errText(err)}` }
147    }
148
149    const elapsed = ((Date.now() - started) / 1000).toFixed(1)
150    const clip: Clip = { id, prompt, path: outPath, duration, elapsed, model, at: started }
151    await addClip($, clip)
152    return { ok: true, clip, base64: bytes.base64 }
153  } finally {
154    $.ui.status(undefined)
155    await update($, pending, () => null)
156  }
157}
158
159// Shared tail of /sfx and the tool: toast, play, show the pane.
160async function announce($: Engine, r: GenResult, play: boolean) {
161  if (!r.ok) {
162    $.ui.toast('sfx-gen: ' + r.error)
163    return
164  }
165  $.ui.toast(`sfx ready: ${r.clip.prompt} (${r.clip.elapsed}s)`)
166  void $.ui.open({ id: PANE, title: PANE_TITLE })
167  if (play) await $.audio.play({ base64: r.base64, mime: 'audio/wav' })
168}
169
170async function playClip($: Engine, clip: Clip) {
171  try {
172    const bytes = await $.fs.read(clip.path, { as: 'bytes' })
173    await $.audio.play({ base64: bytes.base64, mime: 'audio/wav' })
174  } catch (err) {
175    $.ui.toast(`sfx-gen: cannot play ${clip.path}: ${errText(err)}`)
176  }
177}
178
179// WhatsApp treats audio-only mp4 as a document, so render a waveform video track.
180async function shareClip($: Engine, clip: Clip) {
181  const ffmpeg = await firstExisting($, ['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg', '/usr/bin/ffmpeg'])
182  if (!ffmpeg) {
183    $.ui.toast('sfx-gen: ffmpeg not found, install it with brew install ffmpeg')
184    return
185  }
186  if (!(await $.fs.exists(clip.path))) {
187    $.ui.toast(`sfx-gen: clip is gone: ${clip.path}`)
188    return
189  }
190
191  const home = await $.env.get('HOME')
192  const out = `${home}/Downloads/sfx-${slug(clip.prompt)}-${clip.id}.mp4`
193  $.ui.toast('sfx-gen: converting to mp4...')
194
195  try {
196    const run = await $.process.run(
197      [
198        ffmpeg, '-y', '-loglevel', 'error',
199        '-i', clip.path,
200        '-filter_complex', '[0:a]showwaves=s=720x720:mode=cline:colors=white,format=yuv420p[v]',
201        '-map', '[v]', '-map', '0:a',
202        '-c:v', 'libx264', '-preset', 'veryfast',
203        '-c:a', 'aac', '-b:a', '192k',
204        '-movflags', '+faststart',
205        out,
206      ],
207      { timeoutMs: 120000 },
208    )
209    if (run.exitCode !== 0) {
210      $.ui.toast(`sfx-gen: ffmpeg exited ${run.exitCode}: ${run.stderr}`)
211      return
212    }
213  } catch (err) {
214    $.ui.toast('sfx-gen: ffmpeg failed: ' + errText(err))
215    return
216  }
217
218  // A file reference on the clipboard pastes as an attachment in WhatsApp desktop.
219  let copied = false
220  try {
221    const cp = await $.process.run([
222      '/usr/bin/osascript',
223      '-e', 'on run argv',
224      '-e', 'set the clipboard to (POSIX file (item 1 of argv))',
225      '-e', 'end run',
226      out,
227    ])
228    copied = cp.exitCode === 0
229  } catch {}
230
231  const where = '~/Downloads/' + out.split('/').pop()
232  $.ui.toast(copied ? `mp4 saved to ${where} and copied. Paste it into WhatsApp.` : `mp4 saved to ${where}`)
233}
234
235export const register: Register = (on, options) => {
236  if (options.sa3_dir) config.sa3Dir = String(options.sa3_dir)
237  if (options.model) config.model = String(options.model)
238  if (options.device) config.device = String(options.device)
239  if (options.encoding) config.encoding = String(options.encoding)
240  if (options.duration != null) config.duration = Number(options.duration)
241
242  on('session.start', async ($, e, next) => {
243    // Seed session state from the store once; a hot reload keeps state as is.
244    if ((await $.state.get(clipsRef)).version === 0) {
245      const saved = await $.store.get(STORE_KEY)
246      const list = Array.isArray(saved) ? (saved as Clip[]).slice(-MAX_CLIPS) : []
247      await update($, clips, () => list)
248      // old clips stay in the pane but don't take over the band
249      const last = list[list.length - 1]
250      if (last) await update($, dismissed, () => last.id)
251    }
252
253    await $.command.register({
254      name: 'sfx',
255      description: 'Generate a sound effect from a text prompt with a local Stable Audio model',
256      argumentHint: '[description] [--duration N] [--seed N]',
257    })
258    await $.command.register({
259      name: 'sfx-pane',
260      description: 'Show generated sound effects in a pane',
261    })
262    await $.tool.register({
263      name: 'sfx',
264      description:
265        'Generate a sound effect from a text prompt using a local Stable Audio model (sa3.cpp). ' +
266        'No API key, no Python. Returns the path to a WAV file. Use for foley and SFX: ' +
267        '"metal impact with a deep sub boom", "whoosh across the screen", "footsteps on gravel". ' +
268        'Keep prompts to one sound each and layer clips for complex scenes.',
269      inputSchema: {
270        type: 'object',
271        properties: {
272          prompt: { type: 'string', description: 'Text description of the sound effect to generate' },
273          duration: { type: 'number', description: 'Clip length in seconds (1-30), default from config' },
274          seed: { type: 'number', description: 'Random seed for reproducibility' },
275          steps: { type: 'number', description: 'Diffusion steps, default 8' },
276          play: { type: 'boolean', description: 'Play the generated clip after writing (default true)' },
277        },
278        required: ['prompt'],
279      },
280    })
281    return next(e)
282  })
283
284  on('command.run', { command: 'sfx' }, async ($, e) => {
285    const a = parseArgs(e.args)
286    if (!a.prompt) return { text: 'Usage: /sfx <description> [--duration N] [--seed N]' }
287    const r = await generate($, a)
288    await announce($, r, true)
289    if (!r.ok) return { text: 'sfx-gen: ' + r.error }
290    return { text: `sfx-gen: generated ${r.clip.duration}s in ${r.clip.elapsed}s -> ${r.clip.path}\n  "${r.clip.prompt}"` }
291  })
292
293  on('command.run', { command: 'sfx-pane' }, async $ => {
294    const opened = await $.ui.open({ id: PANE, title: PANE_TITLE })
295    return { text: opened.isPlaced ? 'Sound effects pane opened.' : 'Sound effects pane is waiting for room.' }
296  })
297
298  on('tool.call', { tool: 'mcp__sfx-gen__sfx' }, async ($, e) => {
299    const input = e as unknown as GenOpts & { play?: boolean }
300    const r = await generate($, input)
301    await announce($, r, input.play !== false)
302    if (!r.ok) return { result: r.error }
303    return { result: `Generated ${r.clip.duration}s sound effect "${r.clip.prompt}" in ${r.clip.elapsed}s -> ${r.clip.path}` }
304  })
305
306  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
307    const { Box, Text, Button } = $.ui.resolve(e)
308    const list = (await read($, clips)) ?? []
309    const now = await read($, pending)
310    const room = Math.max(1, (e.viewport?.rows ?? 24) - 6)
311    const recent = list.slice(-room).reverse()
312
313    return (
314      <Box flexDirection="column">
315        {now && (
316          <Text color="yellow">
317            generating {now.duration}s "{now.prompt}" with {now.model}...
318          </Text>
319        )}
320        {list.length === 0 && !now && <Text dimColor>No clips yet. Try /sfx a whoosh across the screen</Text>}
321        {recent.map(clip => (
322          <Box key={'row-' + clip.id} flexDirection="column" marginBottom={1}>
323            <Text wrap="truncate-end">{clip.prompt}</Text>
324            <Box>
325              <Text dimColor>
326                {clip.duration}s, made in {clip.elapsed}s{' '}
327              </Text>
328              <Button key={'play-' + clip.id} label="Play" onPress={() => void playClip($, clip)} />
329              <Text> </Text>
330              <Button key={'share-' + clip.id} label="Share mp4" onPress={() => void shareClip($, clip)} />
331            </Box>
332          </Box>
333        ))}
334      </Box>
335    )
336  })
337
338  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
339    if (e.props.hasSurvey) return next(e)
340
341    const { Box, Text, Button } = $.ui.resolve(e)
342    const now = await read($, pending)
343    if (now) {
344      return (
345        <Box>
346          <Text color="yellow">
347            sfx: generating {now.duration}s "{now.prompt}" with {now.model} ({now.device})...
348          </Text>
349        </Box>
350      )
351    }
352
353    const list = (await read($, clips)) ?? []
354    const last = list[list.length - 1]
355    if (!last || (await read($, dismissed)) === last.id) return next(e)
356
357    return (
358      <Box>
359        <Text wrap="truncate-end">sfx: "{last.prompt}" {last.duration}s </Text>
360        <Button key="play" label="Play" onPress={() => void playClip($, last)} />
361        <Text> </Text>
362        <Button key="share" label="Share mp4" onPress={() => void shareClip($, last)} />
363        <Text> </Text>
364        <Button key="dismiss" label="Dismiss" role="dismiss" onPress={() => void update($, dismissed, () => last.id)} />
365      </Box>
366    )
367  })
368}
369
types/index.d.ts 23 lines
1export type Clip = {
2  id: string
3  prompt: string
4  path: string
5  duration: number
6  elapsed: string
7  model: string
8  at: number
9}
10
11export type Pending = { prompt: string; duration: number; model: string; device: string }
12
13declare module 'claude-code' {
14  interface PluginState {
15    'sfx-gen': {
16      clips: Clip[]
17      pending: Pending | null
18      // id of the last clip the band was dismissed for
19      dismissed: string | null
20    }
21  }
22}
23