Skip to content

v0.17.0 — AssetService drop-in, sound banks, asset health, private-audio sharing - #15

Merged
this-fifo merged 8 commits into
masterfrom
feat/asset-service-dropin
Jul 28, 2026
Merged

v0.17.0 — AssetService drop-in, sound banks, asset health, private-audio sharing#15
this-fifo merged 8 commits into
masterfrom
feat/asset-service-dropin

Conversation

@this-fifo

Copy link
Copy Markdown
Member

Seven commits. Everything verified against the live API through a real Roblox engine.

AudioScape:getAssetService() — the drop-in

Change one line and existing AssetService:SearchAudioAsync code works against AudioScape's semantic catalog:

local AssetService = AudioScape:getAssetService()  -- was game:GetService("AssetService")

Same AudioSearchParams in, same AudioPages out. Every member we don't override forwards to the real service, so GetAudioMetadataAsync and friends are untouched — proven by a discrepancy: asking the shim for metadata on an asset our stub called "Neon Drift" returned "Paradise Falls / Craig McConnell", because that call bypassed the stub entirely. And on any error it falls back to native, so adopting has no downside case.

Verified against a live engine: AudioSearchParams.MaxDuration defaults to 2147483647, not 0. Treating that as a real value would have attached a meaningless duration ceiling to every request — a bug the docs alone would not have surfaced. Native page size is 30 (undocumented), so the smoke asserts it.

AudioScape:createSoundBank() — variation from one seed

Kills the "machine gun" effect. Needs no new endpoint: similar/sfxSimilar with dedupe was already exactly "close but not the same clip".

Your asset is never silently swapped. extend (default) keeps your seed leading its pool. replace drops it only when we resolved it from our own catalog — a bridged or unresolved seed may be private audio your game has permission for where a substitute might not.

Seeds we've never ingested still work. GetAudioMetadataAsync returns title and artist for any Roblox audio ID, so an unmatched seed bridges via its own metadata. Verified in Studio: seed 134606886228009 missed the catalog, engine returned "Happy Birthday / ViMoD", bank resolved source: bridged with the seed still leading.

checkAssetHealth / auditAudio — what's broken and what's private

Combines two sources because neither suffices: the catalog knows moderation state, only the engine knows whether an asset your experience can play exists at all. Catalog misses it + engine describes it = private to you.

reportPrivate (default off) surfaces those in the console for direct file upload. Off by default because the result is a list of your private catalog and inferring consent from a diagnostic call isn't right.

Naming

AudioScape.setApiKey(key) replaces local client = AudioScape.new(key) as the documented path. "client" means the player's machine in Roblox, and the docs used the same variable name for the server object and the LocalScript object in adjacent examples. .new() still works.

Verified

  • 21 unit specs, 25/25 Open Cloud smoke steps against the live API in a real engine
  • stylua · selene · luau-lsp analyze strict · wally package snapshot
  • Studio MCP runs for the engine-only paths (bridging, private detection, replace-mode guard)

Adds a stand-in for game:GetService("AssetService") so existing
AssetService:SearchAudioAsync call sites work against the AudioScape
catalog without changing anything but the service lookup. Same
AudioSearchParams in, same AudioPages out (GetCurrentPage /
AdvanceToNextPageAsync / IsFinished), same raise-on-failure behaviour.

AudioSubType routes the call: Music hits /v1/search, SoundEffect hits
/v1/sfx/search. SearchKeyword/Title/Artist/Album combine into the query;
Tag becomes a genre or category filter and doubles as the query when
nothing else is set; Min/MaxDuration become a duration filter.

Members we don't override forward to the real AssetService via __index,
so GetAudioMetadataAsync, CreateEditableImage and the rest are unchanged
— the shim is a complete stand-in rather than a two-method object.
Forwarding GetAudioMetadataAsync also keeps fields the engine returns
that we don't hold.

AudioSearchParams signals "unset" with sentinels rather than nil:
strings "", MinDuration 0, MaxDuration 2147483647 (int32 max),
AudioSubType Music. Verified against a live engine — forwarding the
MaxDuration sentinel would have attached a meaningless duration ceiling
to every request. Native page size is 30, also undocumented, so the
smoke asserts both to catch a change.

Mirrored on AudioScapeClient over a new SearchAudio RemoteFunction;
HttpService is server-only, so the request and the result mapping both
stay server-side rather than duplicating the mappers client-side.

Coverage: 14 unit cases in tests/assetService.spec.luau over the pure
helpers plus end-to-end through mock HTTP, and a getAssetService step in
the Open Cloud smoke that exercises a real AudioSearchParams Instance.
Roblox developers read "client" as the player's machine, so naming the
server-side object `client` in every doc and example was actively
misleading. The collision ran on both axes: the same variable name was
used for the server object and the LocalScript object in adjacent README
blocks, and the server object's internal type was `AudioScapeClient`
while AudioScapeClient.luau is the LocalScript module.

