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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## v0.20.0

### Added

- **Browse by game** — `AudioScape:browse({ type = "game" })` lists Roblox experiences by the catalog music heard in them (ordered by player count, minimum 5 tracks). Drill into a game's tracks with `name = "<universe_id>"`, or reverse-look-up the games a track has been heard in with `asset_id = "<id>"`. New `BrowseGameItem` type carries `universe_id`, `name`, `creator_name`, `root_place_id`, `playing`, `visits`, and `track_count`; build icons with `rbxthumb://type=GameIcon&id=<universe_id>`. The game→track mapping refreshes weekly; `playing`/`visits` reflect the last sync, not live CCU.

- **Section metadata on track structure** — `AudioScape:getStructure({ asset_id = ..., include_metadata = true })` returns each section/phrase's authored key/value pairs verbatim in a new `metadata` field: custom cue parameters (lighting amounts, movement paths, easing styles) beyond the flattened `label`/`energy`/`bars` fields. Entries without authored pairs return an empty table. The metadata variant caches independently of the plain structure, so `beatAtTime`/`sectionAtTime` are unaffected. Also available through `AudioScapeClient.getStructure` with the same flag.

### Fixed

- Corrected the `sort = "popular"` documentation: only `genre` and `mood` drill-downs omit tracks with no engagement; `artist`, `album`, and `game` include them, sorted last.

## v0.19.0

### Removed
Expand Down
46 changes: 42 additions & 4 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.19.0"
AudioScape = "this-fifo/audioscape-sdk@0.20.0"
```

Then run:
Expand Down Expand Up @@ -192,7 +192,7 @@ local result = AudioScape:similar(soundInstance)

### `AudioScape:browse(options)`

Browse by artist, album, genre, mood, or trending.
Browse by artist, album, genre, mood, trending, or game.

```lua
-- List all genres
Expand All @@ -211,9 +211,9 @@ local result, err = AudioScape:browse({ type = "trending", limit = 50 })
-- result = { tracks, meta }
```

**Browse types:** `artist`, `album`, `genre`, `mood`, `trending`
**Browse types:** `artist`, `album`, `genre`, `mood`, `trending`, `game`

**Sort (drill-down only):** `popular` (default — global popularity ranking, omits tracks with no engagement), `alpha` (track name A→Z), `recent` (newest first). Ignored for list mode and for `trending` (already popularity-ordered). Pick `alpha` or `recent` to surface tracks that haven't accumulated engagement yet.
**Sort (drill-down only):** `popular` (default — global popularity ranking), `alpha` (track name A→Z), `recent` (newest first). Ignored for list mode and for `trending` (already popularity-ordered). Under `popular`, `genre` and `mood` omit tracks with no engagement — pick `alpha` or `recent` to surface them; `artist`, `album`, and `game` include them, sorted last.

Trending is a popularity-ranked list of music tracks refreshed daily, capped at 200 entries. Player engagement signals (plays, favorites, votes, queue adds, listen duration, plus custom events) are exponentially decayed over a 60-day window with a 30-day half-life, so recent activity dominates.

Expand All @@ -229,6 +229,28 @@ local result, err = AudioScape:browse({ type = "trending", region = "eu", limit

> Regional trending must be enabled for your API key. Until then, `region` is ignored and you get the global list. Contact us via the [Developer Portal](https://developer.audioscape.ai) to enable it.

**Browse by game:** `type = "game"` browses Roblox experiences by the catalog music heard in them — the mapping refreshes weekly from Roblox's own music-discovery data.

```lua
-- List games with catalog music, ordered by player count (min 5 tracks)
local result, err = AudioScape:browse({ type = "game", limit = 20 })
-- items = { { universe_id, name, creator_name, root_place_id, playing, visits, track_count } }

for _, item in result.items do
local game = item :: AudioScape.BrowseGameItem
-- Icons come free in Roblox clients:
icon.Image = `rbxthumb://type=GameIcon&id={game.universe_id}&w=150&h=150`
end

-- Drill into a game's tracks (universe_id travels as a string in `name`)
local tracks = AudioScape:browse({ type = "game", name = "66654135", limit = 25 })

