v0.17.0 — AssetService drop-in, sound banks, asset health, private-audio sharing - #15
Merged
Conversation
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
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
temporarily deployed
to
openCloud-smoke
July 28, 2026 01:34 — with
GitHub Actions
Inactive
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Seven commits. Everything verified against the live API through a real Roblox engine.
AudioScape:getAssetService()— the drop-inChange one line and existing
AssetService:SearchAudioAsynccode works against AudioScape's semantic catalog:Same
AudioSearchParamsin, sameAudioPagesout. Every member we don't override forwards to the real service, soGetAudioMetadataAsyncand 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.MaxDurationdefaults to 2147483647, not0. 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 seedKills the "machine gun" effect. Needs no new endpoint:
similar/sfxSimilarwithdedupewas already exactly "close but not the same clip".Your asset is never silently swapped.
extend(default) keeps your seed leading its pool.replacedrops 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.
GetAudioMetadataAsyncreturns title and artist for any Roblox audio ID, so an unmatched seed bridges via its own metadata. Verified in Studio: seed134606886228009missed the catalog, engine returned "Happy Birthday / ViMoD", bank resolvedsource: bridgedwith the seed still leading.checkAssetHealth/auditAudio— what's broken and what's privateCombines 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)replaceslocal 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
luau-lsp analyzestrict · wally package snapshot