feat: added metronome feature

This commit is contained in:
Xiaohan-Tian
2026-04-19 17:00:10 -07:00
parent f0a2f06295
commit 0ac39068b2
5 changed files with 184 additions and 3 deletions
+16 -1
View File
@@ -29,6 +29,7 @@ import FileImportModal from './common/FileImportModal';
import OpenProjectModal from './common/OpenProjectModal'; import OpenProjectModal from './common/OpenProjectModal';
import { clearChatHistoryAndUI } from '../util/chatUtil'; import { clearChatHistoryAndUI } from '../util/chatUtil';
import PianoIcon from './common/icons/PianoIcon'; import PianoIcon from './common/icons/PianoIcon';
import MetronomeIcon from './common/icons/MetronomeIcon';
import { ConfigManager } from '../core/config/ConfigManager'; import { ConfigManager } from '../core/config/ConfigManager';
const Toolbar: React.FC = () => { const Toolbar: React.FC = () => {
@@ -42,7 +43,7 @@ const Toolbar: React.FC = () => {
barWidthMultiplier, setBarWidthMultiplier, barWidthMultiplier, setBarWidthMultiplier,
isLooping, toggleLoop, isLooping, toggleLoop,
canUndo, canRedo, undoDescription, redoDescription, undo, redo, canUndo, canRedo, undoDescription, redoDescription, undo, redo,
toggleChatBox, toggleSettings, toggleKGOnePanel, showKGOnePanel, cleanupProjectState, toggleChatBox, toggleSettings, toggleKGOnePanel, showKGOnePanel, cleanupProjectState, toggleMetronome, isMetronomeEnabled,
// Piano roll state/actions // Piano roll state/actions
showPianoRoll, setShowPianoRoll, activeRegionId, setActiveRegionId, showPianoRoll, setShowPianoRoll, activeRegionId, setActiveRegionId,
// Selection state // Selection state
@@ -858,6 +859,13 @@ const Toolbar: React.FC = () => {
} }
}; };
const handleMetronomeToggle = () => {
toggleMetronome();
if (DEBUG_MODE.TOOLBAR) {
console.log('Metronome toggled');
}
};
return ( return (
<> <>
<div className="toolbar"> <div className="toolbar">
@@ -949,6 +957,13 @@ const Toolbar: React.FC = () => {
<FaSync /> <FaSync />
</button> </button>
<div className="toolbar-separator"></div> <div className="toolbar-separator"></div>
<button
title="Metronome"
className={`tool-button ${isMetronomeEnabled ? 'active' : ''}`}
onClick={handleMetronomeToggle}
>
<MetronomeIcon />
</button>
<button title="Piano" onClick={handlePianoButtonClick}><PianoIcon /></button> <button title="Piano" onClick={handlePianoButtonClick}><PianoIcon /></button>
{/* <button title="Record"><FaCircle className="record-btn" /></button> {/* <button title="Record"><FaCircle className="record-btn" /></button>
<button title="Metronome">🎵</button> */} <button title="Metronome">🎵</button> */}
@@ -0,0 +1,41 @@
import React from 'react';
type MetronomeIconProps = React.SVGProps<SVGSVGElement>;
/**
* MetronomeIcon metronome silhouette in a Font Awesome-like solid style
* - Uses currentColor
* - Scales with font size (1em)
*/
const MetronomeIcon: React.FC<MetronomeIconProps> = (props) => (
<svg
viewBox="0 0 512 512"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
focusable="false"
{...props}
>
{/*
Trapezoidal body with hollow interior, pendulum arm, and weight.
fillRule=evenodd: regions covered by an odd number of sub-paths are filled.
- Outer trapezoid: 1 crossing → filled (solid walls)
- Inner hole: 2 crossings → transparent
- Pendulum: 3 crossings → filled (visible inside hole)
- Weight: 3 crossings → filled (visible inside hole, no overlap with pendulum)
*/}
<path
fillRule="evenodd"
clipRule="evenodd"
d="
M 208 80 L 304 80 L 432 464 L 80 464 Z
M 226 112 L 286 112 L 396 432 L 116 432 Z
M 250 112 L 262 112 L 244 330 L 232 330 Z
M 222 330 L 264 330 L 260 362 L 226 362 Z
"
/>
</svg>
);
export default MetronomeIcon;
@@ -10,6 +10,7 @@ import type { InstrumentType } from '../track/KGMidiTrack';
import type { KGAudioRegion } from '../region/KGAudioRegion'; import type { KGAudioRegion } from '../region/KGAudioRegion';
import { KGCore } from '../KGCore'; import { KGCore } from '../KGCore';
import { ConfigManager } from '../config/ConfigManager'; import { ConfigManager } from '../config/ConfigManager';
import { KGMetronome } from './KGMetronome';
/** /**
* KGAudioInterface - Audio engine interface for the DAW * KGAudioInterface - Audio engine interface for the DAW
@@ -45,6 +46,10 @@ export class KGAudioInterface {
// Master volume control // Master volume control
private masterGain: Tone.Gain | null = null; private masterGain: Tone.Gain | null = null;
// Metronome
private metronome: KGMetronome = new KGMetronome();
private isMetronomeEnabled = false;
// Audio capture for screen sharing // Audio capture for screen sharing
private captureDestination: MediaStreamAudioDestinationNode | null = null; private captureDestination: MediaStreamAudioDestinationNode | null = null;
private captureStream: MediaStream | 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.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 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 // Check config and setup audio capture if enabled
const enableCapture = configManager.get('audio.enable_audio_capture_for_screen_sharing') as boolean; const enableCapture = configManager.get('audio.enable_audio_capture_for_screen_sharing') as boolean;
@@ -144,6 +154,9 @@ export class KGAudioInterface {
}); });
this.trackAudioPlayerBuses.clear(); this.trackAudioPlayerBuses.clear();
// Dispose metronome
this.metronome.dispose();
// Dispose master gain // Dispose master gain
if (this.masterGain) { if (this.masterGain) {
this.masterGain.dispose(); this.masterGain.dispose();
@@ -430,6 +443,11 @@ export class KGAudioInterface {
// Set transport position (convert beats to Tone.js format) // Set transport position (convert beats to Tone.js format)
this.setTransportPosition(startPosition); this.setTransportPosition(startPosition);
// Start metronome if enabled
if (this.isMetronomeEnabled) {
this.metronome.start(startPosition, timeSignature.numerator, playbackDelay);
}
// Schedule all MIDI events // Schedule all MIDI events
project.getTracks().forEach(track => { project.getTracks().forEach(track => {
const trackId = track.getId().toString(); const trackId = track.getId().toString();
@@ -645,6 +663,7 @@ export class KGAudioInterface {
public stopPlayback(): void { public stopPlayback(): void {
try { try {
Tone.Transport.stop(); Tone.Transport.stop();
this.metronome.stop();
// Release all currently playing notes // Release all currently playing notes
this.trackAudioBuses.forEach(audioBus => { this.trackAudioBuses.forEach(audioBus => {
@@ -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 * Set transport BPM
*/ */
+68
View File
@@ -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<void> {
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;
}
}
}
+21
View File
@@ -59,6 +59,7 @@ interface ProjectState {
keySignature: KeySignature; keySignature: KeySignature;
selectedMode: string; selectedMode: string;
isLooping: boolean; isLooping: boolean;
isMetronomeEnabled: boolean;
loopingRange: [number, number]; // [startBar, endBar] - bar indices (0-based) loopingRange: [number, number]; // [startBar, endBar] - bar indices (0-based)
playheadPosition: number; // in beats playheadPosition: number; // in beats
isPlaying: boolean; isPlaying: boolean;
@@ -119,6 +120,7 @@ interface ProjectState {
startPlaying: () => Promise<void>; startPlaying: () => Promise<void>;
stopPlaying: () => Promise<void>; stopPlaying: () => Promise<void>;
toggleLoop: () => void; toggleLoop: () => void;
toggleMetronome: () => void;
setBpm: (bpm: number) => void; setBpm: (bpm: number) => void;
setMaxBars: (maxBars: number) => void; setMaxBars: (maxBars: number) => void;
setBarWidthMultiplier: (multiplier: number) => void; setBarWidthMultiplier: (multiplier: number) => void;
@@ -269,6 +271,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
keySignature: currentProject.getKeySignature(), keySignature: currentProject.getKeySignature(),
selectedMode: currentProject.getSelectedMode(), selectedMode: currentProject.getSelectedMode(),
isLooping: currentProject.getIsLooping(), isLooping: currentProject.getIsLooping(),
isMetronomeEnabled: false,
loopingRange: currentProject.getLoopingRange(), loopingRange: currentProject.getLoopingRange(),
playheadPosition: KGCore.instance().getPlayheadPosition(), playheadPosition: KGCore.instance().getPlayheadPosition(),
isPlaying: KGCore.instance().getIsPlaying(), isPlaying: KGCore.instance().getIsPlaying(),
@@ -780,6 +783,24 @@ export const useProjectStore = create<ProjectState>((set, get) => {
toggleLoop(isLooping, loopingRange, maxBars); 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) => { setBpm: (bpm: number) => {
try { try {
// Create and execute the change project property command // Create and execute the change project property command