-- Reverse lookup: the games a track has been heard in
local games = AudioScape:browse({ type = "game", asset_id = "1841647093" })
```

`playing` and `visits` come from the last catalog sync, not live CCU. Any mapped game resolves by `universe_id`, even below the 5-track list floor.

### `AudioScape:sfxBrowse(options)`

Browse the SFX catalog. v1 only supports `type = "trending"` — a popularity-ranked list of sound effects, refreshed daily on the same schedule as music trending.
Expand Down Expand Up @@ -320,6 +342,22 @@ local structure = AudioScape:getStructure(soundInstance)

`label` values come from: `Intro`, `Verse`, `Chorus`, `Drop`, `Bridge`, `Climax`, `Outro`, `Main`, `Break`, `Build`, `Breakdown`, `Transition`, `Peak`. `energy` is `1`–`4`.

**Section metadata:** pass `include_metadata = true` and every section/phrase carries its authored key/value pairs verbatim in `metadata` — custom cue parameters (lighting amounts, movement paths, easing styles) beyond the flattened fields. Entries without authored pairs return an empty table. Drive custom events straight from authored cue sections:

```lua
local structure = AudioScape:getStructure({ asset_id = "1843209165", include_metadata = true })

for _, section in structure.sections do
if section.metadata.type == "Move" then
-- e.g. { type = "Move", path = "CueObjects.Part_1", endPoint = "0,10,0",
-- startPoint = "0,0,0", easingStyle = "Quad", relative = "true" }
task.delay(section.start, function()
runMoveCue(section.metadata, section["end"] - section.start)
end)
end
end
```

> **Note on AudioPlayer:** v0.11.0 auto-resolves `audioPlayer.Asset` (the legacy ContentId field). If your project uses the newer `audioPlayer.AudioContent` (a `Content` userdata), pass `audioPlayer.AudioContent.Uri` yourself for now.

### `AudioScape:beatAtTime(asset_id, t)`
Expand Down
15 changes: 12 additions & 3 deletions src/AudioScapeClient.luau
Original file line number Diff line number Diff line change
Expand Up @@ -365,13 +365,22 @@ end

-- Fetch beat grid + section structure for a track (beat-sync animations,
-- lighting cues). Polymorphic input — see similar() for the (input, extras?)
-- contract.
function AudioScapeClient.getStructure(self: AudioScapeClientInstance, input: AssetIdResolvable): (any, string?)
-- contract. Pass `include_metadata = true` (on the options table or extras)
-- to get each section/phrase's authored key/value cue pairs verbatim.
function AudioScapeClient.getStructure(
self: AudioScapeClientInstance,
input: AssetIdResolvable,
extras: { include_metadata: boolean? }?
): (any, string?)
local assetId = resolveAssetId(input)
if not assetId then
return nil, "getStructure requires an asset_id string, a track table, a Sound, or an AudioPlayer"
end
return invoke(getRemotes(self), "GetStructure", { asset_id = assetId })
local opts: any = extras or (type(input) == "table" and input or {})
return invoke(getRemotes(self), "GetStructure", {
asset_id = assetId,
include_metadata = if opts.include_metadata == true then true else nil,
})
end

return AudioScapeClient
71 changes: 61 additions & 10 deletions src/init.luau
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ local VALID_BROWSE_TYPES = {
genre = true,
mood = true,
trending = true,
game = true,
}

local VALID_SFX_BROWSE_TYPES = {
Expand Down Expand Up @@ -135,6 +136,7 @@ export type AssetIdResolvable = string | { asset_id: string, [string]: any } | S
-- Track / Sound / string and still want limit/offset/filters/playerId.
export type StructureExtras = {
playerId: number?,
include_metadata: boolean?,
}

export type SimilarExtras = {
Expand Down Expand Up @@ -178,13 +180,21 @@ export type SfxSimilarExtras = {
}

export type BrowseOptions = {
type: string, -- "artist" | "album" | "genre" | "mood" | "trending"
type: string, -- "artist" | "album" | "genre" | "mood" | "trending" | "game"
-- Drill-down key. For type = "game" pass the numeric universe id as a
-- string (from BrowseGameItem.universe_id); other types take the entity
-- name (or genre slug).
name: string?,
-- "popular" (default — popularity ranking, omits tracks with no
-- engagement), "alpha" (track name A→Z), or "recent" (newest first).
-- Drill-down only; ignored for list mode and for type = "trending"
-- (already popularity-ordered). Pick alpha or recent to surface fresh
-- uploads that haven't accumulated engagement yet.
-- type = "game" only: reverse lookup — list the games this track has
-- been heard in, as game items. Numeric asset id as a string. Ignored
-- when `name` is present.
asset_id: string?,
-- "popular" (default — popularity ranking), "alpha" (track name A→Z),
-- or "recent" (newest first). Drill-down only; ignored for list mode
-- and for type = "trending" (already popularity-ordered). Under popular,
-- genre/mood omit tracks with no engagement; artist/album/game include
-- them, sorted last. Pick alpha or recent to surface fresh uploads
-- under genre/mood.
sort: string?,
limit: number?,
offset: number?,
Expand Down Expand Up @@ -316,6 +326,9 @@ export type BrowseListResult = {
limit: number,
offset: number,
type: string,
-- Echo of the queried track on a type = "game" asset_id reverse
-- lookup; absent everywhere else.
asset_id: number?,
},
}

Expand All @@ -340,6 +353,24 @@ export type BrowseGenreItem = {
track_count: number,
}

-- Typed shape of an item from `AudioScape:browse({ type = "game" }).items[i]`
-- (and from the asset_id reverse lookup). Cast manually, same pattern as
-- BrowseGenreItem above.
export type BrowseGameItem = {
-- Drill-down key: pass back as tostring(universe_id) in the `name`
-- parameter to get this game's tracks.
universe_id: number,
-- Experience title, e.g. "Brookhaven RP".
name: string,
creator_name: string?,
root_place_id: number?,
-- Player count / visit total at the last catalog sync — not live values.
playing: number?,
visits: number?,
-- Catalog tracks heard in this game.
track_count: number,
}

export type BrowseTracksResult = {
tracks: { Track },
meta: {
Expand Down Expand Up @@ -367,6 +398,10 @@ export type SfxBrowseResult = {
export type StructureOptions = {
asset_id: string,
playerId: number?,
-- When true, every section/phrase carries its authored key/value pairs
-- verbatim in `metadata` — cue parameters (lighting amounts, movement
-- paths, easing styles) that the flattened shape otherwise omits.
include_metadata: boolean?,
}

export type BeatGrid = {
Expand All @@ -386,6 +421,10 @@ export type Section = {
bar_end: number?,
bars: number?,
color: string?,
-- Present only when the structure was fetched with include_metadata =
-- true: the entry's authored key/value pairs, verbatim (empty table for
-- entries with none).
metadata: { [string]: string | number }?,
}

export type StructureResult = {
Expand Down Expand Up @@ -1230,13 +1269,14 @@ function AudioScape.browse(
options: BrowseOptions
): (BrowseListResult | BrowseTracksResult | nil, string?)
if type(options.type) ~= "string" or not VALID_BROWSE_TYPES[options.type] then
return nil, "browse requires type to be one of: artist, album, genre, mood, trending"
return nil, "browse requires type to be one of: artist, album, genre, mood, trending, game"
end

-- GET so CloudFront edge-caches.
return requestGet(self, "/v1/browse", {
type = options.type,
name = options.name,
asset_id = options.asset_id,
limit = options.limit,
offset = options.offset,
sort = options.sort,
Expand Down Expand Up @@ -1301,17 +1341,27 @@ function AudioScape.getStructure(

local opts: any = extras or (type(input) == "table" and input or {})

local cached = self._structureCache[assetId]
-- The metadata variant is a distinct payload — cache it under its own
-- key so plain getStructure (and beatAtTime/sectionAtTime, which fetch
-- through it) never serve the heavier shape, and vice versa.
local includeMetadata = opts.include_metadata == true
local cacheKey = if includeMetadata then assetId .. ":meta" else assetId

local cached = self._structureCache[cacheKey]
if cached then
return cached, nil
end

local result, err = request(self, "/v1/track/structure", { asset_id = assetId }, opts.playerId)
local body: { [string]: any } = { asset_id = assetId }
if includeMetadata then
body.include_metadata = true
end
local result, err = request(self, "/v1/track/structure", body, opts.playerId)
if not result then
return nil, err
end

self._structureCache[assetId] = result
self._structureCache[cacheKey] = result
return result, nil
end

Expand Down Expand Up @@ -2377,6 +2427,7 @@ function AudioScape.enableClientAccess(self: AudioScapeInstance)
end
return self:getStructure({
asset_id = options.asset_id,
include_metadata = if options.include_metadata == true then true else nil,
playerId = player.UserId,
})
end)
Expand Down
50 changes: 50 additions & 0 deletions tests/getStructure.spec.luau
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,53 @@ do
"extras.playerId → X-Player-Id header; got: " .. tostring(recorded.headers and recorded.headers["X-Player-Id"])
)
end

-- include_metadata: forwards in the body, forks the cache per variant, and
-- the authored pairs round-trip on sections
do
http:reset()
http:queueResponse({
Success = true,
StatusCode = 200,
Body = serde.encode("json", {
asset_id = "777",
sections = {
{
start = 0.78,
["end"] = 5.64,
label = "light_cue_1",
metadata = { type = "lighting_cluster", amount = 5 },
},
},
phrases = {},
}),
})
local rich, richErr = client:getStructure({ asset_id = "777", include_metadata = true })
assert(rich and not richErr, "metadata variant should succeed; err: " .. tostring(richErr))
assert(#http.requests == 1, "one HTTP call for the metadata variant")
local sent = serde.decode("json", http.requests[1].body)
assert(sent.include_metadata == true, "include_metadata=true forwarded in the body")
assert(
rich.sections[1].metadata and rich.sections[1].metadata.type == "lighting_cluster",
"authored pairs round-trip on the section"
)
assert(rich.sections[1].metadata.amount == 5, "numeric metadata values survive")

-- plain call for the same asset is a cache MISS (different variant) and
-- must not send the flag
http:queueResponse({
Success = true,
StatusCode = 200,
Body = serde.encode("json", { asset_id = "777", sections = {}, phrases = {} }),
})
local plain = client:getStructure({ asset_id = "777" })
assert(plain, "plain variant fetches")
assert(#http.requests == 2, "plain variant is a separate cache entry; got " .. #http.requests)
local sentPlain = serde.decode("json", http.requests[2].body)
assert(sentPlain.include_metadata == nil, "plain call omits the flag from the body")

-- and each variant now serves from its own cache slot
client:getStructure({ asset_id = "777", include_metadata = true })
client:getStructure({ asset_id = "777" })
assert(#http.requests == 2, "both variants cached independently; got " .. #http.requests)
end
25 changes: 24 additions & 1 deletion tests/requestGet.spec.luau
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,34 @@ http:reset()
local rejected, badErr = client:browse({ type = "not-a-type" })
assert(rejected == nil, "bad type rejected without HTTP")
assert(
badErr and badErr:find("artist, album, genre, mood, trending", 1, true),
badErr and badErr:find("artist, album, genre, mood, trending, game", 1, true),
"err lists valid types; got: " .. tostring(badErr)
)
assert(#http.requests == 0, "no HTTP for invalid type")

-- game: accepted type; list mode sends only type
http:reset()
http:queueResponse({ Success = true, StatusCode = 200, Body = "{}" })
local gameResult, gameErr = client:browse({ type = "game" })
assert(gameResult and not gameErr, "browse game should pass pre-flight; err: " .. tostring(gameErr))
recorded = http.requests[1]
assert(recorded.url:find("type=game", 1, true), "type=game in query; got " .. recorded.url)
assert(not recorded.url:find("asset_id", 1, true), "nil asset_id should not appear in URL")

-- game drill-down: universe id travels in `name` as a string
http:reset()
http:queueResponse({ Success = true, StatusCode = 200, Body = "{}" })
client:browse({ type = "game", name = "66654135" })
recorded = http.requests[1]
assert(recorded.url:find("name=66654135", 1, true), "name=<universe_id> in query; got " .. recorded.url)

-- game reverse lookup: asset_id forwards verbatim
http:reset()
http:queueResponse({ Success = true, StatusCode = 200, Body = "{}" })
client:browse({ type = "game", asset_id = "1841647093" })
recorded = http.requests[1]
assert(recorded.url:find("asset_id=1841647093", 1, true), "asset_id in query; got " .. recorded.url)

-- 5xx → server error message. Re-declare locals so luau-lsp doesn't narrow
-- `err` to one specific status string after the first assert.
do
Expand Down
2 changes: 1 addition & 1 deletion wally.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "this-fifo/audioscape-sdk"
version = "0.19.0"
version = "0.20.0"
registry = "https://github.com/UpliftGames/wally-index"
realm = "server"
license = "MIT"
Expand Down