Skip to content

Commit

Permalink
✨ [Sound] Better support for loop and progress
Browse files Browse the repository at this point in the history
  • Loading branch information
beefchimi committed Dec 28, 2023
1 parent f846f8a commit edacd78
Show file tree
Hide file tree
Showing 2 changed files with 109 additions and 37 deletions.
82 changes: 48 additions & 34 deletions src/Sound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,6 @@ export class Sound extends EmittenCommon<SoundEventMap> {
readonly #gainNode: GainNode;
readonly #fadeSec: number = 0;

readonly #time = {
start: 0,
offset: 0,
};

readonly #progress = {
elapsed: 0,
remaining: 0,
Expand All @@ -36,6 +31,7 @@ export class Sound extends EmittenCommon<SoundEventMap> {
};

#intervalId = 0;
#lastStartTime = 0;

constructor(
readonly id: SoundId,
Expand Down Expand Up @@ -152,23 +148,23 @@ export class Sound extends EmittenCommon<SoundEventMap> {
}

get progress(): SoundProgressEvent {
if (this.#time.start) {
// This Getter is also the Setter.
const adjustment = this.#time.offset || this.#time.start;

this.#progress.elapsed = clamp(
0,
this.context.currentTime - adjustment,
this.duration,
);
this.#progress.remaining = this.duration - this.#progress.elapsed;

this.#progress.percentage = clamp(
0,
progressPercentage(this.#progress.elapsed, this.duration),
100,
);
}
if (!this.#lastStartTime) return {...this.#progress};

this.#incrementLoop();

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

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

this.#progress.percentage = clamp(
0,
progressPercentage(this.#progress.elapsed, this.duration),
100,
);

return {...this.#progress};
}
Expand All @@ -178,17 +174,14 @@ export class Sound extends EmittenCommon<SoundEventMap> {
}

play() {
if (!this.#time.start) {
this.#source.start();
this.#time.start = this.context.currentTime + tokens.minStartTime;
}
if (!this.#lastStartTime) this.#source.start();

if (this._state === 'paused') {
// Restoring directly to `playbackRate` instead of `speed`.
this.#source.playbackRate.value = this._speed;
this.#time.offset = this.context.currentTime - this.#progress.elapsed;
}

this.#updateStartTime();
this.#setState('playing');

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

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

return this;
}
Expand All @@ -246,6 +236,30 @@ export class Sound extends EmittenCommon<SoundEventMap> {
}
}

#incrementLoop() {
if (!this.loop) return;

const fullyElapsed = this.#progress.elapsed === this.duration;
const noTimeRemaining = this.#progress.remaining === 0;
const progressDone = this.#progress.percentage === 100;

if (fullyElapsed || noTimeRemaining || progressDone) {
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,
);
}

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

// This needs to happen AFTER our artifical `ended` event is emitted.
Expand Down
64 changes: 61 additions & 3 deletions src/tests/Sound.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,19 +148,61 @@ describe('Sound component', () => {
});

describe('loop', () => {
const testSound = new Sound(
const mockConstructorArgs: SoundConstructor = [
'TestLoop',
defaultAudioBuffer,
defaultContext,
defaultAudioNode,
);
];

it('allows `set` and `get`', async () => {
// TODO: Should check `AudioBufferSourceNode` value.
const testSound = new Sound(...mockConstructorArgs);

// TODO: Should check that `loop` is set on the source.
// const spySourceNode = vi.spyOn(AudioBufferSourceNode.prototype, 'loop', 'set');

expect(testSound.loop).toBe(false);
testSound.loop = true;
expect(testSound.loop).toBe(true);
});

it('does not repeat by default', async () => {
const testSound = new Sound(...mockConstructorArgs);

expect(testSound.progress.iterations).toBe(0);
testSound.play();
vi.advanceTimersToNextTimer();
expect(testSound.progress.iterations).toBe(0);
vi.advanceTimersToNextTimer();
expect(testSound.progress.iterations).toBe(0);
});

it('repeats sound indefinitely', async () => {
const testSound = new Sound(...mockConstructorArgs);

expect(testSound.state).toBe('created');
expect(testSound.progress.iterations).toBe(0);

testSound.loop = true;
testSound.play();

expect(testSound.state).toBe('playing');
vi.advanceTimersToNextTimer();
expect(testSound.progress.iterations).toBe(1);
vi.advanceTimersToNextTimer();
expect(testSound.progress.iterations).toBe(2);

// TODO: Test env seems to trigger `ended` even though
// we are looping the Sound.
// expect(testSound.state).toBe('playing');

testSound.loop = false;
vi.advanceTimersToNextTimer();
// `iterations` does not increment a final time,
// as it is only updated at the START of a new iteration.
expect(testSound.progress.iterations).toBe(2);
expect(testSound.state).toBe('ending');
});
});

describe('duration', () => {
Expand Down Expand Up @@ -448,6 +490,22 @@ describe('Sound component', () => {

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

// TODO: This test needs to instead check against
// `requestAnimationFrame()` never getting registered.
// We actually do still `emit` at the end of `setState()`.
it('does not emit events when subscribed after the sound has been started', async () => {
const testSound = new Sound(...mockConstructorArgs);
const spyProgressEvent: SoundEventMap['progress'] = vi.fn(
(_event) => {},
);

testSound.play();
vi.advanceTimersToNextTimer();
testSound.on('progress', spyProgressEvent);

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

0 comments on commit edacd78

Please sign in to comment.