·9 min read

Finding samples in plain English, and why the model is the easy part

React NativeAudioAgentsRetrieval

Splice shipped an MCP server recently, so you can ask Claude for “a dark lo-fi guitar loop around 80 BPM” and it goes and searches their catalog without you opening the app. I'll be honest, I was building almost the same thing into react-native-audio-effects at the same time. We got to similar places from opposite directions. And in both cases the language model turned out to be the least interesting part of it.

The problem both of us are solving

Producers don't think in filenames. They think “something gritty for a 90bpm lo-fi beat”. A texture, a tempo, a genre, and a rough sense of what the sound has to do in the track.

Sample libraries index the other thing entirely. Titles, tags, folders, whatever the uploader happened to type that day.

Closing that gap used to mean scrolling. You'd audition forty kicks to find the one with the right amount of air on it. The whole pitch of an agent is that you describe the sound once and something else does the scrolling for you.

What Splice shipped

Splice's MCP server exposes their catalog as tools an AI client can call. describe_a_sound, create_stack, prompt_to_stack, share_stack, download_asset. You point Claude or Cursor or VS Code at mcp.splice.com/mcp and your assistant can search, build a stack of complementary sounds, and pull the assets down.

It's a clean design, and the split is obvious once you see it. The model lives outside, in whatever client you already use. The catalog lives inside, behind an account, a subscription and credits. The MCP server is just the seam between the two.

What I built, and the one constraint that shaped everything

Mine is a library API instead of a server, because it runs inside a React Native app and the sound has to end up loaded in an audio engine on the device, not sitting in a downloads folder. Three steps. Interpret the request, search for it, load the result.

const agent = new SampleAgent();

// Search, and see what it understood.
const { query, results } = await agent.find(
  'gritty drum loop, 90bpm, under 5 seconds'
);

// Or go straight to a loaded, playable source.
const { source, attribution } = await agent.fetch('warm vinyl crackle');
await source.play({ loop: true });

Now the constraint that shaped all of it. It had to work with no API key. A library you can't try until you register for a token is a library most people never try at all. Splice has no public API. Freesound's needs a registered key. Openverse indexes Freesound (among other providers), serves direct CDN audio URLs, and takes no authentication whatsoever, so search works the second you install the package.

So the model is optional

This is where the two designs really split. Splice assumes a model, because an MCP server without a client isn't anything. I couldn't assume one, because every free model also needs a key, and I'd just decided that a key was the thing standing between this feature and the people who would actually use it.

So the default interpreter is a vocabulary parser. A list of words that genuinely show up in sample metadata, plus a few regexes for the things that are unambiguous in text. Tempo, musical key, duration, loop vs one shot. It isn't clever. It is instant, free, offline, and right about the parts of a request that matter most for actually finding something.

A model goes in front of it as a hook, not a dependency:

const agent = new SampleAgent({
  interpret: async (prompt) => {
    const reply = await myModel(prompt);
    return { terms: reply.terms, bpm: reply.bpm, understood: {} };
  },
});

What a model actually buys you here is handling for phrasing the vocabulary has no entry for. “That sound from early Burial records”, that kind of thing. Worth having. Just not worth blocking the whole feature on.

The hard part was never the language

Here's the bit I didn't see coming. Turning English into search terms is the easy half. The hard half is that the search engine underneath has opinions, and most of them are actively hostile to a detailed request.

Every extra word is a filter

Openverse ANDs its search terms. So the more faithfully you render what the user said, the more likely you come back with nothing at all. Measured against the live API:

gritty drum loop gives you 29 results.
gritty drum loop 90bpm gives you 0.

Uploaders do sometimes put a tempo in the title. Just nowhere near often enough to require it. So the most accurate version of the query is the one that matches nothing, which is a pretty useless way to answer someone who asked you for a sound.

Instead of picking once between precision and recall, the search walks a ladder of query variants, most specific first, and stops at the first one that returns anything. That way a detailed request still gets something back.

The order you give up words matters

Which raises the question of what to drop first. My first version lumped every descriptive word together and dropped them all as fluff. That's how snare one shot for an ambient song ended up searching for snare one shot and returning jazz loops.

To a producer the genre is the request. It carries more intent than the instrument does, half the time.