Removes the naming decision rather than answering it. The module doubles
as the object:

    AudioScape.setApiKey(HttpService:GetSecret("AudioScapeKey"))
    local result = AudioScape:search({ query = "chill lo-fi" })

Nothing was gained by the instance. Universe and Place are read off the
running game, so they're identical for every object; only the key and
analytics settings were ever per-instance. Multi-instance was already
unsupported in practice — the per-player rate limiter is module-scoped,
and enableClientAccess creates a fixed-name folder that a second
instance would collide with.

new() is retained and unchanged in behaviour: it still serves the
multiple-key case and gives the test harness isolated analytics buffers.
initState is shared by both paths so they can't drift. Re-keying
preserves the queued analytics buffer and won't spawn a second flush
loop. AudioScapeClient gets the same treatment with lazily-resolved
remotes; its new() stays eager so a missing enableClientAccess still
fails at startup rather than at first search.

Internal type AudioScapeClient -> AudioScapeInstance (33 signatures), so
the name AudioScapeClient now refers only to the LocalScript module,
where it means what a Roblox developer expects.

Verified: 19 unit specs including 7 new cases covering the colon-call
form, Secret userdata pass-through, key independence between module and
instance, and buffer survival across a re-key. Open Cloud smoke passes
against the real engine.
auditAudio walks the data model for Sound and AudioPlayer instances,
collects distinct asset IDs, and reports which ones our catalog knows.
Built on the existing lookup endpoint, so it needs no API change: the
meta.missing_ids contract already answers "do we have this asset".
Ordered most-used first, with sample_path from GetFullName so a
developer can jump straight to the instance. Sounds with an empty
SoundId are counted as scanned but skipped — an unassigned template is
normal, not a broken entry.

Scanning defaults to the services audio actually lives in rather than
walking `game`, and roots is overridable for large places. A failed
lookup batch fails the whole audit rather than silently reporting assets
as unknown when we simply couldn't ask.

setEndpoints makes baseUrl and analyticsUrl overridable; both were
hardcoded with no escape hatch, which blocked developing against a
local API. Endpoint state survives setApiKey in either order, and
trailing slashes are trimmed so paths can't collapse to a double slash
that some gateways route differently.

auditAudio is covered in the Open Cloud smoke rather than Lune: the
mock has no tree, no IsA and no GetFullName, and the repo's split
already puts engine behaviour in the smoke. The step plants two Sounds,
one AudioPlayer and one blank Sound so counts are deterministic —
verified scanned=4 distinct=1 uses=3 against a real engine.
Addresses the "machine gun" effect: a repeated footstep or impact that
plays the identical clip every time reads as obviously synthetic. The
usual workaround is hand-picking five assets and calling math.random.

Needs no new API endpoint. similar/sfxSimilar with dedupe is already
exactly "semantically close but not the same clip" — the variation-pool
primitive was shipped in v0.14.0 without being framed as one.

The developer's asset is never silently swapped. Default mode "extend"
keeps the seed at the head of its own pool and adds neighbours around
it; "replace" builds from neighbours only and must be asked for.

Seeds outside the catalog still work. GetAudioMetadataAsync returns
title and artist for any Roblox audio id at no cost to us, so an
unmatched seed is bridged by searching on its own metadata.
Pools[name].source reports catalog / bridged / none, and none degrades
to just the seed — never worse than not using a bank at all.

Resolution is once, at startup: seed classification batches 100 per
request and metadata bridging batches 30, matching the respective API
caps. Roblox limits a server to 500 HTTP requests/minute, so per-play
resolution would exhaust the budget almost immediately. Every pick
afterwards is a local table lookup.

reportUnavailable drops a failed asset from later picks. Detection is
inherently client-side — a headless server never fetches audio, so
GetAssetFetchStatus stays None there, measured earlier via Open Cloud.
An exhausted pool falls back to the seed rather than returning nil.

Picks aggregate into one audio_pick event per distinct asset with a
count. One event per pick would overrun the 500-event analytics buffer
during any footstep loop.

GetService for AssetService is resolved lazily rather than at module
load, so the module stays loadable where the service isn't present —
the Lune harness among them.

Adding a fourth src file also updates the wally package snapshot and
loadSdk's identity-based customRequire.

