diff --git a/CHANGELOG.md b/CHANGELOG.md index cda3a58..c187430 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## v0.22.0 + +### Added + +- **Seed lists on `similar` and `sfxSimilar`** — pass a list instead of one asset (`AudioScape:similar(playlist.tracks, { limit = 20 })`, or `{ asset_ids = { ... }, limit = 20 }`) and results sound like the set as a whole rather than any one track: playlist continuation for music, variety packs for SFX. Each element can be anything the single-seed form accepts (asset_id string, Track, `Sound`, `AudioPlayer`); up to 100 seeds; seeds are never returned. `result.meta.seeds_used` counts the seeds that resolved and `result.meta.seeds_ignored` lists the ids skipped (unknown, not yet analyzed, or not public). Available through `AudioScapeClient.similar` / `sfxSimilar` too. + +### Fixed + +- **`AudioScapeClient.similar` and `sfxSimilar` now honour `max_score` and `dedupe`.** The client methods sent both, but the server-side remotes dropped them before calling the API, so near-duplicate filtering never applied to client-initiated calls. + ## v0.21.0 ### Changed diff --git a/README.md b/README.md index f880b98..cae718d 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.21.0" +AudioScape = "this-fifo/audioscape-sdk@0.22.0" ``` Then run: @@ -168,7 +168,8 @@ local result, err = AudioScape:lookup({ Find tracks that sound similar to a given track. `input` is anything that carries an asset_id — a string, a Track from a previous response, a `Sound`, -or an `AudioPlayer`. +or an `AudioPlayer` — or a list of those (up to 100), in which case results +sound like the set as a whole. ```lua local result, err = AudioScape:similar({ @@ -188,6 +189,13 @@ local result = AudioScape:similar(playlist.tracks[1], { limit = 10 }) -- Shorthand: pass a playing Sound directly local result = AudioScape:similar(soundInstance) + +-- Playlist continuation: pass a list (strings, Tracks, Sounds) and get +-- tracks that sound like the whole set. Seeds are never returned; page with +-- `offset` to build the next playlist. +local result = AudioScape:similar(playlist.tracks, { limit = 20 }) +-- result.meta.seeds_used -- seeds that resolved and were blended +-- result.meta.seeds_ignored -- ids skipped: unknown, not yet analyzed, or not public ``` ### `AudioScape:browse(options)` @@ -291,7 +299,8 @@ local result, err = AudioScape:sfxSearch({ ### `AudioScape:sfxSimilar(input, extras?)` Find sound effects acoustically similar to a given asset. Same polymorphic -input as `AudioScape:similar`. +input as `AudioScape:similar`, including a list of seeds to grow a variety +pack from the clips you already picked. ```lua local result, err = AudioScape:sfxSimilar({ @@ -308,6 +317,9 @@ local result, err = AudioScape:sfxSimilar({ -- Or pass an SfxTrack / Sound / asset_id string: local result = AudioScape:sfxSimilar(sfx.tracks[1], { limit = 10 }) + +-- Or a list of seeds — results sound like the set as a whole: +local result = AudioScape:sfxSimilar({ "9083837523", "18672006515" }, { limit = 10 }) ``` ### `AudioScape:getSfxTaxonomy()` diff --git a/src/AudioScapeClient.luau b/src/AudioScapeClient.luau index 5ed8087..3c0ee00 100644 --- a/src/AudioScapeClient.luau +++ b/src/AudioScapeClient.luau @@ -38,6 +38,11 @@ end -- beyond asset_id) typecheck under strict mode. export type AssetIdResolvable = string | { asset_id: string, [string]: any } | Sound | AudioPlayer +-- Seed-list forms of similar / sfxSimilar (mirrors src/init.luau): an array +-- of AssetIdResolvable, or an options table carrying `asset_ids`. +export type SeedList = { AssetIdResolvable } +export type SimilarInput = AssetIdResolvable | SeedList | { asset_ids: SeedList, [string]: any } + type AudioScapeClientInstance = typeof(setmetatable( {} :: { -- Optional because the module itself starts unresolved and fills this @@ -110,6 +115,35 @@ local function resolveAssetId(input: any): string? return nil end +-- Mirrored from src/init.luau (see resolveSeedIds there): (nil, nil) when +-- `input` is not a seed list, (nil, reason) when an element doesn't resolve, +-- (ids, nil) otherwise. +local function resolveSeedIds(input: any): ({ string }?, string?) + if type(input) ~= "table" or input.asset_id ~= nil then + return nil, nil + end + local list: any + if type(input.asset_ids) == "table" then + list = input.asset_ids + elseif #input > 0 then + list = input + else + return nil, nil + end + local ids: { string } = {} + for i, item in ipairs(list) do + local id = resolveAssetId(item) + if not id then + return nil, string.format("asset_ids[%d] is not an asset_id string, a track table, a Sound, or an AudioPlayer", i) + end + table.insert(ids, id) + end + if #ids == 0 then + return nil, "asset_ids must be a non-empty list" + end + return ids, nil +end + -- Search the catalog. Pass `query` for free-text/natural-language search, or -- `asset_ids` for a direct batch lookup of up to 100 IDs (filters bypassed, -- results in input order, missing IDs surfaced in `meta.missing_ids`). @@ -138,13 +172,14 @@ function AudioScapeClient.lookup(self: AudioScapeClientInstance, options: { asse end -- Find tracks that sound similar to a given track. Polymorphic input — --- pass an asset_id string, a Track table, a Sound, or an AudioPlayer; pass --- `extras` when you want limit/offset/filters and didn't use the legacy --- options-table form. `max_score`/`dedupe` filter near-duplicate matches --- of the seed track from the results. +-- pass an asset_id string, a Track table, a Sound, or an AudioPlayer, or a +-- list of those to get tracks that sound like the whole set; pass `extras` +-- when you want limit/offset/filters and didn't use the legacy options-table +-- form. `max_score`/`dedupe` filter near-duplicate matches of the seed track +-- from the results. function AudioScapeClient.similar( self: AudioScapeClientInstance, - input: AssetIdResolvable, + input: SimilarInput, extras: { limit: number?, offset: number?, @@ -153,13 +188,18 @@ function AudioScapeClient.similar( dedupe: boolean?, }? ): (any, string?) - local assetId = resolveAssetId(input) - if not assetId then - return nil, "similar requires an asset_id string, a track table, a Sound, or an AudioPlayer" + local seedIds, seedErr = resolveSeedIds(input) + if seedErr then + return nil, "similar " .. seedErr + end + local assetId = if seedIds then nil else resolveAssetId(input) + if not seedIds and not assetId then + return nil, "similar requires an asset_id string, a track table, a Sound, or an AudioPlayer, or a list of them" end local opts: any = extras or (type(input) == "table" and input or {}) return invoke(getRemotes(self), "Similar", { asset_id = assetId, + asset_ids = seedIds, limit = opts.limit, offset = opts.offset, filters = opts.filters, @@ -182,7 +222,7 @@ end -- SFX libraries with many uploads of the same generic clip. function AudioScapeClient.sfxSimilar( self: AudioScapeClientInstance, - input: AssetIdResolvable, + input: SimilarInput, extras: { limit: number?, offset: number?, @@ -191,13 +231,18 @@ function AudioScapeClient.sfxSimilar( dedupe: boolean?, }? ): (any, string?) - local assetId = resolveAssetId(input) - if not assetId then - return nil, "sfxSimilar requires an asset_id string, a track table, a Sound, or an AudioPlayer" + local seedIds, seedErr = resolveSeedIds(input) + if seedErr then + return nil, "sfxSimilar " .. seedErr + end + local assetId = if seedIds then nil else resolveAssetId(input) + if not seedIds and not assetId then + return nil, "sfxSimilar requires an asset_id string, a track table, a Sound, or an AudioPlayer, or a list of them" end local opts: any = extras or (type(input) == "table" and input or {}) return invoke(getRemotes(self), "SfxSimilar", { asset_id = assetId, + asset_ids = seedIds, limit = opts.limit, offset = opts.offset, filters = opts.filters, diff --git a/src/init.luau b/src/init.luau index fa6dec1..e8c679b 100644 --- a/src/init.luau +++ b/src/init.luau @@ -77,7 +77,10 @@ export type LookupOptions = { } export type SimilarOptions = { - asset_id: string, + asset_id: string?, + -- Seed list (up to 100): results sound like the set as a whole. Use + -- instead of asset_id. + asset_ids: { AssetIdResolvable }?, limit: number?, offset: number?, playerId: number?, @@ -88,6 +91,8 @@ export type SimilarOptions = { max: number?, }?, }?, + max_score: number?, + dedupe: boolean?, } export type SfxSearchOptions = { @@ -111,7 +116,10 @@ export type SfxSearchOptions = { } export type SfxSimilarOptions = { - asset_id: string, + asset_id: string?, + -- Seed list (up to 100): results sound like the set as a whole. Use + -- instead of asset_id. + asset_ids: { AssetIdResolvable }?, limit: number?, offset: number?, playerId: number?, @@ -123,6 +131,8 @@ export type SfxSimilarOptions = { max: number?, }?, }?, + max_score: number?, + dedupe: boolean?, } -- Anything you can extract an asset_id from. The table arm uses an open @@ -131,6 +141,14 @@ export type SfxSimilarOptions = { -- strict mode without a sealed-table mismatch. export type AssetIdResolvable = string | { asset_id: string, [string]: any } | Sound | AudioPlayer +-- Seed-list forms of similar / sfxSimilar: an array of anything +-- AssetIdResolvable (`{ track1, track2 }`, a playlist's tracks), or the +-- options table carrying `asset_ids`. The API blends the seeds into one +-- acoustic profile. +export type SeedList = { AssetIdResolvable } +export type SimilarInput = AssetIdResolvable | SeedList | SimilarOptions +export type SfxSimilarInput = AssetIdResolvable | SeedList | SfxSimilarOptions + -- "Extras" mirror the legacy *Options shapes minus asset_id. Used as the -- optional second positional arg in the polymorphic forms — when you pass a -- Track / Sound / string and still want limit/offset/filters/playerId. @@ -260,6 +278,10 @@ export type SimilarResult = { total: number, limit: number, offset: number, + -- asset_ids requests only: seeds that resolved, and the ids skipped + -- (unknown, not yet analyzed, or not public). + seeds_used: number?, + seeds_ignored: { string }?, }, } @@ -297,6 +319,10 @@ export type SfxSimilarResult = { total: number, limit: number, offset: number, + -- asset_ids requests only: seeds that resolved, and the ids skipped + -- (unknown, not yet analyzed, or not public). + seeds_used: number?, + seeds_ignored: { string }?, }, } @@ -670,6 +696,39 @@ local function resolveAssetId(input: any): string? return nil end +-- Seed-list arm of similar / sfxSimilar: an array of AssetIdResolvable +-- (`{ "123", track, sound }`) or an options table carrying `asset_ids`. +-- Returns (nil, nil) when `input` is not a seed list at all, so the caller +-- falls through to the single-seed arms; (nil, reason) when it is one but an +-- element doesn't resolve; (ids, nil) otherwise. Checked before +-- resolveAssetId, which returns nil for any table without `asset_id`. +-- Mirrored in src/AudioScapeClient.luau, same trade-off as resolveAssetId. +local function resolveSeedIds(input: any): ({ string }?, string?) + if type(input) ~= "table" or input.asset_id ~= nil then + return nil, nil + end + local list: any + if type(input.asset_ids) == "table" then + list = input.asset_ids + elseif #input > 0 then + list = input + else + return nil, nil + end + local ids: { string } = {} + for i, item in ipairs(list) do + local id = resolveAssetId(item) + if not id then + return nil, string.format("asset_ids[%d] is not an asset_id string, a track table, a Sound, or an AudioPlayer", i) + end + table.insert(ids, id) + end + if #ids == 0 then + return nil, "asset_ids must be a non-empty list" + end + return ids, nil +end + local function request( self: AudioScapeInstance, path: string, @@ -1189,25 +1248,34 @@ function AudioScape.lookup(self: AudioScapeInstance, options: LookupOptions): (S end -- Polymorphic input: pass an asset_id string, a Track-shaped table, a Sound, --- or an AudioPlayer. `extras` carries limit/offset/filters/playerId; if you --- pass the legacy options table (`{ asset_id, limit, ... }`) those fields are --- read from the input itself when `extras` is omitted. +-- or an AudioPlayer — or a list of those (`playlist.tracks`) to get tracks +-- that sound like the whole set. `extras` carries limit/offset/filters/ +-- playerId; if you pass the legacy options table (`{ asset_id, limit, ... }` +-- or `{ asset_ids, limit, ... }`) those fields are read from the input itself +-- when `extras` is omitted. function AudioScape.similar( self: AudioScapeInstance, - input: AssetIdResolvable, + input: SimilarInput, extras: SimilarExtras? ): (SimilarResult?, string?) - local assetId = resolveAssetId(input) - if not assetId then - return nil, "similar requires an asset_id string, a track table, a Sound, or an AudioPlayer" + local seedIds, seedErr = resolveSeedIds(input) + if seedErr then + return nil, "similar " .. seedErr + end + local assetId = if seedIds then nil else resolveAssetId(input) + if not seedIds and not assetId then + return nil, "similar requires an asset_id string, a track table, a Sound, or an AudioPlayer, or a list of them" end local opts: any = extras or (type(input) == "table" and input or {}) -- GET so CloudFront edge-caches. dedupe=false is omitted (no point - -- sending the no-op default — keeps the cache key clean). + -- sending the no-op default — keeps the cache key clean). A seed list + -- ships as one comma-separated param: the API takes a CSV, and buildQuery + -- would JSON-encode a table. return requestGet(self, "/v1/similar", { asset_id = assetId, + asset_ids = if seedIds then table.concat(seedIds, ",") else nil, limit = opts.limit, offset = opts.offset, filters = opts.filters, @@ -1249,20 +1317,26 @@ end -- input — see similar() for the (input, extras?) contract. function AudioScape.sfxSimilar( self: AudioScapeInstance, - input: AssetIdResolvable, + input: SfxSimilarInput, extras: SfxSimilarExtras? ): (SfxSimilarResult?, string?) - local assetId = resolveAssetId(input) - if not assetId then - return nil, "sfxSimilar requires an asset_id string, a track table, a Sound, or an AudioPlayer" + local seedIds, seedErr = resolveSeedIds(input) + if seedErr then + return nil, "sfxSimilar " .. seedErr + end + local assetId = if seedIds then nil else resolveAssetId(input) + if not seedIds and not assetId then + return nil, "sfxSimilar requires an asset_id string, a track table, a Sound, or an AudioPlayer, or a list of them" end local opts: any = extras or (type(input) == "table" and input or {}) -- GET so CloudFront edge-caches. dedupe=false is omitted (no point - -- sending the no-op default — keeps the cache key clean). + -- sending the no-op default — keeps the cache key clean). Seed list as a + -- CSV, same as similar. return requestGet(self, "/v1/sfx/similar", { asset_id = assetId, + asset_ids = if seedIds then table.concat(seedIds, ",") else nil, limit = opts.limit, offset = opts.offset, filters = opts.filters, @@ -2308,14 +2382,17 @@ function AudioScape.enableClientAccess(self: AudioScapeInstance) if not checkRateLimit(player) then return nil, "Rate limited — please wait before making another request" end - if type(options) ~= "table" or type(options.asset_id) ~= "string" then - return nil, "similar requires a table with an asset_id string" + if type(options) ~= "table" or (type(options.asset_id) ~= "string" and type(options.asset_ids) ~= "table") then + return nil, "similar requires a table with an asset_id string or an asset_ids list" end return self:similar({ asset_id = options.asset_id, + asset_ids = options.asset_ids, limit = options.limit, offset = options.offset, filters = options.filters, + max_score = options.max_score, + dedupe = options.dedupe, playerId = player.UserId, }) end) @@ -2355,14 +2432,17 @@ function AudioScape.enableClientAccess(self: AudioScapeInstance) if not checkRateLimit(player) then return nil, "Rate limited — please wait before making another request" end - if type(options) ~= "table" or type(options.asset_id) ~= "string" then - return nil, "sfxSimilar requires a table with an asset_id string" + if type(options) ~= "table" or (type(options.asset_id) ~= "string" and type(options.asset_ids) ~= "table") then + return nil, "sfxSimilar requires a table with an asset_id string or an asset_ids list" end return self:sfxSimilar({ asset_id = options.asset_id, + asset_ids = options.asset_ids, limit = options.limit, offset = options.offset, filters = options.filters, + max_score = options.max_score, + dedupe = options.dedupe, playerId = player.UserId, }) end) diff --git a/tests/sfxSimilar.spec.luau b/tests/sfxSimilar.spec.luau index c4597c1..34f7c45 100644 --- a/tests/sfxSimilar.spec.luau +++ b/tests/sfxSimilar.spec.luau @@ -186,3 +186,42 @@ do local q = decodeQuery(http.requests[1].url) assert(q.max_score == "0.95", "extras.max_score wins; got: " .. tostring(q.max_score)) end + +-- 11. Seed list: array input → asset_ids CSV on the wire, no asset_id +do + local http = mockHttp.new() + local sdk = loadSdk({ http = http }) + local client = (sdk :: any).new("test-key") + + http:queueResponse(emptyResponse()) + -- Mixed list (string + SfxTrack-shaped table): annotate so strict mode + -- doesn't infer `{ string }` from the first element. + local seeds: { any } = { "9083837523", { asset_id = "18672006515" } } + local result, err = client:sfxSimilar(seeds, { limit = 10 }) + assert(result and not err, "seed list should succeed; err: " .. tostring(err)) + + local q = decodeQuery(http.requests[1].url) + assert( + q.asset_ids == "9083837523,18672006515", + "seed list → asset_ids CSV on the wire; got: " .. tostring(q.asset_ids) + ) + assert(q.asset_id == nil, "seed list sends no asset_id; got: " .. tostring(q.asset_id)) + assert(q.limit == "10", "extras.limit carried with a seed list; got: " .. tostring(q.limit)) +end + +-- 12. Seed list: legacy options-table form; bad element rejected pre-flight +do + local http = mockHttp.new() + local sdk = loadSdk({ http = http }) + local client = (sdk :: any).new("test-key") + + http:queueResponse(emptyResponse()) + client:sfxSimilar({ asset_ids = { "1", "2" }, limit = 3, filters = { categories = { "foley" } } }) + local q = decodeQuery(http.requests[1].url) + assert(q.asset_ids == "1,2", "options-table asset_ids → CSV; got: " .. tostring(q.asset_ids)) + assert(q.filters and q.filters:find("foley", 1, true), "options-table filters carried; got: " .. tostring(q.filters)) + + local r, e = client:sfxSimilar({ "1", "" }) + assert(r == nil and e and e:find("asset_ids[2]", 1, true), "empty-string element named by index; got: " .. tostring(e)) + assert(#http.requests == 1, "no HTTP for an invalid seed list") +end diff --git a/tests/similar.spec.luau b/tests/similar.spec.luau index bb12470..0689459 100644 --- a/tests/similar.spec.luau +++ b/tests/similar.spec.luau @@ -227,3 +227,81 @@ do assert(q.max_score == "0.95", "extras.max_score wins; got: " .. tostring(q.max_score)) assert(q.dedupe == "true", "extras.dedupe carried alongside max_score; got: " .. tostring(q.dedupe)) end + +-- 13. Seed list: array of strings → asset_ids CSV on the wire, no asset_id +do + local http = mockHttp.new() + local sdk = loadSdk({ http = http }) + local client = (sdk :: any).new("test-key") + + http:queueResponse(emptyResponse()) + local result, err = client:similar({ "1234", "rbxassetid://5678" }, { limit = 20 }) + assert(result and not err, "seed list should succeed; err: " .. tostring(err)) + assert(#http.requests == 1, "exactly one HTTP call for a seed list") + + local q = decodeQuery(http.requests[1].url) + assert(q.asset_ids == "1234,5678", "seed list → asset_ids=1234,5678 on the wire; got: " .. tostring(q.asset_ids)) + assert(q.asset_id == nil, "seed list sends no asset_id; got: " .. tostring(q.asset_id)) + assert(q.limit == "20", "extras.limit carried with a seed list; got: " .. tostring(q.limit)) +end + +-- 14. Seed list: array of Track tables (a playlist's tracks) +do + local http = mockHttp.new() + local sdk = loadSdk({ http = http }) + local client = (sdk :: any).new("test-key") + + http:queueResponse(emptyResponse()) + client:similar({ { asset_id = "11", name = "A" }, { asset_id = "22", name = "B" } }) + + local q = decodeQuery(http.requests[1].url) + assert(q.asset_ids == "11,22", "Track tables → asset_ids=11,22; got: " .. tostring(q.asset_ids)) +end + +-- 15. Seed list: legacy options-table form carries limit/filters/playerId +do + local http = mockHttp.new() + local sdk = loadSdk({ http = http }) + local client = (sdk :: any).new("test-key") + + http:queueResponse(emptyResponse()) + client:similar({ asset_ids = { "33", "44" }, limit = 5, playerId = 42, filters = { genres = { "rock" } } }) + + local recorded = http.requests[1] + local q = decodeQuery(recorded.url) + assert(q.asset_ids == "33,44", "options-table asset_ids → CSV; got: " .. tostring(q.asset_ids)) + assert(q.asset_id == nil, "options-table seed list sends no asset_id; got: " .. tostring(q.asset_id)) + assert(q.limit == "5", "options-table limit carried; got: " .. tostring(q.limit)) + assert(q.filters and q.filters:find("rock", 1, true), "options-table filters carried; got: " .. tostring(q.filters)) + assert( + recorded.headers and recorded.headers["X-Player-Id"] == "42", + "options-table playerId → X-Player-Id; got: " .. tostring(recorded.headers and recorded.headers["X-Player-Id"]) + ) +end + +-- 16. Validation: an element that doesn't resolve → pre-flight error naming it, no HTTP +do + local http = mockHttp.new() + local sdk = loadSdk({ http = http }) + local client = (sdk :: any).new("test-key") + + -- Mixed list: strict mode infers the array type from the first element, + -- so annotate to test the runtime check rather than the type checker. + local seeds: { any } = { "1234", { name = "no id" } } + local r, e = client:similar(seeds) + assert(r == nil, "bad element → nil result") + assert(e and e:find("asset_ids[2]", 1, true), "error names the offending index; got: " .. tostring(e)) + assert(#http.requests == 0, "no HTTP for an invalid seed list") +end + +-- 17. Validation: empty asset_ids +do + local http = mockHttp.new() + local sdk = loadSdk({ http = http }) + local client = (sdk :: any).new("test-key") + + local r, e = client:similar({ asset_ids = {} }) + assert(r == nil, "empty list → nil result") + assert(e and e:find("non-empty", 1, true), "empty list rejected pre-flight; got: " .. tostring(e)) + assert(#http.requests == 0, "no HTTP for an empty seed list") +end diff --git a/wally.toml b/wally.toml index 01fdfa5..01c3c77 100644 --- a/wally.toml +++ b/wally.toml @@ -1,6 +1,6 @@ [package] name = "this-fifo/audioscape-sdk" -version = "0.21.0" +version = "0.22.0" registry = "https://github.com/UpliftGames/wally-index" realm = "server" license = "MIT"