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