#1311 Sound preview: Play/Pause, a scrub bar with the waveform, time and loop #1340

Merged
sky_line merged 4 commits from TheSlowly/Slipstream:sound-scrub-1311 into main 2026-10-31 22:09:18 +00:00
Collaborator

Fixes #1311: the inventory sound preview and the upload preview get a player.

  • Player: Play/Pause, a scrub bar with the waveform (click or drag to seek), m:ss.cc time and a loop toggle.
  • Engine: the audio engine gets "local clips" beside the local loops and shots: pause, seek, loop and position. It also gets a request hook, so a preview fetches a sound that isn't cached yet.
  • Playback uses the cached .dsf, so what you hear is what plays in-world, including the edge fades, which matters when checking where split clips join.
  • Retained UI: the player polls from idle, not draw(), and reports its changes to the retained UI.
  • Tests: the time and waveform maths have a unit test.

Tested in-world: the inventory and upload previews.

Fixes #1311: the inventory sound preview and the upload preview get a player. - **Player:** Play/Pause, a scrub bar with the waveform (click or drag to seek), m:ss.cc time and a loop toggle. - **Engine:** the audio engine gets "local clips" beside the local loops and shots: pause, seek, loop and position. It also gets a request hook, so a preview fetches a sound that isn't cached yet. - **Playback** uses the cached `.dsf`, so what you hear is what plays in-world, including the edge fades, which matters when checking where split clips join. - **Retained UI:** the player polls from idle, not `draw()`, and reports its changes to the retained UI. - **Tests:** the time and waveform maths have a unit test. Tested in-world: the inventory and upload previews.
The sound preview needs a scrub bar, and the path Play Locally uses cannot carry one.
triggerSound starts a forced-priority source on one of the 30 channels: nothing reports its
position, nothing pauses or seeks it, a paused source would be reaped as done, and channels
and buffers are stolen and recycled by priority. Adding pause and seek to LLAudioSource /
LLAudioChannel would fight those lifecycle rules and put the state on every in-world sound.

Local clips sit next to the weather's local loops and shots instead: one AL buffer and one
listener-relative source with no EFX and no channel, so nothing steals, reaps or times them
out and they never change what plays in-world. They play, pause, seek (AL_SAMPLE_OFFSET) and
loop (AL_LOOPING), and report their position in frames. OpenAL reports offset 0 once a
source has stopped and only applies an offset set on a stopped source at its next play, so a
clip keeps its own cursor while stopped and a play from stopped rewinds and sets it first. A
clip that plays to its end rewinds to the start. The local record's shot flag becomes an
explicit kind (loop, shot, clip), so each call only acts on its own kind.

The preview must also play a sound that was never played in this session: preloadSound only
decodes an asset already in the asset cache, and only sounds held by a source are fetched.
requestSound / releaseSound make a sound wanted with no source: it is fetched before
anything else, decoded first in line, and kept from the data sweep while requested. #891's
retries, backoff and bad marks apply unchanged. Requested sounds are also fetched while the
engine is muted (master mute, the progress screen), which stops every other fetch, so a
preview opened then still loads instead of waiting until audio is unmuted. readDecodedSound
reads the cached .dsf through LLSoundCache::readPCM, in the format the WAV header gives,
which is how in-world playback reads it: a stereo asset's interleaved data under the
decoder's mono header reads, plays and is timed as one mono stream twice as long, as
in-world. A listed .dsf that cannot be read is dropped and decoded again once, as
LLAudioData::load does; a second failure counts as a failed decode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The two sound previews (inventory and upload) need the same player, so it is one panel,
panel_sound_player.xml (class sound_player), fed either a sound asset or PCM already in
memory.

