Skip to content

Commit

Permalink
✨ [Sound] Improve interaction of speed/loop/pause/progress
Browse files Browse the repository at this point in the history
  • Loading branch information
beefchimi committed Dec 29, 2023
1 parent d32a357 commit 396cfb1
Show file tree
Hide file tree
Showing 2 changed files with 51 additions and 24 deletions.
59 changes: 37 additions & 22 deletions src/Sound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ export class Sound extends EmittenCommon<SoundEventMap> {
};

#intervalId = 0;
#lastStartTime = 0;
#timestamp = 0;
#elapsedSnapshot = 0;
#hasStarted = false;

constructor(
readonly id: SoundId,
Expand Down Expand Up @@ -116,15 +118,24 @@ export class Sound extends EmittenCommon<SoundEventMap> {
const oldSpeed = this._speed;
const newSpeed = clamp(tokens.minSpeed, value, tokens.maxSpeed);

this._speed = newSpeed;
if (oldSpeed === newSpeed) return;

if (oldSpeed !== newSpeed) {
this.emit('speed', newSpeed);
}
this._speed = newSpeed;
this.emit('speed', newSpeed);

// Must return if `paused`, because the way we currently
// "pause" is by slowing `playbackRate` to a halt.
if (this._state === 'paused') return;

if (this._state !== 'playing') {
this.#source.playbackRate.value = newSpeed;
return;
}

const {currentTime} = this.context;
this.#timestamp = Math.max(currentTime, tokens.minStartTime);
this.#elapsedSnapshot = this.#progress.elapsed;

linearRamp(
this.#source.playbackRate,
{from: oldSpeed, to: newSpeed},
Expand All @@ -148,18 +159,23 @@ export class Sound extends EmittenCommon<SoundEventMap> {
}

get progress(): SoundProgressEvent {
if (!this.#lastStartTime) return {...this.#progress};
if (!this.#hasStarted) return {...this.#progress};

// When combining `speed + pause + looping`, we can end up with
// an accumulative loss of precision. The `progress` calculations
// can end up behind the actual play position of the sound.
// Not yet sure how to resolve this.
this.#incrementLoop();

const timeSince =
Math.max(this.context.currentTime - this.#timestamp, 0) * this.speed;

this.#progress.elapsed = clamp(
0,
this.context.currentTime - this.#lastStartTime,
this.#elapsedSnapshot + timeSince,
this.duration,
);

this.#progress.remaining = this.duration - this.#progress.elapsed;

this.#progress.percentage = clamp(
0,
progressPercentage(this.#progress.elapsed, this.duration),
Expand All @@ -174,14 +190,19 @@ export class Sound extends EmittenCommon<SoundEventMap> {
}

play() {
if (!this.#lastStartTime) this.#source.start();
if (!this.#hasStarted) {
this.#source.start();
this.#hasStarted = true;
}

if (this._state === 'paused') {
// Restoring directly to `playbackRate` instead of `speed`.
this.#source.playbackRate.value = this._speed;
}

this.#updateStartTime();
this.#timestamp = Math.max(this.context.currentTime, tokens.minStartTime);
this.#elapsedSnapshot = this.#progress.elapsed;

this.#setState('playing');

return this;
Expand All @@ -208,7 +229,7 @@ export class Sound extends EmittenCommon<SoundEventMap> {
// an explicit "stop" and a natural "end".
this.#setState('stopping');

if (this.#lastStartTime) this.#source.stop();
if (this.#hasStarted) this.#source.stop();
// Required to manually emit the `ended` event for "un-started" sounds.
else this.#handleEnded();

Expand Down Expand Up @@ -247,17 +268,11 @@ export class Sound extends EmittenCommon<SoundEventMap> {
this.#progress.elapsed = 0;
this.#progress.remaining = this.duration;
this.#progress.percentage = 0;

this.#progress.iterations++;
this.#updateStartTime();
}
}

#updateStartTime() {
this.#lastStartTime = Math.max(
this.context.currentTime - this.#progress.elapsed,
tokens.minStartTime,
);
this.#timestamp = this.context.currentTime;
this.#elapsedSnapshot = 0;
}
}

#updateProgress() {
Expand All @@ -276,7 +291,7 @@ export class Sound extends EmittenCommon<SoundEventMap> {
this.emit('ended', {
id: this.id,
source: this.#source,
neverStarted: !this.#lastStartTime,
neverStarted: !this.#hasStarted,
});

// This needs to happen AFTER our artifical `ended` event is emitted.
Expand Down
16 changes: 14 additions & 2 deletions src/tests/Sound.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,6 @@ describe('Sound component', () => {
it('sets value on `playbackRate`', async () => {
const oldValue = testSound.speed;
const newValue = 2.2;
const {currentTime} = defaultContext;

const spySourceCancel = vi.spyOn(
AudioParam.prototype,
Expand All @@ -92,6 +91,9 @@ describe('Sound component', () => {
'linearRampToValueAtTime',
);

const {currentTime} = defaultContext;
testSound.play();

// TODO: Spy on the `playbackRate.value` setter.
// const spyPlaybackRateSet = vi.spyOn(AudioParam.prototype, 'value', 'set');
testSound.speed = newValue;
Expand All @@ -103,6 +105,8 @@ describe('Sound component', () => {

it('does not set value on playbackRate if paused', async () => {
const {currentTime} = defaultContext;
// TODO: This test should be changed to directly check that
// the `AudioParam` has the expected `tokens` value.
const spyRamp = vi.spyOn(AudioParam.prototype, 'linearRampToValueAtTime');

testSound.play();
Expand All @@ -122,8 +126,11 @@ describe('Sound component', () => {
expect(spyRamp).toBeCalledWith(3.45, currentTime);
});

// TODO: Need to figure out how to spy on `playbackRate.value`.
it.todo('sets value directly on AudioParam when not `paused` or `playing`');

// TODO: Author this test if/when we support "transitions".
it.todo('Transitions to new speed');
it.todo('transitions to new speed');

it('triggers speed event when set to a unique value', async () => {
const spySpeed: SoundEventMap['speed'] = vi.fn((_rate) => {});
Expand Down Expand Up @@ -506,6 +513,11 @@ describe('Sound component', () => {

expect(spyProgressEvent).not.toBeCalled();
});

// TODO: We need to correctly mock a sound's duration and playback.
// This test should cover a combination of:
// changing speeds during playback, looping, and pausing.
it.todo('calculates progress values at various points in playback');
});
});
});

0 comments on commit 396cfb1

Please sign in to comment.