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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion 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.14.1"
AudioScape = "this-fifo/audioscape-sdk@0.15.0"
```

Then run:
Expand Down
112 changes: 82 additions & 30 deletions src/AudioScapeMusicPlayer.luau
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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

Expand All @@ -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
────────────────────────────────────────── ]]
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions tests/_harness/loadSdk.luau
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
69 changes: 57 additions & 12 deletions tests/_harness/mockGame.luau
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading