diff --git a/src/lib/device/metronome/metronome.ts b/src/lib/device/metronome/metronome.ts index a4d1974..f570c21 100644 --- a/src/lib/device/metronome/metronome.ts +++ b/src/lib/device/metronome/metronome.ts @@ -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); } diff --git a/src/lib/practice/types.ts b/src/lib/practice/types.ts index c1524d6..4e73d08 100644 --- a/src/lib/practice/types.ts +++ b/src/lib/practice/types.ts @@ -9,6 +9,7 @@ export interface Tempo { export interface ScoreTimestamp { start: number; duration?: number; + interval?: number; } export interface PracticeNote { diff --git a/src/lib/timer/event.ts b/src/lib/timer/event.ts index c332728..81150fa 100644 --- a/src/lib/timer/event.ts +++ b/src/lib/timer/event.ts @@ -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; } export class TickEvent { @@ -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; + 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; } } diff --git a/src/lib/timer/tick.ts b/src/lib/timer/tick.ts index 3e59b0a..f40d4c6 100644 --- a/src/lib/timer/tick.ts +++ b/src/lib/timer/tick.ts @@ -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 { @@ -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()); + }); } } @@ -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); } }); } @@ -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); } } } @@ -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); } }); } @@ -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); } } } @@ -143,6 +157,14 @@ export class AudioClockTimer { window.requestAnimationFrame(this.#onAnimationFrame.bind(this)); } + #beforeStartCallbacks: Set<() => Promise> = new Set(); + beforeStart(cb: () => Promise) { + this.#beforeStartCallbacks.add(cb); + return () => this.removeStart(cb); + } + removeBeforeStart(cb: () => Promise) { + this.#beforeStartCallbacks.delete(cb); + } #startCallbacks: Set<() => any> = new Set(); onStart(cb: () => any) { this.#startCallbacks.add(cb); @@ -161,10 +183,14 @@ export class AudioClockTimer { } #schedules: MultiMap = new MultiMap(); + #cleanupSchedules: MultiMap = new MultiMap(); #events: Map = 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; } @@ -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); + } + } } } @@ -219,8 +253,8 @@ export class AudioClockTimer { type TimeUnit = 'tick' | 'note' | 'beat' | 'bar' | 'second' | 'millisecond'; export interface TempoSchedule { time: ScoreTimestamp; - cb?: WithCleanup; - audioCb?: AudioTickCallback; + animation?: WithCleanup; + audio?: AudioTickCallback; } export interface TempoState { bpm: number; @@ -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 = { + 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 + } }) ); }