So texture adjectives and genre words get tracked separately now. Texture goes first (“gritty”, “dusty”, “warm” narrow hard and rarely show up in an uploader's title). Genre goes last, and only if there's still something left. “Ambient” is most of the intent in that request, so it should be the last thing you give up, not the first.

Words that were doing two jobs

Same shape of bug, different place. Words like “one shot” and “loop” are signals, they set a structured field. But they were also being left in the text query, so Openverse was AND-ing on “one” and “shot” and matching any title with the word shot in it. Same with a parsed musical key, it became a filter and a repeated search term at the same time. Anything you capture as structure has to come back out of the terms.

The filter that looked obvious and was completely wrong

Openverse has a category parameter. For a loop request, category=music looks like exactly the filter you want. Applying it takes gritty drum loop from 29 results to 0, because plenty of real loops get filed under sound effects.

Duration turned out to be the honest separator. Asking for a one shot and getting handed a four minute loop is the clearest way this search can be wrong, and length is the one signal that reliably tells them apart. Three seconds covers a long reverbed cymbal and excludes anything that loops. Results with no reported duration get kept rather than dropped, because excluding them would quietly lose valid samples over missing metadata.

And then there's the network

Openverse doesn't degrade gracefully. It either answers fast or it hangs until you give up. Three probes in a row during development:

63ms. Then 30s (timeout). Then 30s (timeout).

There's no middle ground to wait for, which changes what a timeout is even for. A long timeout buys you nothing against a hung connection and costs the user the entire wait. So the per request timeout is 2.5 seconds (already thirty times a healthy response) and the budget goes on retrying instead of waiting.

Better than that, the hang is a property of the individual connection, not the service. A request that stalls stays stalled while a fresh one next to it comes back completely fine. So requests get hedged. If the first one hasn't answered within 400ms, a second goes out, and whichever comes back first wins. 400ms sits comfortably above a healthy 63ms response so a working request never gets duplicated, and well below the 2.5s timeout so a stalled one gets overtaken early instead of eating the whole budget.

The search as a whole is bounded too. Without a ceiling the relaxation ladder just multiplies out (four variants times three attempts times the request timeout) and the UI sits there on “Searching…” for minutes. Bounding the whole operation matters more than bounding any single request.

Attribution isn't an afterthought

Everything Openverse returns is openly licensed. Openly licensed isn't the same as free of obligation though. Most results are CC-BY, which means anyone shipping that sample has to name the creator and the licence.

So licence and creator come back on every single result, and loading a sample hands you a formatted credit line right next to the audio. The point is to make crediting the easy path instead of the thing you remember three weeks after release. There's a publicDomainOnly option too, because a producer dropping a sample into a track they're going to publish has a very different obligation under CC0 than under CC-BY.

How the two compare

 Splice MCPSampleAgent
ShapeMCP serverLibrary API
ModelRequired (the host client)Optional hook
AuthAccount, subscription, creditsNone
CatalogSplice's licensed libraryOpenverse (Freesound et al.)
LicensingCovered by your Splice planCC, attribution returned
Runs inClaude, Cursor, VS CodeYour app, on device
Ends withA downloaded fileA playing audio source

These aren't really competitors. Splice is the better answer when you're at a desk, in a DAW, with a subscription, and you want a professionally produced loop that's already cleared. Mine is the answer when the sound has to arrive inside an app you're shipping, from a catalog with no gate in front of it, on a phone.

Why this matters if you make music

Honestly, the shared idea is worth more than either implementation. For years the interface to a sample library has been a search box that only rewards you for correctly guessing the uploader's vocabulary. Describing a sound the way you'd describe it to someone sitting in the room with you is a better interface, and it's finally doable.

What I took away from it

I set out to build an agent and ended up spending most of the time on retrieval. The parser is a word list and five regexes. All the interesting code sits downstream of it. Deciding which part of a request to abandon first. Noticing that a perfectly plausible filter just took your recall to zero. Working out that the right response to a hanging service is a second request, not a longer wait.

“Add an LLM” is the easy half of a feature like this. The half that decides whether it's any good is the unglamorous retrieval work underneath, and that part you have to measure. You can't guess it.

The sample agent lives in src/samples, and the whole library is open source.