Skip to content

Commit

Permalink
feat: add sound to metronome, and fix slow initial lookahead issue
Browse files Browse the repository at this point in the history
  • Loading branch information
threedalpeng committed Feb 16, 2024
1 parent 874b78d commit 3c6e9b7
Show file tree
Hide file tree
Showing 3 changed files with 55 additions and 19 deletions.
6 changes: 3 additions & 3 deletions src/lib/device/metronome/MetronomeBeats.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
const metronome = getMetronomeContext();
let beatPerBar = 4,
currentBeat = 0;
currentBeat = 1;
onMount(() => {
beatPerBar = metronome.state.beatPerBar;
currentBeat = metronome.state.currentBeat;
Expand All @@ -21,12 +21,12 @@
<div class="relative top-[60px] flex w-screen flex-row items-center justify-center gap-[40px]">
{#each new Array(beatPerBar) as _, i}
{#if i === 0}
{#if i === currentBeat}
{#if i === currentBeat - 1}
<div class="h-[30px] w-[30px] rounded-full bg-indigo-500" />
{:else}
<div class="h-[30px] w-[30px] rounded-full bg-indigo-900" />
{/if}
{:else if i === currentBeat}
{:else if i === currentBeat - 1}
<div class="h-[20px] w-[20px] rounded-full bg-indigo-500" />
{:else}
<div class="h-[20px] w-[20px] rounded-full bg-indigo-900" />
Expand Down
36 changes: 30 additions & 6 deletions src/lib/device/metronome/metronome.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import AudioTickTimer from '../../timer/tick';
import AudioTickTimer, { type AudioTickState, type TickState } from '../../timer/tick';

export interface MetronomeOption {
beatPerBar?: number;
Expand Down Expand Up @@ -78,20 +78,44 @@ class Metronome {

#currentBeat = 0;
#barPassed = 0;
onTick({ startSec }: { startSec: number }) {
onTick({ time, tickPassed }: TickState) {
console.log(tickPassed, 'in Tick');
this.#currentBeat = (tickPassed % 4) + 1;
this.#onBeatCallbacks.forEach((cb) => cb(this.state));
if (this.#currentBeat === 0) {
if (tickPassed % 4 === 0) {
this.#onBarCallbacks.forEach((cb) => cb(this.state));
}

this.#currentBeat++;
if (this.#currentBeat >= this.#beatPerBar) {
this.#barPassed++;
this.#currentBeat = 0;
}
}

scheduleAudio({ audioCtx, startSec }: { audioCtx: AudioContext; startSec: number }) {}
#masterGain: GainNode | null = null;
scheduleAudio({ audioCtx, time, tickPassed }: AudioTickState) {
console.log(tickPassed, 'in AudioTick');
if (!this.#masterGain) {
this.#masterGain = audioCtx.createGain();
this.#masterGain.connect(audioCtx.destination);
}

const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.connect(gain);
gain.connect(this.#masterGain);
if (tickPassed % 4 === 0) {
osc.frequency.value = 880;
} else {
osc.frequency.value = 440;
}
osc.start(time);
osc.stop(time + 0.1);
gain.gain.setValueAtTime(gain.gain.value, time + 0.01);
gain.gain.linearRampToValueAtTime(0.0001, time + 0.1);
osc.addEventListener('ended', () => {
gain.disconnect();
});
}

stop() {
if (this.#isRunning) {
Expand Down
32 changes: 22 additions & 10 deletions src/lib/timer/tick.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import TimerWorker from './timer-worker?worker';

export type AudioTickCallback = (state: { audioCtx: AudioContext; startSec: number }) => unknown;
export type TickCallback = (state: { startSec: number }) => unknown;
export interface TickState {
/** shows the order of current tick after startup */
tickPassed: number;
/** scheduled tick time, in seconds */
time: number;
}
export type AudioTickState = { audioCtx: AudioContext } & TickState;
export type TickCallback = (state: TickState) => unknown;
export type AudioTickCallback = (state: AudioTickState) => unknown;

const LOOKAHEAD_INTERVAL_MS = 100;
const SCHEDULE_AHEAD_SEC = 0.1;
Expand All @@ -22,6 +29,7 @@ class AudioTickTimer {

#isRunning = false;
#nextTick: number = 0;
#tickPassed: number = 0;
tickIntervalMs = 10;
get isRunning() {
return this.#isRunning;
Expand All @@ -38,21 +46,25 @@ class AudioTickTimer {
}
if (!this.#isRunning) {
this.#isRunning = true;
// fast initial lookhead
this.#onLookahead();
this.lookaheadTimer.postMessage('start');
this.#nextTick = this.audioCtx.currentTime;
this.#tickPassed = 0;
}
}

#tickQueue: number[] = [];
#tickQueue: TickState[] = [];
#onLookahead() {
// schedule audio
// and push expected events to queues
// in this case, metronome ticks will be queued
while (this.#nextTick < this.audioCtx!!.currentTime + SCHEDULE_AHEAD_SEC) {
this.#audioTickCallbacks.forEach((cb) =>
cb({ audioCtx: this.audioCtx!!, startSec: this.#nextTick })
cb({ audioCtx: this.audioCtx!!, time: this.#nextTick, tickPassed: this.#tickPassed })
);
this.#tickQueue.push(this.#nextTick);
this.#tickQueue.push({ time: this.#nextTick, tickPassed: this.#tickPassed });
this.#tickPassed += 1;
this.#nextTick += 0.001 * this.tickIntervalMs;
}
}
Expand All @@ -73,16 +85,16 @@ class AudioTickTimer {
this.#tickCallbacks.delete(cb);
}

#onAnimationFrame(time: DOMHighResTimeStamp) {
#onAnimationFrame() {
if (this.audioCtx) {
const currentTime = this.audioCtx.currentTime;
let nextTick = this.#tickQueue[0];
while (nextTick !== undefined && nextTick <= currentTime) {
let tickState = this.#tickQueue[0];
while (tickState !== undefined && tickState.time <= currentTime) {
this.#tickQueue.shift();
this.#tickCallbacks.forEach((cb) => {
cb({ startSec: nextTick!! });
cb(tickState);
});
nextTick = this.#tickQueue[0];
tickState = this.#tickQueue[0];
}
}
window.requestAnimationFrame(this.#onAnimationFrame.bind(this));
Expand Down

0 comments on commit 3c6e9b7

Please sign in to comment.