An asset is requested from the engine (requestSound), so a sound never played this session
is fetched and decoded while the preview is open, and its cached .dsf is played: exactly
what in-world playback hears, LL's short edge fades included, which is what a join check
between split clips (#1310) needs. Until then the time shows Loading..., and the controls are
disabled with a reason when there is no audio device, the sound is blocked, or it is marked
bad after failed fetches.

Playback is an engine local clip made on the first play, so it never takes a channel and
changes nothing in-world. Only one preview plays at a time: starting one pauses the others.
The volume is Master x Sound Effects, as Play Locally has always been. The panel keeps the
playhead itself while the clip is not playing and seeks there on each play. If the engine is
replaced, its clip and request are forgotten (never handed to another engine) and the clip
is remade on the next play. A hidden or minimised preview pauses.

The scrub bar (sound_scrub_bar) draws a min/max waveform column per pixel, with the part
before the playhead in the accent colour. A press or drag moves the playhead without
seeking; letting go seeks, so a click seeks at once. While dragging, the time shows where
the release will seek. The time reads m:ss.cc, since most sounds are under ten seconds.

The arithmetic (time format, bar <-> frame, waveform columns) is in slsoundscrub with a
unit test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The inventory sound preview only had Play Inworld and Play Locally, a fire-and-forget local
trigger: no position, no length, no pause, no way to start part-way. Checking where a clip
starts and ends, where split clips join (#1310) or where a click is meant listening from the
start every time.

Play Locally is now the player's Play / Pause, above the waveform scrub bar, the position
and length (m:ss.cc) and a Loop toggle. Opening the preview requests the sound, so one never
played this session is fetched and decoded at once (it showed nothing until played before),
and Play Locally plays its cached decoded file: what in-world playback hears, including LL's
short fade at each end. Play Inworld is unchanged and still sends the normal trigger.

The upload preview gets the same player, labelled Play, fed the decoded file it already
holds (shared, not copied), so a long file can be checked before it is split.

Test-Plan: Open a sound from inventory that you have not played this session (for example
  from Library > Sounds). The preview shows Loading... briefly, then the waveform and
  0:00.00 / its length. Play Locally plays it for you only: the playhead and time move, the
  part already played turns green and the button reads Pause. Pause, then Play Locally: it
  resumes where it stopped. Click the bar while playing: playback jumps there. Drag the
  playhead: it follows the pointer and the time shows where you are, and playback jumps only
  when you let go. Tick Loop: at the end it starts over without stopping; untick it and the
  sound plays to its end and the playhead returns to the start. Change the Master and Sound
  Effects volumes while it plays: it follows them, and muting Sound Effects silences it.
  Play Inworld still plays it for everyone. Open two sound previews and play one, then the
  other: the first pauses. Close a preview while it plays: it stops (minimised: it pauses).
  Block a sound in the Sound Explorer and open it from inventory: "This sound is blocked"
  and the controls are disabled. Upload a sound (Build > Upload > Sound, any WAV, Ogg, MP3
  or FLAC): the upload window has the same player, labelled Play, playing the file; with a
  file over 30 s the bar covers the whole file. A stereo sound asset shows the same length
  in the preview as it plays in-world.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The retained UI records each floater's drawing and replays it. A floater is drawn live only
while the mouse moves over it, while it holds keyboard focus (without focus replay) or mouse
capture; otherwise its views' draw() is not called until something reports a change or the
staggered safety refresh (RenderUIRetainedRefreshMs, 125 ms) re-records it. The player did
all its work in draw(): picking up the decoded file, following the playhead and the end of
the clip, and updating its controls. With the mouse resting elsewhere, Loading..., the
playhead and the time would only move at the safety refresh's pace, and the scrub bar never
reported a new clip or playhead at all.

The polling moves to an idle callback that is registered only while there is something to
follow (a sound loading, or playing) and removed once there is not, and on destruction.
A preview that is hidden or minimised pauses, so it never follows playback unseen. The
blocklist's change signal replaces the per-frame blocked check: a sound blocked meanwhile
pauses and drops its request, and one unblocked while loading is requested again. An asset
already decoded is picked up when it is set, so a cached sound shows its waveform at once.

Every visible change now reports onRetainedDirty(): the scrub bar reports a new clip (its
peaks are worked out then, and on a width change, instead of while drawing) and the
playhead only when it lands on another pixel, a press, drag, release or cancelled drag,
and the panel reports the Play / Pause label, which LLButton::setLabel does not. The time
and status text, and enabling or disabling the controls, already report themselves on a
real change (LLTextBox::setText, LLView::setEnabled). While a clip plays the time changes
every frame, so the floater draws live then and goes back to replaying when it stops.

Test-Plan: Open a sound from inventory, press Play Locally, then move the mouse off the
  preview and keep it still (do not click anywhere else either). The playhead and the time
  keep moving smoothly, and at the end the sound stops, the playhead returns to the start
  and the button reads Play Locally again. With the mouse still elsewhere, open a sound you
  have not played this session: Loading... turns into the waveform and length on its own.
  Do the same in the upload window (Build > Upload > Sound): Play, mouse away, the playhead
  keeps moving and stops at the end. Click the bar and drag the playhead, release, and
  move the mouse away: the playhead stays where you let go and playback continues from
  there. Block the sound in the Sound Explorer while its preview plays: it stops and the
  preview shows "This sound is blocked"; unblock it: the controls come back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
sky_line/Slipstream!1340
No description provided.