Verified: 11 unit cases (pool construction, no-immediate-repeat over 200
draws, heal, aggregation, validation) plus an Open Cloud smoke step
against the live catalog (source=catalog pool=5). Bridging confirmed in
Studio against a real engine: seed 134606886228009 missing from the
catalog, engine returned Title=Happy Birthday Artist=ViMoD, bank
resolved source=bridged with the seed still leading.
auditAudio previously reported known/unknown, which conflated a
moderated asset with one that is simply private to the developer's
experience. Those need opposite responses: one is broken, the other is
working fine and only needs sharing if they want similarity for it.

checkAssetHealth combines two sources because neither is sufficient
alone. The new /v1/assets/health endpoint knows moderation state; only
the Roblox engine knows whether an asset the experience can play exists
at all. An id our catalog lacks but GetAudioMetadataAsync can describe
is private to that experience. Statuses: ok / moderated / deleted /
delisted / private / unknown.

Only ids the catalog misses go to the engine — asking about everything
would burn the 30-per-call metadata budget for no new information. A
metadata failure degrades to unknown rather than failing an audit that
otherwise succeeded.

Sound banks: replace mode now keeps the seed unless we resolved it from
our own catalog. A bridged or unresolved seed may be private audio
scoped to that experience; the game has permission for its own asset
where a substitute might not, so silently swapping it out is the one
outcome worse than not helping. extend mode was already safe.

Verified against a real engine in Studio: 1837879082 -> ok,
134606886228009 -> private (catalog misses it, engine returns "Happy
Birthday"), 1 -> unknown. auditAudio carries the same statuses through a
DataModel scan, and replace mode retained a bridged seed.

The auditAudio smoke step now asserts status rather than known. It will
fail with 403 until /v1/assets/health is deployed — that 403 is API
Gateway's response for an unregistered route, and every other step
passes today.
Two real gaps. checkAssetHealth had no unit spec at all — it was only
exercised manually in Studio and indirectly through auditAudio's smoke
step. And the guard that keeps a bridged seed in replace mode, which is
the whole private-audio protection, was untested: the existing replace
cases covered a catalog seed and an unresolvable one, but not the
bridged path in between.

Both needed the engine, so mockGame now takes an injectable
AssetService. Leaving it nil is itself a case worth testing — the SDK's
pcall must degrade to "unknown" rather than failing a call.

checkAssetHealth (9 cases): catalog statuses pass through; catalog-miss
plus engine-describes resolves to private with the engine's title;
neither source knowing it stays unknown rather than asserting something
we can't evidence; a missing engine degrades instead of failing; only
not_in_catalog ids reach the engine, since asking about everything would
burn the 30-per-call budget for nothing; metadata batches at 30 and
health lookups at 100; content ids normalise and duplicates collapse; a
failed health request fails the call rather than reporting everything as
unknown, which would read as a definitive answer.

soundBank (2 cases): replace mode keeps a bridged seed and still adds
neighbours around it, and still drops a seed we resolved from our own
catalog so the opt-in behaviour is intact for assets we can vouch for.

auditAudio's data-model scan stays smoke-only — the mock has no tree,
IsA or GetFullName, matching the repo's existing split.

21 unit specs green; full gate clean; Open Cloud smoke still passes
against the live API after the harness change.
checkAssetHealth gains options.reportPrivate (default false), which
reports detected private assets to /v1/assets/private-report so they
surface in the console with an option to upload the file directly.

Off by default deliberately: the result is a list of the developer's
private catalog, and inferring consent from a diagnostic call isn't
right. The determination is returned to them either way, and a failed
report never fails a health check they already have an answer for.

The reported duration comes from GetAudioMetadataAsync rather than from
whoever later uploads a file, so upload validation compares against a
value the uploader does not control. AssetHealth gains `duration`, set
only for private assets.

checkAssetHealth's second parameter becomes an options table so the flag
has somewhere to live; auditAudio threads it through.

Adds a live smoke step. The private path needs an asset Roblox knows and
we don't, which no fixture can guarantee, so it asserts what IS
deterministic: a trending asset resolves ok with a name, an id neither
source knows resolves to unknown rather than a guess, and input order is
preserved.

25/25 smoke steps green against the live API; 21 unit specs; full gate
clean.
@this-fifo
this-fifo temporarily deployed to openCloud-smoke July 28, 2026 01:33 — with GitHub Actions Inactive
Three doc-comment call sites in AudioScapeClient and AudioScapeMusicPlayer
still showed the old `client:` form. Missed when the rest of the sweep
landed; no behaviour change.
@this-fifo
this-fifo temporarily deployed to openCloud-smoke July 28, 2026 01:34 — with GitHub Actions Inactive
@this-fifo
this-fifo merged commit 5b42061 into master Jul 28, 2026
2 checks passed
@this-fifo
this-fifo deleted the feat/asset-service-dropin branch July 28, 2026 01:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant