From f16dc16a82ae505ffb68c6f54c76d7c34123b65f Mon Sep 17 00:00:00 2001 From: Filipe Herculano Date: Tue, 28 Jul 2026 13:34:21 -0400 Subject: [PATCH 1/2] feat: build sound banks from a portal-curated playlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createSoundBank now accepts a playlist_id instead of seeds. Slots become pools, so the sounds a game plays can change without shipping code. Slots are used exactly as curated — no similarity expansion. Whoever built the bank already chose what belongs in each slot, and quietly adding sounds they never listened to is the same mistake as auto-refreshing their gunshot. Widening a slot is a decision made in the portal, where it can be heard first. Adds kind/sound_count/slots to PlaylistInfo and a sounds list to PlaylistResult, kept separate from tracks so neither needs a type test before use. Verified end to end through Open Cloud against the live API: 27 steps green, including a two-slot bank whose pools match the playlist's declared slots exactly. --- README.md | 18 ++++- src/AudioScapeSoundBank.luau | 99 +++++++++++++++++++++-- src/init.luau | 40 +++++++++ tests/openCloud/smokeScript.luau | 82 ++++++++++++++++++- tests/soundBank.spec.luau | 134 +++++++++++++++++++++++++++++++ 5 files changed, 365 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index e92972c..9f5c3a9 100644 --- a/README.md +++ b/README.md @@ -444,7 +444,8 @@ sound.SoundId = "rbxassetid://" .. bank:pick("footstep") | Option | Type | Default | Description | | --- | --- | --- | --- | -| `seeds` | `{ [string]: string }` | — | Named asset IDs, e.g. `{ footstep = "rbxassetid://123" }` | +| `seeds` | `{ [string]: string }?` | — | Named asset IDs, e.g. `{ footstep = "rbxassetid://123" }` | +| `playlist_id` | `string?` | — | Build from a sound bank curated in the portal instead. Pass this or `seeds`, not both | | `kind` | `string?` | `"sfx"` | `"sfx"` expands through the sound-effects catalog, `"music"` through music | | `poolSize` | `number?` | `8` | How many assets to end up with per seed | | `mode` | `string?` | `"extend"` | `"extend"` keeps your asset in the pool and adds neighbours; `"replace"` uses neighbours only | @@ -452,6 +453,21 @@ sound.SoundId = "rbxassetid://" .. bank:pick("footstep") **Your asset is never silently swapped.** In the default `extend` mode it leads its own pool — we add to your choice rather than overriding it. `replace` exists for cases where you genuinely don't care which specific clip plays, and you have to ask for it. +### Building from a portal-curated bank + +Slots you set up in the portal become pools, so the sounds a game plays can change without shipping code. + +```lua +local bank = AudioScape:createSoundBank({ playlist_id = "sfx-1785256484251" }) +bank:resolveAsync() + +sound.SoundId = "rbxassetid://" .. bank:pick("footstep") +``` + +Each slot is used **exactly as curated** — no similarity expansion. Whoever built the bank already decided what belongs in each slot, and adding sounds they never listened to isn't ours to do. Widen a slot in the portal, where you can hear it first. `bank.Pools[name].source` reads `"playlist"` on this path. + +Everything else is unchanged: `pick` still avoids immediate repeats, and `reportUnavailable` still drops an asset that fails to load. + ### Methods | Method | Description | diff --git a/src/AudioScapeSoundBank.luau b/src/AudioScapeSoundBank.luau index d598a55..b18698e 100644 --- a/src/AudioScapeSoundBank.luau +++ b/src/AudioScapeSoundBank.luau @@ -31,6 +31,7 @@ local DEFAULT_POOL_SIZE = 8 -- approach AudioScapeMusicPlayer uses for its analytics dependency, so this -- module stays independent of init.luau's concrete type. type CatalogClient = { + getPlaylist: (self: any, options: any) -> (any, string?), lookup: (self: any, options: any) -> (any, string?), search: (self: any, options: any) -> (any, string?), sfxSearch: (self: any, options: any) -> (any, string?), @@ -41,7 +42,12 @@ type CatalogClient = { export type SoundBankOptions = { -- Named seeds: { footstep = "rbxassetid://123", impact = "456" }. - seeds: { [string]: string }, + -- Provide these or `playlist_id`, not both. + seeds: { [string]: string }?, + -- Build the bank from a sound bank curated in the portal. Each slot becomes + -- a pool of exactly the sounds chosen for it, so the sounds a game plays can + -- change without shipping code. + playlist_id: string?, -- "sfx" (default) expands through the sound-effects catalog; "music" -- through the music catalog. kind: string?, @@ -57,9 +63,10 @@ export type SoundBankOptions = { export type SoundBankPool = { name: string, seed_asset_id: string, - -- Where the neighbourhood came from: "catalog" (the seed itself is in our - -- catalog), "bridged" (matched via the engine's metadata for the seed), or - -- "none" (couldn't resolve — pool is just the seed). + -- Where the pool came from: "catalog" (the seed itself is in our catalog), + -- "bridged" (matched via the engine's metadata for the seed), "none" + -- (couldn't resolve — pool is just the seed), or "playlist" (curated in the + -- portal and used exactly as chosen). source: string, assets: { string }, } @@ -89,7 +96,14 @@ end function AudioScapeSoundBank.new(catalog: CatalogClient, options: SoundBankOptions): SoundBankInstance assert(type(options) == "table", "createSoundBank requires an options table") - assert(type(options.seeds) == "table", "createSoundBank requires a seeds table") + + local playlistId = options.playlist_id + if playlistId ~= nil then + assert(type(playlistId) == "string" and #playlistId > 0, "createSoundBank playlist_id must be a non-empty string") + assert(options.seeds == nil, "createSoundBank takes seeds or playlist_id, not both") + else + assert(type(options.seeds) == "table", "createSoundBank requires a seeds table or a playlist_id") + end local kind = options.kind or "sfx" assert(kind == "sfx" or kind == "music", 'createSoundBank kind must be "sfx" or "music"') @@ -99,7 +113,10 @@ function AudioScapeSoundBank.new(catalog: CatalogClient, options: SoundBankOptio local seeds: { [string]: string } = {} local names: { string } = {} - for name, raw in options.seeds do + -- Annotated rather than inlined: `options.seeds or {}` infers a union with a + -- sealed empty table, which Luau then refuses to iterate. + local rawSeeds: { [string]: string } = options.seeds or {} + for name, raw in rawSeeds do local id = normalizeId(raw) assert(id, "seed '" .. tostring(name) .. "' is not a usable asset id: " .. tostring(raw)) seeds[name] = id @@ -113,6 +130,7 @@ function AudioScapeSoundBank.new(catalog: CatalogClient, options: SoundBankOptio _catalog = catalog, _seeds = seeds, _names = names, + _playlistId = playlistId, _kind = kind, _mode = mode, _poolSize = math.max(1, options.poolSize or DEFAULT_POOL_SIZE), @@ -235,7 +253,76 @@ function AudioScapeSoundBank._neighbours(self: any, anchorId: string): { string end -- Resolve every seed into a pool. Safe to call more than once; it rebuilds. +-- Build pools straight from a portal-curated sound bank. +-- +-- Slots are used exactly as chosen — no similarity expansion. The developer +-- already decided what belongs in each slot, and quietly adding sounds they +-- never reviewed is the same mistake as auto-refreshing their gunshot. Widening +-- a slot is a decision made in the portal, where they can hear it first. +function AudioScapeSoundBank._resolveFromPlaylist(self: any): (boolean, string?) + local result, err = self._catalog:getPlaylist({ + playlist_id = self._playlistId, + playerId = self._playerId, + }) + if not result then + return false, err or "could not load sound bank " .. tostring(self._playlistId) + end + + local slots = if type(result.playlist) == "table" then result.playlist.slots else nil + if type(slots) ~= "table" then + return false, "playlist " .. tostring(self._playlistId) .. " returned no slots" + end + + local pools: { [string]: SoundBankPool } = {} + local names: { string } = {} + for _, slot in slots do + local label = if type(slot) == "table" then slot.label else nil + local assetIds = if type(slot) == "table" then slot.asset_ids else nil + if type(label) == "string" and #label > 0 and type(assetIds) == "table" and #assetIds > 0 then + local assets: { string } = {} + for _, raw in assetIds do + local id = normalizeId(tostring(raw)) + if id then + table.insert(assets, id) + end + end + if #assets > 0 then + pools[label] = { + name = label, + -- The first sound in the slot stands in as the seed, so the + -- pool shape matches the seeded path for every consumer. + seed_asset_id = assets[1], + source = "playlist", + assets = assets, + } + table.insert(names, label) + end + end + end + + if #names == 0 then + return false, "playlist " .. tostring(self._playlistId) .. " has no usable slots" + end + + table.sort(names) + self._names = names + self.Pools = pools + self.IsResolved = true + + self._catalog:trackCustom("audio_resolve", nil, self._playerId, { + source = "playlist", + playlist_id = self._playlistId, + slots = #names, + }) + + return true +end + function AudioScapeSoundBank.resolveAsync(self: any): (boolean, string?) + if self._playlistId then + return self:_resolveFromPlaylist() + end + local known, err = self:_classifySeeds() if err then return false, err diff --git a/src/init.luau b/src/init.luau index 5fa98d0..b9bf020 100644 --- a/src/init.luau +++ b/src/init.luau @@ -414,12 +414,49 @@ export type PlaylistOptions = { playerId: number?, } +-- A slot of interchangeable sounds. Sound effects fire on events rather than +-- playing in sequence, so an SFX playlist is grouped by slot, not ordered. +export type PlaylistSlot = { + label: string, + asset_ids: { string }, +} + export type PlaylistInfo = { id: string, name: string, genre: string, playback_mode: string, track_count: number, + -- "music", "sfx", or "mixed". Absent on responses from before SFX support. + kind: string?, + sound_count: number?, + slots: { PlaylistSlot }?, +} + +-- A sound in a playlist. Same shape as an sfxSearch result plus the two fields +-- that only mean something inside a playlist. +export type PlaylistSound = { + asset_id: string, + name: string, + description: string, + category: string, + subcategory: string, + tags: string, + duration: number, + created_at: string, + updated_at: string, + creator_id: number?, + creator_name: string, + -- Where the audio actually starts and stops, from our analysis. Trimming to + -- sound_start_sec removes the leading silence that makes an impact feel late. + sound_start_sec: number?, + sound_end_sec: number?, + true_peak_dbtp: number?, + play_count: number, + like_count: number, + -- The slot this sound belongs to; sounds sharing a label are variants. + label: string?, + position: number, } export type PlaylistTrack = { @@ -441,6 +478,9 @@ export type PlaylistTrack = { export type PlaylistResult = { playlist: PlaylistInfo, tracks: { PlaylistTrack }, + -- Sound effects, kept separate from `tracks` rather than unioned into it so + -- neither list needs a type test before use. + sounds: { PlaylistSound }?, meta: { total: number }, } diff --git a/tests/openCloud/smokeScript.luau b/tests/openCloud/smokeScript.luau index da32746..2730fab 100644 --- a/tests/openCloud/smokeScript.luau +++ b/tests/openCloud/smokeScript.luau @@ -26,6 +26,7 @@ type Fixtures = { music_asset_id_with_structure: string?, sfx_asset_id: string?, playlist_id: string?, + sfx_playlist_id: string?, } -- Dynamic require of a deserialized ModuleScript — luau-lsp can't statically @@ -72,6 +73,7 @@ local fixtures: Fixtures = { music_asset_id_with_structure = nil, sfx_asset_id = nil, playlist_id = nil, + sfx_playlist_id = nil, } step("browse_trending", function() @@ -325,7 +327,15 @@ step("listPlaylists", function() if #r.playlists > 0 then fixtures.playlist_id = r.playlists[1].id end - return { total = r.meta.total, returned = #r.playlists } + -- A sound bank is a playlist of SFX, so it comes back from the same list. + -- Recorded separately because the two feed different steps below. + for _, p in r.playlists do + if p.kind == "sfx" then + fixtures.sfx_playlist_id = p.id + break + end + end + return { total = r.meta.total, returned = #r.playlists, sfx = fixtures.sfx_playlist_id } end) step("getPlaylist", function() @@ -340,6 +350,29 @@ step("getPlaylist", function() return { id = r.playlist.id, name = r.playlist.name, tracks = #r.tracks } end) +step("getPlaylist (sfx)", function() + local id = fixtures.sfx_playlist_id + if not id then + return "skipped — no sfx playlists for this key" + end + local r, err = client:getPlaylist({ playlist_id = id }) + assert(r, "getPlaylist(sfx) failed: " .. tostring(err)) + assert(r.playlist.kind == "sfx", "kind is sfx; got " .. tostring(r.playlist.kind)) + assert(r.sounds and #r.sounds > 0, "sounds returned") + assert(#r.tracks == 0, "an sfx playlist carries no music tracks") + assert(r.playlist.slots and #r.playlist.slots > 0, "slots returned") + + -- Every sound belongs to a slot, and the slots account for every sound. + local inSlots = 0 + for _, slot in r.playlist.slots do + assert(#slot.asset_ids > 0, "slot '" .. tostring(slot.label) .. "' is not empty") + inSlots += #slot.asset_ids + end + assert(inSlots == #r.sounds, "slots cover every sound: " .. tostring(inSlots) .. " vs " .. tostring(#r.sounds)) + + return { id = r.playlist.id, slots = #r.playlist.slots, sounds = #r.sounds } +end) + -- Analytics: enqueue one event per public track method, force-flush, assert -- drain. Exercises workspace:GetServerTimeNow under real engine (catches the -- class of bug fixed in 64ad673 where the Lune mock returned os.clock()). @@ -548,6 +581,53 @@ step("auditAudio", function() ) end) +-- A bank built from a portal-curated sound bank. The slots are used exactly as +-- chosen, so this asserts the pools match what the playlist declared rather than +-- whatever similarity would have produced. +step("createSoundBank (from playlist)", function() + local id = fixtures.sfx_playlist_id + if not id then + return "skipped — no sfx playlists for this key" + end + + local declared, err = client:getPlaylist({ playlist_id = id }) + assert(declared, "getPlaylist failed: " .. tostring(err)) + + local bank = client:createSoundBank({ playlist_id = id }) + local ok, resolveErr = bank:resolveAsync() + assert(ok, "resolveAsync from playlist failed: " .. tostring(resolveErr)) + + local slots = 0 + for _, slot in declared.playlist.slots do + slots += 1 + local pool = bank:getPool(slot.label) + assert( + #pool == #slot.asset_ids, + "slot '" .. slot.label .. "' pool is " .. tostring(#pool) .. ", declared " .. tostring(#slot.asset_ids) + ) + for i, assetId in slot.asset_ids do + assert(pool[i] == assetId, "slot '" .. slot.label .. "' differs at index " .. tostring(i)) + end + assert( + bank.Pools[slot.label].source == "playlist", + "source should be 'playlist', got " .. tostring(bank.Pools[slot.label].source) + ) + + -- Picking must still avoid immediate repeats on a curated pool. + if #pool > 1 then + local previous = nil + for n = 1, 20 do + local pick = bank:pick(slot.label) + assert(pick, "pick returned nil at " .. tostring(n)) + assert(pick ~= previous, "immediate repeat at " .. tostring(n)) + previous = pick + end + end + end + + return string.format("slots=%d", slots) +end) + -- createSoundBank. The bridging path calls AssetService:GetAudioMetadataAsync, -- which Lune can't model at all, so the engine is the only place it can be -- exercised against the real catalog. diff --git a/tests/soundBank.spec.luau b/tests/soundBank.spec.luau index 60593e0..9f7e639 100644 --- a/tests/soundBank.spec.luau +++ b/tests/soundBank.spec.luau @@ -312,3 +312,137 @@ do end) assert(not okSeed, "unusable seed id rejected") end + +-- 14. A bank sourced from a portal-curated playlist uses each slot exactly as +-- chosen. Quietly expanding a slot with similar sounds would put audio the +-- developer never reviewed into their game — the same mistake as +-- auto-refreshing a gunshot. +do + local http = mockHttp.new() + local sdk = loadSdk({ http = http }) + local AudioScape: any = sdk + AudioScape.setApiKey("test-key") + + http:queueResponse({ + Success = true, + StatusCode = 200, + Body = serde.encode("json", { + playlist = { + id = "sfx-123", + name = "Bank", + genre = "", + playback_mode = "order", + track_count = 0, + sound_count = 4, + kind = "sfx", + slots = { + { label = "footstep", asset_ids = { "111", "222", "333" } }, + { label = "impact", asset_ids = { "444" } }, + }, + }, + tracks = {}, + sounds = {}, + meta = { total = 4 }, + }), + }) + + local bank = AudioScape:createSoundBank({ playlist_id = "sfx-123" }) + local ok, err = bank:resolveAsync() + assert(ok, "resolve from playlist failed: " .. tostring(err)) + assert(#http.requests == 1, "one request — the playlist fetch; got " .. tostring(#http.requests)) + + local footstep = bank:getPool("footstep") + assert(#footstep == 3, "the slot is the pool, verbatim; got " .. tostring(#footstep)) + assert(footstep[1] == "111", "order preserved; got " .. tostring(footstep[1])) + assert(#bank:getPool("impact") == 1, "single-sound slot stays a single-sound pool") + assert(bank.Pools.footstep.source == "playlist", "source reports 'playlist'") + assert(bank.IsResolved, "IsResolved set") +end + +-- 15. Picking still refuses immediate repeats on a playlist-sourced pool. +do + local http = mockHttp.new() + local sdk = loadSdk({ http = http }) + local AudioScape: any = sdk + AudioScape.setApiKey("test-key") + + http:queueResponse({ + Success = true, + StatusCode = 200, + Body = serde.encode("json", { + playlist = { + id = "sfx-9", + name = "B", + genre = "", + playback_mode = "order", + track_count = 0, + slots = { { label = "step", asset_ids = { "1", "2", "3" } } }, + }, + tracks = {}, + meta = { total = 3 }, + }), + }) + + local bank = AudioScape:createSoundBank({ playlist_id = "sfx-9" }) + bank:resolveAsync() + + local previous: string? = nil + for i = 1, 100 do + local pick = bank:pick("step") + assert(pick, "pick returned nil at " .. tostring(i)) + assert(pick ~= previous, "immediate repeat at " .. tostring(i)) + previous = pick + end + + -- reportUnavailable still heals a curated pool. + assert(bank:reportUnavailable("2"), "first report returns true") + for i = 1, 40 do + assert(bank:pick("step") ~= "2", "unavailable sound never picked (" .. tostring(i) .. ")") + end +end + +-- 16. Validation and failure paths for the playlist source +do + local http = mockHttp.new() + local sdk = loadSdk({ http = http }) + local AudioScape: any = sdk + AudioScape.setApiKey("test-key") + + local okBoth = pcall(function() + AudioScape:createSoundBank({ seeds = { s = "1" }, playlist_id = "sfx-1" }) + end) + assert(not okBoth, "seeds and playlist_id together are rejected") + + local okNeither = pcall(function() + AudioScape:createSoundBank({}) + end) + assert(not okNeither, "neither seeds nor playlist_id is rejected") + + local okEmpty = pcall(function() + AudioScape:createSoundBank({ playlist_id = "" }) + end) + assert(not okEmpty, "empty playlist_id is rejected") + + -- A playlist that can't be fetched fails the resolve rather than yielding a + -- silently empty bank that picks nil at runtime. + http:queueResponse({ Success = false, StatusCode = 404, Body = "" }) + local missing = AudioScape:createSoundBank({ playlist_id = "sfx-nope" }) + local ok404 = missing:resolveAsync() + assert(not ok404, "a 404 playlist fails the resolve") + assert(not missing.IsResolved, "and leaves the bank unresolved") + + -- A music playlist has no slots; say so rather than resolving to nothing. + http:queueResponse({ + Success = true, + StatusCode = 200, + Body = serde.encode("json", { + playlist = { id = "m", name = "M", genre = "", playback_mode = "order", track_count = 5, slots = {} }, + tracks = {}, + meta = { total = 5 }, + }), + }) + local musicBank = AudioScape:createSoundBank({ playlist_id = "music-1" }) + local okMusic, errMusic = musicBank:resolveAsync() + assert(not okMusic, "a playlist with no slots fails the resolve") + assert(tostring(errMusic):find("slots", 1, true), "error says why; got: " .. tostring(errMusic)) +end From 3cbb42a5a54fa637d57b703e7b1a89cdcae7dac9 Mon Sep 17 00:00:00 2001 From: Filipe Herculano Date: Tue, 28 Jul 2026 14:15:17 -0400 Subject: [PATCH 2/2] chore: release v0.18.0 --- CHANGELOG.md | 21 +++++++++++++++++++++ README.md | 2 +- wally.toml | 2 +- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e857405..584d685 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## v0.18.0 + +### Added + +- **`AudioScape:createSoundBank({ playlist_id = "..." })` builds a bank from a sound bank curated in the portal.** Each slot becomes a pool, so the sounds a game plays can change without shipping code: + + ```lua + local bank = AudioScape:createSoundBank({ playlist_id = "sfx-1785256484251" }) + bank:resolveAsync() + + sound.SoundId = "rbxassetid://" .. bank:pick("footstep") + ``` + + Slots are used **exactly as curated** — no similarity expansion. Whoever built the bank already decided what belongs in each slot, and quietly adding sounds they never listened to is the same mistake as swapping a game's gunshot unasked. Widening a slot is a decision made in the portal, where it can be heard first. `bank.Pools[name].source` reads `"playlist"` on this path. + + Everything else is unchanged: `pick` still avoids immediate repeats, and `reportUnavailable` still drops a sound that fails to load. + +- **`getPlaylist` understands playlists that carry sound effects.** `playlist.kind` is `"music"`, `"sfx"`, or `"mixed"`; `playlist.slots` gives the grouped view; and sounds arrive in a `sounds` list beside `tracks`, kept separate so neither needs a type test before use. Sounds carry the same shape as `sfxSearch` results, including `sound_start_sec` / `sound_end_sec` — trimming to `sound_start_sec` removes the leading silence that makes an impact feel late. + +- `seeds` is now optional on `createSoundBank`. Pass `seeds` or `playlist_id`, not both. + ## v0.17.0 ### Changed diff --git a/README.md b/README.md index 9f5c3a9..959a41f 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Add to your `wally.toml`: ```toml [server-dependencies] -AudioScape = "this-fifo/audioscape-sdk@0.17.0" +AudioScape = "this-fifo/audioscape-sdk@0.18.0" ``` Then run: diff --git a/wally.toml b/wally.toml index 03a250b..bda8803 100644 --- a/wally.toml +++ b/wally.toml @@ -1,6 +1,6 @@ [package] name = "this-fifo/audioscape-sdk" -version = "0.17.0" +version = "0.18.0" registry = "https://github.com/UpliftGames/wally-index" realm = "server" license = "MIT"