Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -444,14 +444,30 @@ 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 |
| `playerId` | `number?` | — | |

**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 |
Expand Down
99 changes: 93 additions & 6 deletions src/AudioScapeSoundBank.luau
Original file line number Diff line number Diff line change
Expand Up @@ -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?),
Expand All @@ -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?,
Expand All @@ -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 },
}
Expand Down Expand Up @@ -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"')
Expand All @@ -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
Expand All @@ -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),
Expand Down Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions src/init.luau
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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 },
}

Expand Down
82 changes: 81 additions & 1 deletion tests/openCloud/smokeScript.luau
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand All @@ -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()).
Expand Down Expand Up @@ -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.
Expand Down
Loading