From 0ac39068b251d5d4c504c89c3f639a3ede3c4ee9 Mon Sep 17 00:00:00 2001
From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com>
Date: Sun, 19 Apr 2026 17:00:10 -0700
Subject: [PATCH] feat: added metronome feature
---
src/components/Toolbar.tsx | 17 ++++-
src/components/common/icons/MetronomeIcon.tsx | 41 +++++++++++
src/core/audio-interface/KGAudioInterface.ts | 40 ++++++++++-
src/core/audio-interface/KGMetronome.ts | 68 +++++++++++++++++++
src/stores/projectStore.ts | 21 ++++++
5 files changed, 184 insertions(+), 3 deletions(-)
create mode 100644 src/components/common/icons/MetronomeIcon.tsx
create mode 100644 src/core/audio-interface/KGMetronome.ts
diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx
index e2e89a8..c0d2cb0 100644
--- a/src/components/Toolbar.tsx
+++ b/src/components/Toolbar.tsx
@@ -29,6 +29,7 @@ import FileImportModal from './common/FileImportModal';
import OpenProjectModal from './common/OpenProjectModal';
import { clearChatHistoryAndUI } from '../util/chatUtil';
import PianoIcon from './common/icons/PianoIcon';
+import MetronomeIcon from './common/icons/MetronomeIcon';
import { ConfigManager } from '../core/config/ConfigManager';
const Toolbar: React.FC = () => {
@@ -42,7 +43,7 @@ const Toolbar: React.FC = () => {
barWidthMultiplier, setBarWidthMultiplier,
isLooping, toggleLoop,
canUndo, canRedo, undoDescription, redoDescription, undo, redo,
- toggleChatBox, toggleSettings, toggleKGOnePanel, showKGOnePanel, cleanupProjectState,
+ toggleChatBox, toggleSettings, toggleKGOnePanel, showKGOnePanel, cleanupProjectState, toggleMetronome, isMetronomeEnabled,
// Piano roll state/actions
showPianoRoll, setShowPianoRoll, activeRegionId, setActiveRegionId,
// Selection state
@@ -858,6 +859,13 @@ const Toolbar: React.FC = () => {
}
};
+ const handleMetronomeToggle = () => {
+ toggleMetronome();
+ if (DEBUG_MODE.TOOLBAR) {
+ console.log('Metronome toggled');
+ }
+ };
+
return (
<>
@@ -949,6 +957,13 @@ const Toolbar: React.FC = () => {
+
{/*
*/}
diff --git a/src/components/common/icons/MetronomeIcon.tsx b/src/components/common/icons/MetronomeIcon.tsx
new file mode 100644
index 0000000..4ac129a
--- /dev/null
+++ b/src/components/common/icons/MetronomeIcon.tsx
@@ -0,0 +1,41 @@
+import React from 'react';
+
+type MetronomeIconProps = React.SVGProps
;
+
+/**
+ * MetronomeIcon – metronome silhouette in a Font Awesome-like solid style
+ * - Uses currentColor
+ * - Scales with font size (1em)
+ */
+const MetronomeIcon: React.FC = (props) => (
+
+);
+
+export default MetronomeIcon;
diff --git a/src/core/audio-interface/KGAudioInterface.ts b/src/core/audio-interface/KGAudioInterface.ts
index 17ccbbb..1c8f600 100644
--- a/src/core/audio-interface/KGAudioInterface.ts
+++ b/src/core/audio-interface/KGAudioInterface.ts
@@ -10,6 +10,7 @@ import type { InstrumentType } from '../track/KGMidiTrack';
import type { KGAudioRegion } from '../region/KGAudioRegion';
import { KGCore } from '../KGCore';
import { ConfigManager } from '../config/ConfigManager';
+import { KGMetronome } from './KGMetronome';
/**
* KGAudioInterface - Audio engine interface for the DAW
@@ -45,6 +46,10 @@ export class KGAudioInterface {
// Master volume control
private masterGain: Tone.Gain | null = null;
+ // Metronome
+ private metronome: KGMetronome = new KGMetronome();
+ private isMetronomeEnabled = false;
+
// Audio capture for screen sharing
private captureDestination: MediaStreamAudioDestinationNode | null = null;
private captureStream: MediaStream | null = null;
@@ -88,6 +93,11 @@ export class KGAudioInterface {
Tone.Transport.bpm.value = TIME_CONSTANTS.DEFAULT_BPM; // Default BPM
Tone.Transport.timeSignature = [TIME_CONSTANTS.DEFAULT_TIME_SIGNATURE.numerator, TIME_CONSTANTS.DEFAULT_TIME_SIGNATURE.denominator]; // Default time signature
+ // Initialize metronome sampler in background (non-blocking)
+ this.metronome.initialize(this.masterGain!).catch(err => {
+ console.error('Failed to initialize metronome:', err);
+ });
+
// Check config and setup audio capture if enabled
const enableCapture = configManager.get('audio.enable_audio_capture_for_screen_sharing') as boolean;
@@ -144,6 +154,9 @@ export class KGAudioInterface {
});
this.trackAudioPlayerBuses.clear();
+ // Dispose metronome
+ this.metronome.dispose();
+
// Dispose master gain
if (this.masterGain) {
this.masterGain.dispose();
@@ -429,7 +442,12 @@ export class KGAudioInterface {
// Set transport position (convert beats to Tone.js format)
this.setTransportPosition(startPosition);
-
+
+ // Start metronome if enabled
+ if (this.isMetronomeEnabled) {
+ this.metronome.start(startPosition, timeSignature.numerator, playbackDelay);
+ }
+
// Schedule all MIDI events
project.getTracks().forEach(track => {
const trackId = track.getId().toString();
@@ -645,7 +663,8 @@ export class KGAudioInterface {
public stopPlayback(): void {
try {
Tone.Transport.stop();
-
+ this.metronome.stop();
+
// Release all currently playing notes
this.trackAudioBuses.forEach(audioBus => {
audioBus.releaseAll();
@@ -791,6 +810,23 @@ export class KGAudioInterface {
}
}
+ // ===== METRONOME =====
+
+ public setMetronomeEnabled(enabled: boolean): void {
+ this.isMetronomeEnabled = enabled;
+ }
+
+ /** Start the metronome mid-playback without restarting the transport. */
+ public startMetronomeDuringPlayback(currentPositionBeats: number, beatsPerBar: number): void {
+ const playbackDelay = (ConfigManager.instance().get('audio.playback_delay') as number) ?? 0.2;
+ this.metronome.start(currentPositionBeats, beatsPerBar, playbackDelay);
+ }
+
+ /** Stop the metronome mid-playback without stopping the transport. */
+ public stopMetronomeDuringPlayback(): void {
+ this.metronome.stop();
+ }
+
/**
* Set transport BPM
*/
diff --git a/src/core/audio-interface/KGMetronome.ts b/src/core/audio-interface/KGMetronome.ts
new file mode 100644
index 0000000..f8025af
--- /dev/null
+++ b/src/core/audio-interface/KGMetronome.ts
@@ -0,0 +1,68 @@
+import * as Tone from 'tone';
+import { KGToneSamplerFactory } from './KGToneSamplerFactory';
+
+/**
+ * KGMetronome - click track synchronized with Tone.Transport
+ * Uses Tone.Loop so it automatically respects loop points and play position.
+ */
+export class KGMetronome {
+ private loop: Tone.Loop | null = null;
+ private sampler: Tone.Sampler | null = null;
+
+ /**
+ * Load the woodblock sampler. Called once from KGAudioInterface.initialize() —
+ * runs in background (caller should not await).
+ */
+ async initialize(masterOutput: Tone.ToneAudioNode): Promise {
+ try {
+ this.sampler = await KGToneSamplerFactory.instance().createSampler('woodblock');
+ this.sampler.connect(masterOutput);
+ console.log('KGMetronome: woodblock sampler loaded');
+ } catch (error) {
+ console.error('KGMetronome: failed to load woodblock sampler', error);
+ }
+ }
+
+ /**
+ * Start the metronome click loop.
+ * @param startPositionBeats - Transport start position in beats (unused beyond logging)
+ * @param beatsPerBar - numerator of the time signature
+ * @param playbackDelay - seconds to offset audio trigger, matching MIDI note scheduling delay
+ */
+ start(startPositionBeats: number, beatsPerBar: number, playbackDelay = 0): void {
+ this.stop();
+
+ const ppq = Tone.Transport.PPQ;
+
+ this.loop = new Tone.Loop((time) => {
+ if (this.sampler?.loaded) {
+ // Derive bar position from the exact Transport tick count at the
+ // scheduled audio time — no beatCount tracking needed, which avoids
+ // all phase initialisation errors when playing from mid-bar.
+ const ticks = Tone.Transport.getTicksAtTime(time);
+ const beatNumber = Math.round(ticks / ppq);
+ const note = beatNumber % beatsPerBar === 0 ? 'C5' : 'C4';
+ this.sampler.triggerAttackRelease(note, '16n', time + playbackDelay);
+ }
+ }, '4n');
+
+ this.loop.start(0);
+ console.log(`KGMetronome: started at beat ${startPositionBeats} (${beatsPerBar} beats/bar), delay ${playbackDelay}s`);
+ }
+
+ /** Stop and dispose the loop only — sampler is kept alive for reuse. */
+ stop(): void {
+ if (this.loop) {
+ this.loop.dispose();
+ this.loop = null;
+ }
+ }
+
+ dispose(): void {
+ this.stop();
+ if (this.sampler) {
+ this.sampler.dispose();
+ this.sampler = null;
+ }
+ }
+}
diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts
index 1756bf8..b7e0d7f 100644
--- a/src/stores/projectStore.ts
+++ b/src/stores/projectStore.ts
@@ -59,6 +59,7 @@ interface ProjectState {
keySignature: KeySignature;
selectedMode: string;
isLooping: boolean;
+ isMetronomeEnabled: boolean;
loopingRange: [number, number]; // [startBar, endBar] - bar indices (0-based)
playheadPosition: number; // in beats
isPlaying: boolean;
@@ -119,6 +120,7 @@ interface ProjectState {
startPlaying: () => Promise;
stopPlaying: () => Promise;
toggleLoop: () => void;
+ toggleMetronome: () => void;
setBpm: (bpm: number) => void;
setMaxBars: (maxBars: number) => void;
setBarWidthMultiplier: (multiplier: number) => void;
@@ -269,6 +271,7 @@ export const useProjectStore = create((set, get) => {
keySignature: currentProject.getKeySignature(),
selectedMode: currentProject.getSelectedMode(),
isLooping: currentProject.getIsLooping(),
+ isMetronomeEnabled: false,
loopingRange: currentProject.getLoopingRange(),
playheadPosition: KGCore.instance().getPlayheadPosition(),
isPlaying: KGCore.instance().getIsPlaying(),
@@ -780,6 +783,24 @@ export const useProjectStore = create((set, get) => {
toggleLoop(isLooping, loopingRange, maxBars);
},
+ toggleMetronome: () => {
+ const { isMetronomeEnabled, isPlaying, timeSignature } = get();
+ const newValue = !isMetronomeEnabled;
+ set({ isMetronomeEnabled: newValue });
+
+ const audio = KGAudioInterface.instance();
+ audio.setMetronomeEnabled(newValue);
+
+ if (isPlaying) {
+ if (newValue) {
+ const currentBeat = KGCore.instance().getPlayheadPosition();
+ audio.startMetronomeDuringPlayback(currentBeat, timeSignature.numerator);
+ } else {
+ audio.stopMetronomeDuringPlayback();
+ }
+ }
+ },
+
setBpm: (bpm: number) => {
try {
// Create and execute the change project property command