From f7a3a3b95c66bb904f4dffcd6a770ff441e9a024 Mon Sep 17 00:00:00 2001 From: svarah Date: Fri, 22 May 2026 08:02:51 -0700 Subject: [PATCH 1/2] =?UTF-8?q?v0.15.0=20=E2=80=94=20migrate=20AudioScapeM?= =?UTF-8?q?usicPlayer=20to=20new=20Roblox=20Audio=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces legacy `Sound` playback with `AudioPlayer` + `AudioDeviceOutput` + `Wire`. Public method surface unchanged (queue/setQueue/clearQueue/play/ stop/skip/setVolume/setPlayerId/playTrack/destroy + OnTrackChanged / OnQueueFinished callbacks + NowPlaying/IsPlaying/Queue fields). New: - player:getAudioPlayer() returns the live AudioPlayer Instance (or nil) for TweenService-driven volume animation and custom effect-chain wiring. - PlayerOptions.output accepts a caller-provided AudioDeviceOutput or AudioEmitter for spatial / custom-routing setups; auto-created AudioDeviceOutput remains the default. Tests: - tests/musicPlayer.spec.luau — 7 lifecycle scenarios with a fake analytics client and extended mock Instance (Play/Stop/Destroy + Ended signal + created-instance recorder). - tests/openCloud/smokeScript.luau — createPlayer step extended from a 4-line existence check to a full real-engine lifecycle check (Asset, IsPlaying, TimePosition advance, Wire wiring, setVolume propagation, stop/destroy teardown). Confirmed working: Lune 17/17, stylua, selene, luau-lsp analyze all clean; Roblox Studio MCP smoke confirms TimePosition advances and Wire routes AudioPlayer -> AudioDeviceOutput; live ear-test confirms audible playback and natural Ended -> next-track advancement. Sean (OffGridDude) --- CHANGELOG.md | 16 ++ README.md | 2 +- src/AudioScapeMusicPlayer.luau | 112 +++++++++---- tests/_harness/loadSdk.luau | 4 +- tests/_harness/mockGame.luau | 69 ++++++-- tests/musicPlayer.spec.luau | 263 +++++++++++++++++++++++++++++++ tests/openCloud/smokeScript.luau | 58 ++++++- wally.toml | 2 +- 8 files changed, 475 insertions(+), 51 deletions(-) create mode 100644 tests/musicPlayer.spec.luau diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d8c2fc..c6e8512 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## v0.15.0 + +### Changed + +- **`AudioScapeMusicPlayer` now uses the new Roblox Audio API (`AudioPlayer` + `AudioDeviceOutput` + `Wire`) instead of legacy `Sound`.** Public method surface is unchanged — `queue`, `setQueue`, `clearQueue`, `play`, `stop`, `skip`, `setVolume`, `setPlayerId`, `playTrack`, `destroy`, the `OnTrackChanged` / `OnQueueFinished` callbacks, and the `NowPlaying` / `IsPlaying` / `Queue` fields all behave the same. The internal `AudioPlayer` is now reachable via the new `player:getAudioPlayer()` accessor so consumers can wire effect chains or drive volume tweens through `TweenService`. + +### Added + +- **`player:getAudioPlayer()`** — returns the current `AudioPlayer` Instance, or `nil` if not playing. Use for `TweenService`-driven volume animation (e.g. lobby-music crossfades) or for wiring custom effect chains (filters, reverbs) downstream of the player's output. +- **`PlayerOptions.output`** — pass a custom `AudioDeviceOutput` / `AudioEmitter` Instance to opt out of the default auto-created `AudioDeviceOutput`. Useful for spatial setups where the player should feed an `AudioEmitter` on a part instead of a global device output. When omitted, the SDK auto-creates an `AudioDeviceOutput` parented to `options.parent` (defaults to `SoundService`) for the drop-in story. + +### Notes + +- Requires a Roblox client that supports the new audio API (`AudioPlayer` / `AudioDeviceOutput` / `Wire`). No legacy `Sound` fallback is provided. +- The auto-created `AudioDeviceOutput` is owned by the player and destroyed in `:destroy()`. A caller-provided `options.output` is left in place on destroy — the caller owns its lifecycle. + ## v0.14.1 ### Changed diff --git a/README.md b/README.md index 1591c0b..64aae92 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.14.1" +AudioScape = "this-fifo/audioscape-sdk@0.15.0" ``` Then run: diff --git a/src/AudioScapeMusicPlayer.luau b/src/AudioScapeMusicPlayer.luau index 24ab744..9e5d3a1 100644 --- a/src/AudioScapeMusicPlayer.luau +++ b/src/AudioScapeMusicPlayer.luau @@ -2,9 +2,13 @@ --[[ AudioScapeMusicPlayer — drop-in audio playback with auto-analytics. - Manages a queue of tracks, handles Sound lifecycle, and automatically + Manages a queue of tracks, handles AudioPlayer lifecycle, and automatically fires play/stop/skip analytics events through the AudioScape client. + Each track plays through an AudioPlayer wired to an AudioDeviceOutput + (auto-created in :new unless the caller passes their own output instance — + e.g. an AudioEmitter for spatial audio — via PlayerOptions.output). + Usage: local player = client:createPlayer() player:queue(result.tracks) @@ -32,6 +36,10 @@ export type PlayerOptions = { parent: Instance?, volume: number?, playerId: number?, + -- Optional pre-existing audio output (e.g. AudioDeviceOutput or AudioEmitter + -- on a part for spatial setups). When omitted, the player auto-creates an + -- AudioDeviceOutput parented to `parent`. + output: Instance?, } -- Minimal interface for the analytics client dependency. @@ -52,7 +60,10 @@ export type MusicPlayerInstance = typeof(setmetatable( _playerId: number?, _queue: { Track }, _index: number, - _sound: Sound?, + _output: Instance, + _ownsOutput: boolean, + _audioPlayer: AudioPlayer?, + _wire: Wire?, _playing: boolean, _advancing: boolean, _startedAt: number, @@ -70,15 +81,31 @@ export type MusicPlayerInstance = typeof(setmetatable( function AudioScapeMusicPlayer.new(client: AnalyticsClient, options: PlayerOptions?): MusicPlayerInstance local opts: PlayerOptions = options or {} + local parent = opts.parent or SoundService + + local output: Instance + local ownsOutput: boolean + if opts.output then + output = opts.output + ownsOutput = false + else + local device = Instance.new("AudioDeviceOutput") + device.Parent = parent + output = device + ownsOutput = true + end local self = setmetatable({ _client = client, - _parent = opts.parent or SoundService, + _parent = parent, _volume = math.clamp(opts.volume or 0.5, 0, 1), _playerId = opts.playerId, _queue = {}, _index = 0, - _sound = nil, + _output = output, + _ownsOutput = ownsOutput, + _audioPlayer = nil, + _wire = nil, _playing = false, _advancing = false, _startedAt = 0, @@ -94,9 +121,13 @@ function AudioScapeMusicPlayer.new(client: AnalyticsClient, options: PlayerOptio OnQueueFinished = nil, }, AudioScapeMusicPlayer) - -- Flush final analytics on game shutdown - game:BindToClose(function() - self:destroy() + -- Flush final analytics on game shutdown. pcall so loading the module + -- outside a server context (Studio command bar, plugin contexts) doesn't + -- error — the SDK is server-realm but is sometimes exercised elsewhere. + pcall(function() + game:BindToClose(function() + self:destroy() + end) end) return self @@ -108,7 +139,10 @@ function AudioScapeMusicPlayer.destroy(self: MusicPlayerInstance) local duration = self:_getListenDuration() self._client:trackStop(self.NowPlaying.asset_id, self._playerId, duration) end - self:_cleanupSound() + self:_cleanupAudio() + if self._ownsOutput and self._output then + self._output:Destroy() + end end -- Add tracks to the end of the queue. @@ -165,14 +199,14 @@ end -- Stop the currently playing track. Fires a stop analytics event. function AudioScapeMusicPlayer.stop(self: MusicPlayerInstance) - if not self._playing or not self._sound then + if not self._playing or not self._audioPlayer then return end local duration = self:_getListenDuration() local track = self.NowPlaying - self:_cleanupSound() + self:_cleanupAudio() if track then self._client:trackStop(track.asset_id, self._playerId, duration) @@ -190,7 +224,7 @@ function AudioScapeMusicPlayer.skip(self: MusicPlayerInstance) local duration = self:_getListenDuration() local track = self.NowPlaying - self:_cleanupSound() + self:_cleanupAudio() if track then self._client:trackSkip(track.asset_id, self._playerId, duration) @@ -219,8 +253,8 @@ end -- Set playback volume (0 to 1). function AudioScapeMusicPlayer.setVolume(self: MusicPlayerInstance, volume: number) self._volume = math.clamp(volume, 0, 1) - if self._sound then - self._sound.Volume = self._volume + if self._audioPlayer then + self._audioPlayer.Volume = self._volume end end @@ -232,13 +266,21 @@ end -- Play a single track immediately, replacing current playback. -- Does not affect the queue. function AudioScapeMusicPlayer.playTrack(self: MusicPlayerInstance, track: Track) - self:_cleanupSound() + self:_cleanupAudio() self._queue = { track } self._index = 1 self.Queue = self._queue self:_playTrackAtIndex(1) end +-- Return the underlying AudioPlayer Instance for the currently playing track, +-- or nil if nothing is playing. Use for TweenService-driven volume animation +-- (crossfades) or wiring custom effect chains (filters, reverbs) downstream +-- of the player's output. +function AudioScapeMusicPlayer.getAudioPlayer(self: MusicPlayerInstance): AudioPlayer? + return self._audioPlayer +end + --[[ ────────────────────────────────────────── Internal helpers ────────────────────────────────────────── ]] @@ -249,15 +291,21 @@ function AudioScapeMusicPlayer._playTrackAtIndex(self: MusicPlayerInstance, inde return end - -- Clean up any existing sound - self:_cleanupSound() + -- Clean up any existing audio + self:_cleanupAudio() - -- Create new Sound - local sound = Instance.new("Sound") - sound.SoundId = "rbxassetid://" .. track.asset_id - sound.Volume = self._volume - sound.Parent = self._parent - self._sound = sound + -- Create AudioPlayer for this track and wire it to the output + local audioPlayer = Instance.new("AudioPlayer") + audioPlayer.Asset = "rbxassetid://" .. track.asset_id + audioPlayer.Volume = self._volume + audioPlayer.Parent = self._parent + self._audioPlayer = audioPlayer + + local wire = Instance.new("Wire") + wire.SourceInstance = audioPlayer + wire.TargetInstance = self._output + wire.Parent = audioPlayer + self._wire = wire -- Update state self._playing = true @@ -274,7 +322,7 @@ function AudioScapeMusicPlayer._playTrackAtIndex(self: MusicPlayerInstance, inde self._client:trackPlay(track.asset_id, self._playerId, track.duration) -- Listen for track end - self._endedConnection = sound.Ended:Connect(function() + self._endedConnection = audioPlayer.Ended:Connect(function() -- Guard against re-entrant advancement (e.g. skip() already handling this) if self._advancing then return @@ -283,7 +331,7 @@ function AudioScapeMusicPlayer._playTrackAtIndex(self: MusicPlayerInstance, inde local listenDuration = self:_getListenDuration() - self:_cleanupSound() + self:_cleanupAudio() -- Track stop (natural end) self._client:trackStop(track.asset_id, self._playerId, listenDuration) @@ -308,18 +356,22 @@ function AudioScapeMusicPlayer._playTrackAtIndex(self: MusicPlayerInstance, inde end end) - sound:Play() + audioPlayer:Play() end -function AudioScapeMusicPlayer._cleanupSound(self: MusicPlayerInstance) +function AudioScapeMusicPlayer._cleanupAudio(self: MusicPlayerInstance) if self._endedConnection then self._endedConnection:Disconnect() self._endedConnection = nil end - if self._sound then - self._sound:Stop() - self._sound:Destroy() - self._sound = nil + if self._audioPlayer then + self._audioPlayer:Stop() + self._audioPlayer:Destroy() + self._audioPlayer = nil + end + if self._wire then + self._wire:Destroy() + self._wire = nil end self._playing = false self.IsPlaying = false diff --git a/tests/_harness/loadSdk.luau b/tests/_harness/loadSdk.luau index 80c3855..9a1e88e 100644 --- a/tests/_harness/loadSdk.luau +++ b/tests/_harness/loadSdk.luau @@ -73,7 +73,7 @@ local function loadFile(filePath: string, env: any): any return chunk() end -local function loadSdk(opts: LoadOpts): (any, { string }) +local function loadSdk(opts: LoadOpts): (any, { string }, { any }) assert(opts and opts.http, "loadSdk requires opts.http (mockHttp or realHttp instance)") -- selene: allow(global_usage) @@ -148,7 +148,7 @@ local function loadSdk(opts: LoadOpts): (any, { string }) initEnv.script = rootScript initEnv.require = customRequire - return loadFile("src/init.luau", initEnv), recordedWarnings + return loadFile("src/init.luau", initEnv), recordedWarnings, (Instance :: any).__created end return { diff --git a/tests/_harness/mockGame.luau b/tests/_harness/mockGame.luau index 26499b0..fe9a9ce 100644 --- a/tests/_harness/mockGame.luau +++ b/tests/_harness/mockGame.luau @@ -77,20 +77,65 @@ local function makeWorkspace() } end +-- A minimal Instance shim. Stores arbitrary properties and provides the small +-- set of methods/signals the SDK touches at runtime: Play/Stop/Destroy on +-- audio instances, and an Ended signal on AudioPlayer. Created instances are +-- recorded on `Instance.__created` so tests can find them by ClassName without +-- having to walk a parent's children (the shim doesn't model children). local function makeInstance() - return { - new = function(className: string) - local props: { [string]: any } = { ClassName = className } - return setmetatable(props, { - __index = function(_, k) - return rawget(props, k) - end, - __newindex = function(_, k, v) - rawset(props, k, v) + local created: { any } = {} + local InstanceTable: { [string]: any } = {} + InstanceTable.__created = created + + local function makeSignal() + local handlers: { (...any) -> () } = {} + local signal: { [string]: any } = {} + signal.Connect = function(_, fn: (...any) -> ()) + table.insert(handlers, fn) + return { + Disconnect = function() + for i, h in ipairs(handlers) do + if h == fn then + table.remove(handlers, i) + break + end + end end, - }) - end, - } + } + end + -- Test-only fire hook + signal._fire = function(...) + for _, h in ipairs(handlers) do + h(...) + end + end + return signal + end + + InstanceTable.new = function(className: string) + local props: { [string]: any } = { + ClassName = className, + Ended = makeSignal(), + } + local inst + inst = setmetatable(props, { + __index = function(_, k) + return rawget(props, k) + end, + __newindex = function(_, k, v) + rawset(props, k, v) + end, + }) + props.Play = function() end + props.Stop = function() end + props.Destroy = function() + (props :: any)._destroyed = true + end + table.insert(created, inst) + return inst + end + + return InstanceTable end local function makeTask() diff --git a/tests/musicPlayer.spec.luau b/tests/musicPlayer.spec.luau new file mode 100644 index 0000000..dfeb35f --- /dev/null +++ b/tests/musicPlayer.spec.luau @@ -0,0 +1,263 @@ +--!strict +-- Unit tests for AudioScapeMusicPlayer — verifies the v0.15.0 migration from +-- legacy `Sound` to the new Roblox Audio API (`AudioPlayer` + `AudioDeviceOutput` +-- + `Wire`), plus the new `getAudioPlayer()` accessor and `PlayerOptions.output` +-- override. Analytics callbacks (trackPlay / trackStop / trackSkip) are +-- exercised through a fake client so we can assert on call shape and timing. + +local fs = require("@lune/fs") +local luau = require("@lune/luau") +local mockGame = require("./_harness/mockGame") + +local function findByClass(created: { any }, className: string): any? + for _, inst in ipairs(created) do + if (inst :: any).ClassName == className and not (inst :: any)._destroyed then + return inst + end + end + return nil +end + +local function lastByClass(created: { any }, className: string): any? + for i = #created, 1, -1 do + if (created[i] :: any).ClassName == className then + return created[i] + end + end + return nil +end + +local function makeFakeAnalytics() + local calls: { { kind: string, assetId: string, playerId: any, duration: any } } = {} + return { + trackPlay = function(_, assetId, playerId, duration) + table.insert(calls, { kind = "play", assetId = assetId, playerId = playerId, duration = duration }) + end, + trackStop = function(_, assetId, playerId, duration) + table.insert(calls, { kind = "stop", assetId = assetId, playerId = playerId, duration = duration }) + end, + trackSkip = function(_, assetId, playerId, duration) + table.insert(calls, { kind = "skip", assetId = assetId, playerId = playerId, duration = duration }) + end, + }, + calls +end + +local function makeTrack(assetId: string, duration: number) + return { + asset_id = assetId, + name = "t-" .. assetId, + artist = "a", + album = "alb", + genre = "Electronic", + duration = duration, + } +end + +-- Helper that builds a player directly via the SDK's AudioScapeMusicPlayer.new +-- export. We dig it out of the SDK's createPlayer machinery by calling +-- createPlayer with a fake analytics client wrapper. +local function buildPlayer(analytics: any, options: any?) + -- Load the player module in a fresh env (matches loadSdk's harness). + local Instance = mockGame.Instance() + local game = mockGame.game({}) + local workspace = mockGame.workspace() + local task = mockGame.task() + + local env: { [string]: any } = { + assert = assert, + error = error, + ipairs = ipairs, + pairs = pairs, + setmetatable = setmetatable, + math = math, + table = table, + typeof = typeof, + type = type, + tostring = tostring, + game = game, + workspace = workspace, + Instance = Instance, + task = task, + } + local chunk = luau.load(fs.readFile("src/AudioScapeMusicPlayer.luau"), { environment = env }) + local Player = chunk() + local instance = Player.new(analytics, options) + return instance, (Instance :: any).__created, Instance +end + +-- 1) Construction creates an AudioDeviceOutput when no override is supplied +do + local analytics, _calls = makeFakeAnalytics() + local player, created = buildPlayer(analytics) + + local output = findByClass(created, "AudioDeviceOutput") + assert(output ~= nil, "auto-create AudioDeviceOutput on .new") + assert(findByClass(created, "AudioPlayer") == nil, "no AudioPlayer created until play()") + + player:destroy() +end + +-- 2) Construction with PlayerOptions.output skips the auto-create +do + local analytics, _calls = makeFakeAnalytics() + -- Pre-build an "AudioEmitter"-shaped fake via a separate Instance shim + local externalInstance = mockGame.Instance() + local emitter = (externalInstance :: any).new("AudioEmitter") + + local player, created = buildPlayer(analytics, { output = emitter }) + assert(findByClass(created, "AudioDeviceOutput") == nil, "no AudioDeviceOutput when caller provides output") + + -- Play a track and assert the wire targets the caller's output, not an auto-created one + player:queue({ makeTrack("111", 30) }) + player:play() + local wire = findByClass(created, "Wire") + assert(wire ~= nil, "Wire created during play") + assert((wire :: any).TargetInstance == emitter, "Wire targets the caller-provided output") + + player:destroy() + -- caller-owned output must NOT be destroyed by the player + assert((emitter :: any)._destroyed == nil, "player must not destroy caller-provided output") +end + +-- 3) play() creates AudioPlayer + Wire wired to the output, fires trackPlay, +-- sets NowPlaying, calls OnTrackChanged, and AssetId is the ContentId string +do + local analytics, calls = makeFakeAnalytics() + local player, created = buildPlayer(analytics) + + local changed: { any } = {} + player.OnTrackChanged = function(t) + table.insert(changed, t) + end + + player:queue({ makeTrack("111", 30), makeTrack("222", 20) }) + player:play() + + local output = findByClass(created, "AudioDeviceOutput") + local audioPlayer = findByClass(created, "AudioPlayer") + local wire = findByClass(created, "Wire") + + assert(audioPlayer ~= nil, "AudioPlayer instance created") + assert(wire ~= nil, "Wire instance created") + assert((audioPlayer :: any).Asset == "rbxassetid://111", "AudioPlayer.Asset set from track.asset_id") + assert((audioPlayer :: any).Volume == 0.5, "default volume 0.5 on the AudioPlayer") + assert((wire :: any).SourceInstance == audioPlayer, "Wire source = AudioPlayer") + assert((wire :: any).TargetInstance == output, "Wire target = AudioDeviceOutput") + assert(player.IsPlaying == true, "IsPlaying flips to true") + assert((player.NowPlaying :: any).asset_id == "111", "NowPlaying set") + + assert(#calls == 1 and calls[1].kind == "play" and calls[1].assetId == "111", "trackPlay fired with asset_id") + assert(#changed == 1 and (changed[1] :: any).asset_id == "111", "OnTrackChanged fired with track") + + -- getAudioPlayer accessor returns the current AudioPlayer + assert(player:getAudioPlayer() == audioPlayer, "getAudioPlayer returns the live AudioPlayer") + + -- setVolume updates the live AudioPlayer + player:setVolume(0.8) + assert((audioPlayer :: any).Volume == 0.8, "setVolume propagates to AudioPlayer.Volume") + -- clamp to 0..1 + player:setVolume(5) + assert((audioPlayer :: any).Volume == 1, "setVolume clamps above 1") + + player:destroy() +end + +-- 4) Natural end (Ended signal) advances to the next track and fires trackStop +do + local analytics, calls = makeFakeAnalytics() + local player, created = buildPlayer(analytics) + + player:queue({ makeTrack("111", 1), makeTrack("222", 1) }) + player:play() + + local firstAudioPlayer = findByClass(created, "AudioPlayer") + assert(firstAudioPlayer ~= nil, "first AudioPlayer exists after play()") + + -- Simulate the asset finishing naturally + local endedSignal = (firstAudioPlayer :: any).Ended + endedSignal._fire() + + -- After Ended: first AP destroyed, second AP created, trackStop fired for 111, + -- trackPlay fired for 222 + assert((firstAudioPlayer :: any)._destroyed, "first AudioPlayer destroyed after Ended") + local secondAudioPlayer = lastByClass(created, "AudioPlayer") + assert(secondAudioPlayer ~= firstAudioPlayer, "second AudioPlayer created for next track") + assert((secondAudioPlayer :: any).Asset == "rbxassetid://222", "second track's Asset") + + -- Call sequence: play(111), stop(111), play(222) + assert(calls[1].kind == "play" and calls[1].assetId == "111", "first call: play(111)") + assert(calls[2].kind == "stop" and calls[2].assetId == "111", "second call: stop(111) on Ended") + assert(calls[3].kind == "play" and calls[3].assetId == "222", "third call: play(222) advance") + + player:destroy() +end + +-- 5) skip() fires trackSkip and advances; getAudioPlayer is nil when queue empties +do + local analytics, calls = makeFakeAnalytics() + local player = buildPlayer(analytics) + + local finished = false + player.OnQueueFinished = function() + finished = true + end + + player:queue({ makeTrack("111", 30) }) + player:play() + + player:skip() + + -- single-track queue + skip → queue finishes + assert(calls[1].kind == "play" and calls[1].assetId == "111", "first call: play(111)") + assert(calls[2].kind == "skip" and calls[2].assetId == "111", "second call: skip(111)") + assert(player.IsPlaying == false, "IsPlaying flips off when queue finishes via skip") + assert(player.NowPlaying == nil, "NowPlaying cleared when queue finishes") + assert(finished == true, "OnQueueFinished fires") + assert(player:getAudioPlayer() == nil, "getAudioPlayer returns nil when not playing") + + player:destroy() +end + +-- 6) stop() fires trackStop, tears down AudioPlayer + Wire, leaves output alive +do + local analytics, calls = makeFakeAnalytics() + local player, created = buildPlayer(analytics) + + player:queue({ makeTrack("111", 30) }) + player:play() + + local ap = findByClass(created, "AudioPlayer") + local wire = findByClass(created, "Wire") + local output = findByClass(created, "AudioDeviceOutput") + + player:stop() + + assert(calls[#calls].kind == "stop", "stop() fires trackStop") + assert((ap :: any)._destroyed, "AudioPlayer destroyed on stop") + assert((wire :: any)._destroyed, "Wire destroyed on stop") + assert((output :: any)._destroyed == nil, "AudioDeviceOutput survives stop()") + assert(player:getAudioPlayer() == nil, "getAudioPlayer nil after stop") + + player:destroy() + assert((output :: any)._destroyed, "owned AudioDeviceOutput destroyed on player:destroy()") +end + +-- 7) destroy() while playing fires a final trackStop (BindToClose flush semantics) +do + local analytics, calls = makeFakeAnalytics() + local player, _created = buildPlayer(analytics) + + player:queue({ makeTrack("111", 30) }) + player:play() + player:destroy() + + local stopCall + for _, c in ipairs(calls) do + if c.kind == "stop" then + stopCall = c + end + end + assert(stopCall ~= nil, "destroy() fires a final trackStop for the in-progress track") + assert(stopCall.assetId == "111", "final trackStop carries the in-progress asset_id") +end diff --git a/tests/openCloud/smokeScript.luau b/tests/openCloud/smokeScript.luau index b68b903..9370705 100644 --- a/tests/openCloud/smokeScript.luau +++ b/tests/openCloud/smokeScript.luau @@ -2,8 +2,8 @@ -- Server-side smoke suite run inside Roblox via Open Cloud Luau Execution. -- This is the project's ONLY integration layer (Lune specs cover pure logic). -- Covers every HTTP-hitting public method against the real AudioScape API --- through the real Roblox engine (real HttpService, Sound, RemoteFunction, --- workspace:GetServerTimeNow). +-- through the real Roblox engine (real HttpService, AudioPlayer + +-- AudioDeviceOutput + Wire, RemoteFunction, workspace:GetServerTimeNow). -- -- Returns { ok, results, fixtures } where results is a list of per-step -- { name, ok, value?, err? }. The harness surfaces the per-step list and @@ -364,12 +364,60 @@ step("analytics_flush", function() return "9 events posted" end) --- createPlayer instantiates a real Sound and connects Sound.Ended. Not --- testable in Lune (the mock can't model Sound events faithfully). +-- createPlayer instantiates a real AudioPlayer + AudioDeviceOutput + Wire and +-- connects AudioPlayer.Ended. Not testable in Lune (the mock can't model the +-- audio API faithfully). v0.15.0 migrated this off legacy Sound — this step +-- is the gate that catches engine-level regressions (Asset vs AudioContent, +-- Wire parenting, etc.) at PR time. step("createPlayer", function() - local player = client:createPlayer() + local player = client:createPlayer({ volume = 0.4 }) assert(player, "createPlayer returned nil") assert(typeof(player.playTrack) == "function", "player.playTrack is not a function") + assert(typeof(player.getAudioPlayer) == "function", "player.getAudioPlayer is not a function (v0.15.0)") + + assert(fixtures.music_asset_id, "needs music_asset_id from browse_trending") + player:playTrack({ + asset_id = fixtures.music_asset_id, + name = "smoke", + artist = "smoke", + album = "smoke", + genre = "smoke", + duration = 30, + }) + + -- Give the engine a moment to start decoding the asset. + task.wait(2) + + local ap = player:getAudioPlayer() + assert(ap, "getAudioPlayer returned nil while a track is queued") + assert(ap:IsA("AudioPlayer"), "underlying instance is " .. ap.ClassName .. ", expected AudioPlayer") + assert(ap.Asset == "rbxassetid://" .. fixtures.music_asset_id, "AudioPlayer.Asset mismatch: " .. tostring(ap.Asset)) + assert(ap.IsPlaying, "AudioPlayer.IsPlaying is false after 2s") + assert(ap.TimePosition > 0, "AudioPlayer.TimePosition did not advance (got " .. tostring(ap.TimePosition) .. ")") + assert(ap.Volume == 0.4, "AudioPlayer.Volume = " .. tostring(ap.Volume) .. ", expected 0.4") + + local wire + for _, child in ap:GetChildren() do + if child:IsA("Wire") then + wire = child + break + end + end + assert(wire, "Wire not parented to AudioPlayer") + assert(wire.SourceInstance == ap, "Wire.SourceInstance is not the AudioPlayer") + assert( + wire.TargetInstance and wire.TargetInstance:IsA("AudioDeviceOutput"), + "Wire.TargetInstance is not an AudioDeviceOutput" + ) + + player:setVolume(0.7) + assert(ap.Volume == 0.7, "setVolume did not propagate to live AudioPlayer.Volume") + + player:stop() + assert(player:getAudioPlayer() == nil, "getAudioPlayer should be nil after stop()") + assert(player.IsPlaying == false, "IsPlaying should be false after stop()") + + player:destroy() return "ok" end) diff --git a/wally.toml b/wally.toml index a864795..f38a24b 100644 --- a/wally.toml +++ b/wally.toml @@ -1,6 +1,6 @@ [package] name = "this-fifo/audioscape-sdk" -version = "0.14.1" +version = "0.15.0" registry = "https://github.com/UpliftGames/wally-index" realm = "server" license = "MIT" From af83ba1ed7db797c7dff30d82f3b37f2845e9f6e Mon Sep 17 00:00:00 2001 From: svarah Date: Fri, 22 May 2026 09:20:21 -0700 Subject: [PATCH 2/2] test(openCloud): use float32-exact volume values in createPlayer smoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Was: assert ap.Volume == 0.4 / 0.7 after setting volume = 0.4 then setVolume(0.7). Those values aren't exactly representable in float32, so AudioPlayer.Volume (engine-side float32) round-trips back to the Lua double as 0.4000000059604645 / 0.699999988079071 and the equality asserts fail. Picked 0.5 and 0.25 — both exactly representable in float32 — so the round-trip is exact and the asserts work without a tolerance check. Sean (OffGridDude) --- tests/openCloud/smokeScript.luau | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/openCloud/smokeScript.luau b/tests/openCloud/smokeScript.luau index 9370705..80e22fc 100644 --- a/tests/openCloud/smokeScript.luau +++ b/tests/openCloud/smokeScript.luau @@ -370,7 +370,10 @@ end) -- is the gate that catches engine-level regressions (Asset vs AudioContent, -- Wire parenting, etc.) at PR time. step("createPlayer", function() - local player = client:createPlayer({ volume = 0.4 }) + -- Picked values exactly representable in float32 (0.5, 0.25) so reads off + -- AudioPlayer.Volume round-trip cleanly to the Lua double we set — no + -- tolerance check needed. + local player = client:createPlayer({ volume = 0.5 }) assert(player, "createPlayer returned nil") assert(typeof(player.playTrack) == "function", "player.playTrack is not a function") assert(typeof(player.getAudioPlayer) == "function", "player.getAudioPlayer is not a function (v0.15.0)") @@ -394,7 +397,7 @@ step("createPlayer", function() assert(ap.Asset == "rbxassetid://" .. fixtures.music_asset_id, "AudioPlayer.Asset mismatch: " .. tostring(ap.Asset)) assert(ap.IsPlaying, "AudioPlayer.IsPlaying is false after 2s") assert(ap.TimePosition > 0, "AudioPlayer.TimePosition did not advance (got " .. tostring(ap.TimePosition) .. ")") - assert(ap.Volume == 0.4, "AudioPlayer.Volume = " .. tostring(ap.Volume) .. ", expected 0.4") + assert(ap.Volume == 0.5, "AudioPlayer.Volume = " .. tostring(ap.Volume) .. ", expected 0.5") local wire for _, child in ap:GetChildren() do @@ -410,8 +413,8 @@ step("createPlayer", function() "Wire.TargetInstance is not an AudioDeviceOutput" ) - player:setVolume(0.7) - assert(ap.Volume == 0.7, "setVolume did not propagate to live AudioPlayer.Volume") + player:setVolume(0.25) + assert(ap.Volume == 0.25, "setVolume did not propagate to live AudioPlayer.Volume: " .. tostring(ap.Volume)) player:stop() assert(player:getAudioPlayer() == nil, "getAudioPlayer should be nil after stop()")