Skip to content

Commit

Permalink
feat: redesign TickEvent for support cleanup
Browse files Browse the repository at this point in the history
  • Loading branch information
threedalpeng committed Feb 26, 2024
1 parent 7c31cd4 commit 87a70b8
Show file tree
Hide file tree
Showing 4 changed files with 105 additions and 58 deletions.
4 changes: 2 additions & 2 deletions src/lib/device/metronome/metronome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ class Metronome {
this.#isScheduled = true;
this.#timer.scheduleLoopOnTempo({
time: { start: 0, interval: this.#notesPerBeat },
cb: this.#onTick.bind(this),
audioCb: this.#scheduleAudio.bind(this)
animation: this.#onTick.bind(this),
audio: this.#scheduleAudio.bind(this)
});
return this.removeSchedule.bind(this);
}
Expand Down
1 change: 1 addition & 0 deletions src/lib/practice/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface Tempo {
export interface ScoreTimestamp {
start: number;
duration?: number;
interval?: number;
}

export interface PracticeNote {
Expand Down
58 changes: 32 additions & 26 deletions src/lib/timer/event.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import type { ScoreTimestamp } from '../practice/types';
import { MAX_TICK_SIZE } from './constant';
import { type AudioTickCallback, type TickCallback } from './tick';

export type TickTimestamp = ScoreTimestamp;
export interface TickEventCallbacks {
audio: AudioTickCallback;
animation: TickCallback;
cleanup: TickCallback;
}

export interface TickEventOption {
start: number;
interval?: number;
duration?: number;
audioCb?: AudioTickCallback;
cb?: TickCallback;
time: TickTimestamp;
callbacks?: Partial<TickEventCallbacks>;
}

export class TickEvent {
Expand All @@ -20,41 +25,42 @@ export class TickEvent {
return this.#id;
}

#start: number;
#time: TickTimestamp;

get start() {
return this.#start;
return this.#time.start;
}

#interval?: number;
get interval() {
return this.#interval;
return this.#time.interval;
}

#duration?: number;
get duration() {
return this.#duration;
return this.#time.duration;
}

#cb?: TickCallback;
get cb() {
return this.#cb;
#callbacks?: Partial<TickEventCallbacks>;
get callbacks() {
return this.#callbacks;
}
get animation() {
return this.#callbacks?.animation;
}
get cleanup() {
return this.#callbacks?.cleanup;
}
#audioCb: AudioTickCallback | undefined;
get audioCb() {
return this.#audioCb;
get audio() {
return this.#callbacks?.audio;
}
constructor({ start, interval, duration, audioCb, cb }: TickEventOption) {
if (start < 0 || MAX_TICK_SIZE <= start) {
constructor({ time, callbacks }: TickEventOption) {
if (time.start < 0 || MAX_TICK_SIZE <= time.start + (time.duration ?? 0)) {
throw RangeError('tick out of range');
}
if (!audioCb && !cb) {
throw TypeError('either audioCb or cb should be defined');
if (!callbacks?.audio && !callbacks?.animation) {
throw TypeError('either audio or animation callback should be defined');
}
this.#id = TickEvent.#nextId;
this.#start = start;
this.#interval = interval;
this.#duration = duration;
this.#audioCb = audioCb;
this.#cb = cb;
this.#time = time;
this.#callbacks = callbacks;
}
}
100 changes: 70 additions & 30 deletions src/lib/timer/tick.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { MultiMap } from '$/utils/multimap';
import type { WithCleanup } from '$/utils/types';
import type { ScoreTimestamp } from '../practice/types';
import { TickEvent } from './event';
import { TickEvent, type TickEventCallbacks, type TickEventOption } from './event';
import TimerWorker from './timer-worker?worker';

export interface TickState {
Expand Down Expand Up @@ -58,12 +58,14 @@ export class AudioClockTimer {
this.audioCtx.resume();
this.#tickPassed = 0;
}
if (!this.#isRunning) {
this.#isRunning = true;
// delay initial lookhead
this.#nextTickOnSecond = this.audioCtx.currentTime + 0.1;
this.lookaheadTimer.postMessage('start');
this.#startCallbacks.forEach((cb) => cb());
if (!this.#isRunning && this.audioCtx.state === 'running') {
Promise.all([...this.#beforeStartCallbacks].map((cb) => cb())).then(() => {
this.#isRunning = true;
// delay initial lookhead
this.#nextTickOnSecond = this.audioCtx!!.currentTime + 0.1;
this.lookaheadTimer.postMessage('start');
this.#startCallbacks.forEach((cb) => cb());
});
}
}

Expand All @@ -83,8 +85,8 @@ export class AudioClockTimer {
if (scheduleIdList) {
scheduleIdList.forEach((id) => {
const event = this.#events.get(id);
if (event && event.audioCb) {
event.audioCb(audioState);
if (event && event.audio) {
event.audio(audioState);
}
});
}
Expand All @@ -96,8 +98,8 @@ export class AudioClockTimer {
event.start <= this.#tickPassed &&
(this.#tickPassed - event.start) % event.interval === 0
) {
if (event.audioCb) {
event.audioCb(audioState);
if (event.audio) {
event.audio(audioState);
}
}
}
Expand All @@ -114,12 +116,24 @@ export class AudioClockTimer {
let tickState = this.#tickQueue[0];
while (tickState !== undefined && tickState.time <= currentTime) {
this.#tickQueue.shift();
const scheduleIdList = this.#schedules.getAll(this.#tickPassed);

const cleanupIdList = this.#cleanupSchedules.getAll(tickState.tickPassed);
if (cleanupIdList) {
console.log(cleanupIdList);
cleanupIdList.forEach((id) => {
const event = this.#events.get(id);
if (event && event.cleanup) {
event.cleanup(tickState);
}
});
}

const scheduleIdList = this.#schedules.getAll(tickState.tickPassed);
if (scheduleIdList !== undefined) {
scheduleIdList.forEach((id) => {
const event = this.#events.get(id);
if (event && event.cb) {
event.cb(tickState);
if (event && event.animation) {
event.animation(tickState);
}
});
}
Expand All @@ -131,8 +145,8 @@ export class AudioClockTimer {
event.start <= tickState.tickPassed &&
(tickState.tickPassed - event.start) % event.interval === 0
) {
if (event.cb) {
event.cb(tickState);
if (event.animation) {
event.animation(tickState);
}
}
}
Expand All @@ -143,6 +157,14 @@ export class AudioClockTimer {
window.requestAnimationFrame(this.#onAnimationFrame.bind(this));
}

#beforeStartCallbacks: Set<() => Promise<any>> = new Set();
beforeStart(cb: () => Promise<any>) {
this.#beforeStartCallbacks.add(cb);
return () => this.removeStart(cb);
}
removeBeforeStart(cb: () => Promise<any>) {
this.#beforeStartCallbacks.delete(cb);
}
#startCallbacks: Set<() => any> = new Set();
onStart(cb: () => any) {
this.#startCallbacks.add(cb);
Expand All @@ -161,10 +183,14 @@ export class AudioClockTimer {
}

#schedules: MultiMap<number, number> = new MultiMap();
#cleanupSchedules: MultiMap<number, number> = new MultiMap();
#events: Map<number, TickEvent> = new Map();
schedule(event: TickEvent) {
this.#events.set(event.id, event);
this.#schedules.set(event.start, event.id);
if (event.duration) {
this.#cleanupSchedules.set(event.start + event.duration, event.id);
}
return event.id;
}

Expand All @@ -184,6 +210,14 @@ export class AudioClockTimer {
const idx = schedulesOnTick.indexOf(eventId);
if (idx >= 0) schedulesOnTick.splice(idx, 1);
}

if (event.duration) {
const cleanupSchedulesOnTick = this.#cleanupSchedules.getAll(event.start + event.duration);
if (cleanupSchedulesOnTick) {
const idx = cleanupSchedulesOnTick.indexOf(eventId);
if (idx >= 0) cleanupSchedulesOnTick.splice(idx, 1);
}
}
}
}

Expand Down Expand Up @@ -219,8 +253,8 @@ export class AudioClockTimer {
type TimeUnit = 'tick' | 'note' | 'beat' | 'bar' | 'second' | 'millisecond';
export interface TempoSchedule {
time: ScoreTimestamp;
cb?: WithCleanup<TickCallback>;
audioCb?: AudioTickCallback;
animation?: WithCleanup<TickCallback>;
audio?: AudioTickCallback;
}
export interface TempoState {
bpm: number;
Expand Down Expand Up @@ -353,33 +387,39 @@ export class TempoTimer extends AudioClockTimer {
this.#tempoChangedCallbacks.delete(cb);
}

scheduleOnTempo({ time, cb, audioCb }: TempoSchedule): number {
scheduleOnTempo({ time, animation, audio }: TempoSchedule): number {
const start = this.convert(time.start, 'note', 'tick');
const duration = time.duration ? this.convert(time.duration, 'note', 'tick') : undefined;
const interval = time.interval ? this.convert(time.interval, 'note', 'tick') : undefined;

const callbacks: Partial<TickEventCallbacks> = {
animation: animation
? (state) => {
callbacks.cleanup = animation(state) ?? undefined;
}
: undefined,
audio
};

return this.schedule(
new TickEvent({
start,
duration,
interval,
cb,
audioCb
time: { start, duration, interval },
callbacks
})
);
}
scheduleLoopOnTempo({ time, cb, audioCb }: TempoSchedule): number {
scheduleLoopOnTempo({ time, animation, audio }: TempoSchedule): number {
const start = this.convert(time.start, 'note', 'tick');
const duration = time.duration ? this.convert(time.duration, 'note', 'tick') : undefined;
const interval = time.interval ? this.convert(time.interval, 'note', 'tick') : undefined;

return this.scheduleLoop(
new TickEvent({
start,
duration,
interval,
cb,
audioCb
time: { start, duration, interval },
callbacks: {
animation,
audio
}
})
);
}
Expand Down

0 comments on commit 87a70b8

Please sign in to comment.