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

## v0.21.0

### Changed

- **`getPlaylist` and `listPlaylists` now use GET** — the requests carry the same parameters in the query string and hit the same endpoint, but GET lets CloudFront cache the response at the edge (60s TTL). When many servers boot against the same playlist at once, they now share one origin request instead of each hitting the API. No call-site changes; POST remains accepted server-side for older SDK versions.

### Fixed

- **SFX sound banks now recognise SFX-only seeds.** `createSoundBank({ kind = "sfx" })` classified its seeds through the music-catalog lookup, so a seed that exists only in the SFX catalog was treated as unknown and its pool collapsed to the seed alone (`source = "none"`). SFX seeds are now classified through `/v1/sfx/similar`, which supplies the pool in the same request, so resolving an SFX bank also makes one request fewer.

## v0.20.1

### Added
Expand Down
4 changes: 2 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.20.1"
AudioScape = "this-fifo/audioscape-sdk@0.21.0"
```

Then run:
Expand Down Expand Up @@ -382,7 +382,7 @@ end

### `AudioScape:getPlaylist(options)`

Fetch a configured playlist and its tracks. Playlists are created in the [Developer Portal](https://developer.audioscape.ai/configure).
Fetch a configured playlist and its tracks. Playlists are created in the [Developer Portal](https://developer.audioscape.ai/configure). Responses are edge-cached for 60 seconds, so many servers fetching the same playlist share one API request; portal edits show up within a minute.

```lua
local result, err = AudioScape:getPlaylist({
Expand Down
65 changes: 50 additions & 15 deletions src/AudioScapeSoundBank.luau
Original file line number Diff line number Diff line change
Expand Up @@ -130,12 +130,41 @@ function AudioScapeSoundBank.new(catalog: CatalogClient, options: SoundBankOptio
-- Count locally and emit one rolled-up event per flush instead.
_pickCounts = {},
_unavailable = {},
-- Neighbours fetched while classifying SFX seeds (see _classifySeeds),
-- so _neighbours doesn't repeat the same /v1/sfx/similar call.
_neighbourCache = {},
}, AudioScapeSoundBank)

return (self :: any) :: SoundBankInstance
end

-- Which seeds does the catalog already know? One request per 100 seeds.
local function neighbourIds(result: any): { string }
local ids: { string } = {}
if result and result.tracks then
for _, track in result.tracks :: { any } do
table.insert(ids, tostring(track.asset_id))
end
end
return ids
end

function AudioScapeSoundBank._neighbourExtras(self: any): { [string]: any }
return {
limit = self._poolSize,
-- Near-identical uploads are the opposite of variation, so drop them.
dedupe = true,
playerId = self._playerId,
}
end

-- Which seeds does the catalog already know?
--
-- Music seeds: one /v1/search lookup per 100 seeds. SFX seeds: that lookup
-- only covers the music catalog, so an SFX-only asset came back unknown and
-- was bridged or dropped even though /v1/sfx/similar knows it. SFX seeds are
-- classified through sfxSimilar instead — a 200 proves the seed is in the SFX
-- catalog and already carries its neighbours (cached for _neighbours); a 404
-- means unknown and falls through to bridging like before.
function AudioScapeSoundBank._classifySeeds(self: any): ({ [string]: boolean }, string?)
local ids: { string } = {}
local seen: { [string]: boolean } = {}
Expand All @@ -148,6 +177,19 @@ function AudioScapeSoundBank._classifySeeds(self: any): ({ [string]: boolean },
end

local known: { [string]: boolean } = {}
if self._kind == "sfx" then
for _, id in ids do
local result, err = self._catalog:sfxSimilar(id, self:_neighbourExtras())
if result then
known[id] = true
self._neighbourCache[id] = neighbourIds(result)
elseif err and not err:find("(404)", 1, true) then
return known, err
end
end
return known, nil
end

for start = 1, #ids, LOOKUP_BATCH_SIZE do
local batch: { string } = {}
for i = start, math.min(start + LOOKUP_BATCH_SIZE - 1, #ids) do
Expand Down Expand Up @@ -217,26 +259,18 @@ function AudioScapeSoundBank._bridgeSeeds(self: any, ids: { string }): { [string
end

function AudioScapeSoundBank._neighbours(self: any, anchorId: string): { string }
local extras = {
limit = self._poolSize,
-- Near-identical uploads are the opposite of variation, so drop them.
dedupe = true,
playerId = self._playerId,
}
local cached = self._neighbourCache[anchorId]
if cached then
return cached
end
local extras = self:_neighbourExtras()
local result
if self._kind == "sfx" then
result = self._catalog:sfxSimilar(anchorId, extras)
else
result = self._catalog:similar(anchorId, extras)
end

local ids: { string } = {}
if result and result.tracks then
for _, track in result.tracks :: { any } do
table.insert(ids, tostring(track.asset_id))
end
end
return ids
return neighbourIds(result)
end

-- Resolve every seed into a pool. Safe to call more than once; it rebuilds.
Expand All @@ -247,6 +281,7 @@ end
-- 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.resolveAsync(self: any): (boolean, string?)
self._neighbourCache = {}
local known, err = self:_classifySeeds()
if err then
return false, err
Expand Down
18 changes: 8 additions & 10 deletions src/init.luau
Original file line number Diff line number Diff line change
Expand Up @@ -735,7 +735,8 @@ local function buildQuery(params: { [string]: any }): string
end

-- GET sibling of request() for endpoints CloudFront edge-caches
-- (browse, sfxBrowse, sfxTaxonomy, similar, sfxSimilar). Flagged as a v1.0.0
-- (browse, sfxBrowse, sfxTaxonomy, similar, sfxSimilar, getPlaylist,
-- listPlaylists). Flagged as a v1.0.0
-- cleanup candidate to fold both into one method-aware helper.
local function requestGet(
self: AudioScapeInstance,
Expand Down Expand Up @@ -1313,19 +1314,16 @@ function AudioScape.getPlaylist(self: AudioScapeInstance, options: PlaylistOptio
return nil, "getPlaylist requires a non-empty playlist_id string"
end

local body: { [string]: any } = {
-- GET so CloudFront can serve repeat fetches of the same playlist from
-- the edge (60s TTL): every server booting against the same playlist_id
-- shares one origin request instead of each hitting the API.
return requestGet(self, "/v1/playlist", {
playlist_id = options.playlist_id,
}

return request(self, "/v1/playlist", body, options.playerId)
}, options.playerId)
end

function AudioScape.listPlaylists(self: AudioScapeInstance, playerId: number?): (ListPlaylistsResult?, string?)
local body: { [string]: any } = {
action = "list",
}

return request(self, "/v1/playlist", body, playerId)
return requestGet(self, "/v1/playlist", { action = "list" }, playerId)
end

--[[ ──────────────────────────────────────────
Expand Down
80 changes: 80 additions & 0 deletions tests/getPlaylist.spec.luau
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
--!strict
-- getPlaylist / listPlaylists go over GET (v0.21.0) so CloudFront can cache
-- them at the edge. Covers the wire format: method, path, query string,
-- headers, no JSON body, and the pre-flight playlist_id validation.

local serde = require("@lune/serde")
local mockHttp = require("./_harness/mockHttp")
local loadSdk = require("./_harness/loadSdk").loadSdk

local http = mockHttp.new()
local sdk = loadSdk({ http = http })
local client = (sdk :: any).new("test-key")

-- getPlaylist: GET /v1/playlist?playlist_id=...
http:queueResponse({
Success = true,
StatusCode = 200,
Body = serde.encode("json", {
playlist = { id = "kpop", name = "K-Pop", genre = "pop", playback_mode = "shuffle", track_count = 0 },
tracks = {},
meta = {},
}),
})
local result, err = client:getPlaylist({ playlist_id = "kpop", playerId = 12345 })
assert(result and not err, "getPlaylist should succeed; err: " .. tostring(err))
assert(#http.requests == 1, "exactly one HTTP call")

local recorded = http.requests[1]
assert(recorded.method == "GET", "getPlaylist uses GET, got " .. recorded.method)
assert(recorded.url:find("/v1/playlist?", 1, true), "URL is /v1/playlist with a query string; got " .. recorded.url)
assert(recorded.url:find("playlist_id=kpop", 1, true), "playlist_id in query; got " .. recorded.url)
assert(recorded.body == nil, "GET sends no body")
assert(recorded.headers and recorded.headers["x-api-key"] == "test-key", "x-api-key set")
assert(recorded.headers and recorded.headers["X-Player-Id"] == "12345", "X-Player-Id forwarded")
assert(recorded.headers and recorded.headers["Content-Type"] == nil, "no Content-Type on GET")

-- playlist_id is URL-encoded, not JSON-encoded
http:reset()
http:queueResponse({ Success = true, StatusCode = 200, Body = "{}" })
client:getPlaylist({ playlist_id = "top100 weekly/theblock" })
recorded = http.requests[1]
assert(
recorded.url:find("playlist_id=top100%20weekly%2Ftheblock", 1, true),
"playlist_id url-encoded; got " .. recorded.url
)

-- pre-flight: empty / non-string playlist_id rejected without HTTP
http:reset()
local rejected, badErr = client:getPlaylist({ playlist_id = "" })
assert(
rejected == nil and badErr and badErr:find("non-empty playlist_id", 1, true),
"empty id rejected; got " .. tostring(badErr)
)
assert(#http.requests == 0, "no HTTP for invalid playlist_id")

-- listPlaylists: GET /v1/playlist?action=list
http:reset()
http:queueResponse({ Success = true, StatusCode = 200, Body = serde.encode("json", { playlists = {}, meta = {} }) })
local list, listErr = client:listPlaylists(777)
assert(list and not listErr, "listPlaylists should succeed; err: " .. tostring(listErr))
recorded = http.requests[1]
assert(recorded.method == "GET", "listPlaylists uses GET, got " .. recorded.method)
assert(recorded.url:find("/v1/playlist?action=list", 1, true), "action=list in query; got " .. recorded.url)
assert(recorded.body == nil, "GET sends no body")
assert(recorded.headers and recorded.headers["X-Player-Id"] == "777", "X-Player-Id forwarded")

-- listPlaylists without a player: header omitted
http:reset()
http:queueResponse({ Success = true, StatusCode = 200, Body = "{}" })
client:listPlaylists()
recorded = http.requests[1]
assert(recorded.headers and recorded.headers["X-Player-Id"] == nil, "no X-Player-Id without a player")

-- 5xx still maps to the shared server-error message
do
http:reset()
http:queueResponse({ Success = false, StatusCode = 503, Body = "" })
local r, e = client:getPlaylist({ playlist_id = "kpop" })
assert(r == nil and e == "Server error — try again later (503)", "got: " .. tostring(e))
end
24 changes: 13 additions & 11 deletions tests/soundBank.spec.luau
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
--!strict
-- Unit tests for AudioScapeSoundBank.
-- Unit tests for AudioScapeSoundBank (music-kind banks: /v1/search lookup
-- then similar; SFX-kind classification lives in soundBankSfx.spec.luau).
--
-- The bridging path (GetAudioMetadataAsync for seeds our catalog doesn't
-- hold) needs a real engine and is covered in the Open Cloud smoke instead;
Expand Down Expand Up @@ -55,7 +56,7 @@ do
http:queueResponse(lookupResponse({ "111" }))
http:queueResponse(similarResponse({ "222", "333", "444" }))

local bank = AudioScape:createSoundBank({ seeds = { footstep = "rbxassetid://111" } })
local bank = AudioScape:createSoundBank({ seeds = { footstep = "rbxassetid://111" }, kind = "music" })
local ok, err = bank:resolveAsync()
assert(ok, "resolve failed: " .. tostring(err))

Expand All @@ -75,6 +76,7 @@ do
local bank = AudioScape:createSoundBank({
seeds = { footstep = "rbxassetid://111" },
mode = "replace",
kind = "music",
})
bank:resolveAsync()

Expand All @@ -91,7 +93,7 @@ do
http:queueResponse(lookupResponse({ "111" }))
http:queueResponse(similarResponse({ "222", "333", "444", "555", "666" }))

local bank = AudioScape:createSoundBank({ seeds = { s = "111" }, poolSize = 3 })
local bank = AudioScape:createSoundBank({ seeds = { s = "111" }, poolSize = 3, kind = "music" })
bank:resolveAsync()
assert(#bank:getPool("s") == 3, "pool capped at poolSize; got " .. tostring(#bank:getPool("s")))
end
Expand All @@ -103,7 +105,7 @@ do
-- the mock has no AssetService for, so it pcalls out and yields no anchor.
http:queueResponse(lookupResponse({}))

local bank = AudioScape:createSoundBank({ seeds = { s = "999" }, mode = "replace" })
local bank = AudioScape:createSoundBank({ seeds = { s = "999" }, mode = "replace", kind = "music" })
local ok = bank:resolveAsync()
assert(ok, "resolve succeeds even when nothing can be expanded")

Expand All @@ -118,7 +120,7 @@ do
http:queueResponse(lookupResponse({ "111" }))
http:queueResponse(similarResponse({ "222", "333" }))

local bank = AudioScape:createSoundBank({ seeds = { s = "111" } })
local bank = AudioScape:createSoundBank({ seeds = { s = "111" }, kind = "music" })
bank:resolveAsync()

local previous: string? = nil
Expand All @@ -135,7 +137,7 @@ do
local AudioScape, http = newSdk()
http:queueResponse(lookupResponse({}))

local bank = AudioScape:createSoundBank({ seeds = { s = "999" } })
local bank = AudioScape:createSoundBank({ seeds = { s = "999" }, kind = "music" })
bank:resolveAsync()
assert(bank:pick("s") == "999", "single-asset pool returns the only asset")
assert(bank:pick("s") == "999", "and keeps returning it rather than nil")
Expand All @@ -145,7 +147,7 @@ end
do
local AudioScape, http = newSdk()
http:queueResponse(lookupResponse({}))
local bank = AudioScape:createSoundBank({ seeds = { s = "1" } })
local bank = AudioScape:createSoundBank({ seeds = { s = "1" }, kind = "music" })
bank:resolveAsync()
assert(bank:pick("nope") == nil, "unknown pool name yields nil")
assert(#bank:getPool("nope") == 0, "unknown pool yields an empty list")
Expand All @@ -157,7 +159,7 @@ do
http:queueResponse(lookupResponse({ "111" }))
http:queueResponse(similarResponse({ "222", "333" }))

local bank = AudioScape:createSoundBank({ seeds = { s = "111" } })
local bank = AudioScape:createSoundBank({ seeds = { s = "111" }, kind = "music" })
bank:resolveAsync()

assert(bank:reportUnavailable("222"), "first report returns true")
Expand All @@ -174,7 +176,7 @@ do
http:queueResponse(lookupResponse({ "111" }))
http:queueResponse(similarResponse({ "222" }))

local bank = AudioScape:createSoundBank({ seeds = { s = "111" } })
local bank = AudioScape:createSoundBank({ seeds = { s = "111" }, kind = "music" })
bank:resolveAsync()
bank:reportUnavailable("111")
bank:reportUnavailable("222")
Expand All @@ -188,7 +190,7 @@ do
http:queueResponse(lookupResponse({ "111" }))
http:queueResponse(similarResponse({ "222" }))

local bank = AudioScape:createSoundBank({ seeds = { s = "111" } })
local bank = AudioScape:createSoundBank({ seeds = { s = "111" }, kind = "music" })
bank:resolveAsync()

-- resolveAsync emits one audio_resolve event per seed.
Expand Down Expand Up @@ -283,7 +285,7 @@ do
http:queueResponse(lookupResponse({ "111" })) -- seed IS in the catalog
http:queueResponse(similarResponse({ "222", "333" }))

local bank = AudioScape:createSoundBank({ seeds = { s = "111" }, mode = "replace" })
local bank = AudioScape:createSoundBank({ seeds = { s = "111" }, mode = "replace", kind = "music" })
bank:resolveAsync()

assert(bank.Pools.s.source == "catalog", "resolved from catalog")
Expand Down
Loading