diff --git a/CHANGELOG.md b/CHANGELOG.md index 6aec017..c946add 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 = ""`, or reverse-look-up the games a track has been heard in with `asset_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=`. 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 diff --git a/README.md b/README.md index c33f98c..abfc466 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.19.0" +AudioScape = "this-fifo/audioscape-sdk@0.20.0" ``` Then run: @@ -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 @@ -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. @@ -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. @@ -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)` diff --git a/src/AudioScapeClient.luau b/src/AudioScapeClient.luau index f61e2a6..5ed8087 100644 --- a/src/AudioScapeClient.luau +++ b/src/AudioScapeClient.luau @@ -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 diff --git a/src/init.luau b/src/init.luau index 3c28343..bc68772 100644 --- a/src/init.luau +++ b/src/init.luau @@ -15,6 +15,7 @@ local VALID_BROWSE_TYPES = { genre = true, mood = true, trending = true, + game = true, } local VALID_SFX_BROWSE_TYPES = { @@ -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 = { @@ -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?, @@ -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?, }, } @@ -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: { @@ -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 = { @@ -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 = { @@ -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, @@ -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 @@ -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) diff --git a/tests/getStructure.spec.luau b/tests/getStructure.spec.luau index 72d83f4..afc88b4 100644 --- a/tests/getStructure.spec.luau +++ b/tests/getStructure.spec.luau @@ -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 diff --git a/tests/requestGet.spec.luau b/tests/requestGet.spec.luau index 81208d7..e1b941e 100644 --- a/tests/requestGet.spec.luau +++ b/tests/requestGet.spec.luau @@ -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= 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 diff --git a/wally.toml b/wally.toml index 094cfd9..15d32a6 100644 --- a/wally.toml +++ b/wally.toml @@ -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"