From 7883bdf76e6ba2b8741d91bbe8619e922706eba6 Mon Sep 17 00:00:00 2001 From: Kim Jeong-won Date: Mon, 19 Feb 2024 17:09:13 +0900 Subject: [PATCH 1/3] feat: add guitar sounds using smplr soundfont --- package-lock.json | 11 ++++++- package.json | 3 +- src/lib/timer/tick.ts | 2 +- src/routes/+layout.svelte | 1 - src/routes/tools/render-sample/+page.svelte | 35 ++++++++++++++++++--- 5 files changed, 44 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index ad28f65..7edb7db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "@steeze-ui/heroicons": "^2.3.0", "@steeze-ui/svelte-icon": "^1.5.0", "idb": "^8.0.0", - "mini-svg-data-uri": "^1.4.4" + "mini-svg-data-uri": "^1.4.4", + "smplr": "^0.12.2" }, "devDependencies": { "@sveltejs/adapter-auto": "^3.1.0", @@ -7777,6 +7778,14 @@ "node": ">=8" } }, + "node_modules/smplr": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/smplr/-/smplr-0.12.2.tgz", + "integrity": "sha512-fmJYIpFJVcoVCH5OtFg+UUf9aErFHk8RaRrJBkS78hNCKzdK1pFlF4KPR/mGerVAVKg78D3cSawQVk0mgiH0PA==", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/sorcery": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.11.0.tgz", diff --git a/package.json b/package.json index 5311e63..18947b4 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "@steeze-ui/heroicons": "^2.3.0", "@steeze-ui/svelte-icon": "^1.5.0", "idb": "^8.0.0", - "mini-svg-data-uri": "^1.4.4" + "mini-svg-data-uri": "^1.4.4", + "smplr": "^0.12.2" }, "devDependencies": { "@sveltejs/adapter-auto": "^3.1.0", diff --git a/src/lib/timer/tick.ts b/src/lib/timer/tick.ts index 30515f0..7f2cc5c 100644 --- a/src/lib/timer/tick.ts +++ b/src/lib/timer/tick.ts @@ -273,7 +273,7 @@ export class TempoTimer extends AudioClockTimer { } }; const onAudioTick: AudioTickCallback = (state) => { - if (currentTime + start >= state.time) { + if (currentTime + start <= state.time) { audioCb(state); this.removeAudioTick(onAudioTick); } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 40ad381..dfdb960 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,7 +1,6 @@ diff --git a/src/routes/tools/render-sample/+page.svelte b/src/routes/tools/render-sample/+page.svelte index 8a08c8a..1b74669 100644 --- a/src/routes/tools/render-sample/+page.svelte +++ b/src/routes/tools/render-sample/+page.svelte @@ -11,9 +11,10 @@ import RandomBoxOptions from '$/lib/practice/RandomBox/RandomBoxOptions.svelte'; import { getRandomBoxContext } from '$/lib/practice/RandomBox/context'; import type { PracticeBoard, PracticeScore } from '$/lib/practice/types'; - import { getPitchFromFingerPosition } from '$/utils/music/pitch'; + import { getPitchFromFingerPosition, numberingPitch } from '$/utils/music/pitch'; import { onDestroy, onMount } from 'svelte'; import { practice } from './data'; + import { Soundfont, CacheStorage } from 'smplr'; const metronome = getMetronomeContext(); const randomBox = getRandomBoxContext<(typeof practice.scores)[number]>(); @@ -25,7 +26,16 @@ function replaceScore() { let score = randomBox.open(); const schedule = () => { - scheduleScore(score); + if (!guitarSoundfont) { + guitarSoundfont = new Soundfont(timer.audioCtx!!, { + instrument: 'acoustic_guitar_steel', + storage: new CacheStorage() + }); + } + // preload soundfont + guitarSoundfont.load.then(() => { + scheduleScore(score); + }); timer.removeTick(schedule); }; timer.onTick(schedule); @@ -46,6 +56,9 @@ }; }) as FingerInfo[]; + let guitarSoundfont: Soundfont | null = null; + + let previousTime = 0; function scheduleScore(score: PracticeScore) { /** Now scheduling */ @@ -57,7 +70,11 @@ currentActiveFingers.clear(); currentBoard = board; }, - ({ audioCtx }) => {} + ({ audioCtx }) => { + // guitarSoundfont!!.start({ + // note: 50 + 12 + // }); + } ); }); @@ -69,6 +86,7 @@ ); return { ...note, pitch }; }); + for (let i = 0; i < notes.length; i++) { const note = notes[i]; const nextThreeFingers = notes.slice(i + 1, i + 4).map((n) => n.position); @@ -83,8 +101,17 @@ currentActiveFingers = currentActiveFingers; }; }, - ({ audioCtx }) => { + ({ audioCtx, time }) => { // play audio with pitch + if (note.pitch) { + guitarSoundfont!!.start({ + note: numberingPitch(note.pitch) + 12, + time: time, + duration: note.time.duration + ? timer.convert(note.time.duration, 'note', 'second') + : note.time.duration + }); + } } ); } From b974c2d08f48a90a8ac17eb3f64ace85518880ca Mon Sep 17 00:00:00 2001 From: Kim Jeong-won Date: Tue, 20 Feb 2024 16:56:40 +0900 Subject: [PATCH 2/3] fix: sync soundfont and metronome sound --- src/lib/device/metronome/metronome.ts | 36 ++++++++++--------- src/lib/timer/tick.ts | 38 +++++++++++++++++++++ src/routes/tools/render-sample/+page.svelte | 4 +-- 3 files changed, 60 insertions(+), 18 deletions(-) diff --git a/src/lib/device/metronome/metronome.ts b/src/lib/device/metronome/metronome.ts index c492c3d..3804f0a 100644 --- a/src/lib/device/metronome/metronome.ts +++ b/src/lib/device/metronome/metronome.ts @@ -23,8 +23,6 @@ class Metronome { this.beatPerBar = beatPerBar; this.bpm = bpm; this.timer.signatureUnit = signatureUnit; - this.timer.onTick(this.#onTick.bind(this)); - this.timer.onAudioTick(this.#scheduleAudio.bind(this)); } get beatPerBar() { @@ -60,20 +58,26 @@ class Metronome { get isRunning() { return this.#isRunning; } - start() { + start(): void { if (!this.#isRunning) { this.#isRunning = true; this.timer.start(); } } + #stopScheduling: (() => void) | null = null; + schedule() { + this.#stopScheduling = this.timer.loop( + { start: 0, duration: this.#notesPerBeat }, + this.#onTick.bind(this), + this.#scheduleAudio.bind(this) + ); + } + #currentBeat = 0; #barPassed = 0; #onTick({ time, tickPassed }: TickState) { - if (tickPassed % this.#ticksPerBeat !== 0) { - return; - } - this.#currentBeat = ((tickPassed / this.#ticksPerBeat) % this.timer.beatPerBar) + 1; + this.#currentBeat += 1; this.#onBeatCallbacks.forEach((cb) => cb(this.state)); if (this.#currentBeat % this.timer.beatPerBar === 1) { this.#onBarCallbacks.forEach((cb) => cb(this.state)); @@ -81,19 +85,16 @@ class Metronome { if (this.#currentBeat >= this.timer.beatPerBar) { this.#barPassed++; + this.#currentBeat = 0; } } #masterGain: GainNode | null = null; - get #ticksPerBeat() { - return this.timer.convert(1, 'beat', 'tick'); + get #notesPerBeat() { + return this.timer.convert(1, 'beat', 'note'); } + #currentBeatInAudioTick = 0; #scheduleAudio({ audioCtx, time, tickPassed }: AudioTickState) { - if (tickPassed % this.#ticksPerBeat !== 0) { - return; - } - const beatNum = (tickPassed / this.#ticksPerBeat) % this.timer.beatPerBar; - if (!this.#masterGain) { this.#masterGain = audioCtx.createGain(); this.#masterGain.connect(audioCtx.destination); @@ -103,7 +104,7 @@ class Metronome { const gain = audioCtx.createGain(); osc.connect(gain); gain.connect(this.#masterGain); - if (beatNum === 0) { + if (this.#currentBeatInAudioTick % this.timer.beatPerBar === 0) { osc.frequency.value = 880; } else { osc.frequency.value = 440; @@ -115,18 +116,21 @@ class Metronome { osc.addEventListener('ended', () => { gain.disconnect(); }); + this.#currentBeatInAudioTick += 1; } stop() { if (this.#isRunning) { this.#isRunning = false; this.timer.stop(); + if (this.#stopScheduling) this.#stopScheduling(); this.#currentBeat = 0; + this.#currentBeatInAudioTick = 0; this.#barPassed = 0; } } - toggle() { + toggle(): void { if (this.#isRunning) { this.stop(); } else { diff --git a/src/lib/timer/tick.ts b/src/lib/timer/tick.ts index 7f2cc5c..1784816 100644 --- a/src/lib/timer/tick.ts +++ b/src/lib/timer/tick.ts @@ -64,6 +64,7 @@ export class AudioClockTimer { // delay initial lookhead this.#nextTick = this.audioCtx.currentTime + 0.1; this.lookaheadTimer.postMessage('start'); + this.#startCallbacks.forEach((cb) => cb()); this.#tickPassed = 0; } } @@ -83,6 +84,14 @@ export class AudioClockTimer { } } + #startCallbacks: Set<() => any> = new Set(); + onStart(cb: () => any) { + this.#startCallbacks.add(cb); + } + removeStart(cb: () => any) { + this.#startCallbacks.delete(cb); + } + #audioTickCallbacks: Set = new Set(); onAudioTick(cb: AudioTickCallback) { this.#audioTickCallbacks.add(cb); @@ -285,4 +294,33 @@ export class TempoTimer extends AudioClockTimer { } this.onAudioTick(onAudioTick); } + + loop(time: { start: number; duration?: number }, cb: TickCallback, audioCb: AudioTickCallback) { + const start = this.convert(time.start, 'note', 'second'); + const duration = time.duration ? this.convert(time.duration, 'note', 'second') : -1; + + let currentTime = this.currentTime; + let tickSchedule = currentTime + start; + let audioTickSchedule = currentTime + start; + const onStart: TickCallback = (state) => { + while (tickSchedule <= state.time) { + cb(state); + tickSchedule += duration; + } + }; + const onAudioTick: AudioTickCallback = (state) => { + if (audioTickSchedule <= state.time) { + audioCb(state); + audioTickSchedule += duration; + } + }; + const stop = () => { + this.removeAudioTick(onAudioTick); + this.removeTick(onStart); + }; + + this.onTick(onStart); + this.onAudioTick(onAudioTick); + return stop; + } } diff --git a/src/routes/tools/render-sample/+page.svelte b/src/routes/tools/render-sample/+page.svelte index 1b74669..7f2fbe0 100644 --- a/src/routes/tools/render-sample/+page.svelte +++ b/src/routes/tools/render-sample/+page.svelte @@ -34,11 +34,11 @@ } // preload soundfont guitarSoundfont.load.then(() => { + metronome.schedule(); scheduleScore(score); }); - timer.removeTick(schedule); }; - timer.onTick(schedule); + timer.onStart(schedule); return score; } From 9b13b39179b04705db8fb413a3459423642e0344 Mon Sep 17 00:00:00 2001 From: Kim Jeong-won Date: Tue, 20 Feb 2024 21:53:31 +0900 Subject: [PATCH 3/3] fix: clear schedule after stop --- src/lib/device/metronome/metronome.ts | 10 ++- src/lib/timer/tick.ts | 9 ++- src/routes/tools/render-sample/data.ts | 104 ++++++++++++++++++++++++- 3 files changed, 118 insertions(+), 5 deletions(-) diff --git a/src/lib/device/metronome/metronome.ts b/src/lib/device/metronome/metronome.ts index 3804f0a..9337664 100644 --- a/src/lib/device/metronome/metronome.ts +++ b/src/lib/device/metronome/metronome.ts @@ -124,6 +124,7 @@ class Metronome { this.#isRunning = false; this.timer.stop(); if (this.#stopScheduling) this.#stopScheduling(); + this.clearSchedule(); this.#currentBeat = 0; this.#currentBeatInAudioTick = 0; this.#barPassed = 0; @@ -168,10 +169,15 @@ class Metronome { this.#onOptionChangeCallbacks.delete(cb); } - destroy() { - this.stop(); + clearSchedule() { + this.timer.clearSchedule(); this.#onBeatCallbacks.clear(); this.#onBarCallbacks.clear(); + } + + destroy() { + this.stop(); + this.clearSchedule(); this.#onOptionChangeCallbacks.clear(); } } diff --git a/src/lib/timer/tick.ts b/src/lib/timer/tick.ts index 1784816..e4ddda0 100644 --- a/src/lib/timer/tick.ts +++ b/src/lib/timer/tick.ts @@ -60,11 +60,11 @@ export class AudioClockTimer { node.start(0); } if (!this.#isRunning) { + this.#startCallbacks.forEach((cb) => cb()); this.#isRunning = true; // delay initial lookhead this.#nextTick = this.audioCtx.currentTime + 0.1; this.lookaheadTimer.postMessage('start'); - this.#startCallbacks.forEach((cb) => cb()); this.#tickPassed = 0; } } @@ -144,10 +144,15 @@ export class AudioClockTimer { } } + clearSchedule() { + this.#audioTickCallbacks.clear(); + this.#tickCallbacks.clear(); + } + destroy() { - this.stop(); this.#audioTickCallbacks.clear(); this.#tickCallbacks.clear(); + this.stop(); } } diff --git a/src/routes/tools/render-sample/data.ts b/src/routes/tools/render-sample/data.ts index f075126..002816d 100644 --- a/src/routes/tools/render-sample/data.ts +++ b/src/routes/tools/render-sample/data.ts @@ -137,10 +137,112 @@ const practices: Record = { } } ] + }, + 'rhythm-test': { + tempo: { + bpm: 120, + beatPerBar: 4, + signatureUnit: 4 + }, + guitar: { + tuning: TUNE.standard + }, + scores: [ + { + positions: [ + { line: 5, fret: 3 }, + { line: 6, fret: 3 }, + { line: 1, fret: 'open' }, + { line: 2, fret: 1 }, + { line: 3, fret: 'open' } + ], + notes: [ + { position: 0, time: { start: 0, duration: 1 / 4 } }, + { position: 2, time: { start: 1 / 4, duration: 1 / 4 } }, + { position: 3, time: { start: 1 / 4, duration: 1 / 4 } }, + { position: 4, time: { start: 1 / 4, duration: 1 / 4 } }, + { position: 1, time: { start: 2 / 4, duration: 1 / 4 } }, + { position: 2, time: { start: 3 / 4, duration: 1 / 4 } }, + { position: 3, time: { start: 3 / 4, duration: 1 / 4 } }, + { position: 4, time: { start: 3 / 4, duration: 1 / 4 } }, + { position: 0, time: { start: 4 / 4, duration: 1 / 4 } }, + { position: 2, time: { start: 5 / 4, duration: 1 / 4 } }, + { position: 3, time: { start: 5 / 4, duration: 1 / 4 } }, + { position: 4, time: { start: 5 / 4, duration: 1 / 4 } }, + { position: 1, time: { start: 6 / 4, duration: 1 / 4 } }, + { position: 2, time: { start: 7 / 4, duration: 1 / 4 } }, + { position: 3, time: { start: 7 / 4, duration: 1 / 4 } }, + { position: 4, time: { start: 7 / 4, duration: 1 / 4 } }, + { position: 0, time: { start: 8 / 4, duration: 1 / 4 } }, + { position: 2, time: { start: 9 / 4, duration: 1 / 4 } }, + { position: 3, time: { start: 9 / 4, duration: 1 / 4 } }, + { position: 4, time: { start: 9 / 4, duration: 1 / 4 } }, + { position: 1, time: { start: 10 / 4, duration: 1 / 4 } }, + { position: 2, time: { start: 11 / 4, duration: 1 / 4 } }, + { position: 3, time: { start: 11 / 4, duration: 1 / 4 } }, + { position: 4, time: { start: 11 / 4, duration: 1 / 4 } }, + { position: 0, time: { start: 12 / 4, duration: 1 / 4 } }, + { position: 2, time: { start: 13 / 4, duration: 1 / 4 } }, + { position: 3, time: { start: 13 / 4, duration: 1 / 4 } }, + { position: 4, time: { start: 13 / 4, duration: 1 / 4 } }, + { position: 1, time: { start: 14 / 4, duration: 1 / 4 } }, + { position: 2, time: { start: 15 / 4, duration: 1 / 4 } }, + { position: 3, time: { start: 15 / 4, duration: 1 / 4 } }, + { position: 4, time: { start: 15 / 4, duration: 1 / 4 } } + ], + boards: [ + { + title: 'C line 1', + fingers: [0, 2, 3, 4], + time: { start: 0 } + }, + { + title: 'C line 1', + fingers: [1, 2, 3, 4], + time: { start: 1 / 2 } + }, + { + title: 'C line 1', + fingers: [0, 2, 3, 4], + time: { start: 2 / 2 } + }, + { + title: 'C line 1', + fingers: [1, 2, 3, 4], + time: { start: 3 / 2 } + }, + { + title: 'C line 1', + fingers: [0, 2, 3, 4], + time: { start: 4 / 2 } + }, + { + title: 'C line 1', + fingers: [1, 2, 3, 4], + time: { start: 5 / 2 } + }, + { + title: 'C line 1', + fingers: [0, 2, 3, 4], + time: { start: 6 / 2 } + }, + { + title: 'C line 1', + fingers: [1, 2, 3, 4], + time: { start: 7 / 2 } + } + ], + fretRange: { + start: 0, + end: 12, + visibility: 'all' + } + } + ] } }; -export const practice: Practice = practices['major-scale']; +export const practice: Practice = practices['rhythm-test']; export const items = [ {