feat: added metronome feature
This commit is contained in:
@@ -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 (
|
||||
<>
|
||||
<div className="toolbar">
|
||||
@@ -949,6 +957,13 @@ const Toolbar: React.FC = () => {
|
||||
<FaSync />
|
||||
</button>
|
||||
<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="Record"><FaCircle className="record-btn" /></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 { 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();
|
||||
@@ -430,6 +443,11 @@ 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,6 +663,7 @@ export class KGAudioInterface {
|
||||
public stopPlayback(): void {
|
||||
try {
|
||||
Tone.Transport.stop();
|
||||
this.metronome.stop();
|
||||
|
||||
// Release all currently playing notes
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<void>;
|
||||
stopPlaying: () => Promise<void>;
|
||||
toggleLoop: () => void;
|
||||
toggleMetronome: () => void;
|
||||
setBpm: (bpm: number) => void;
|
||||
setMaxBars: (maxBars: number) => void;
|
||||
setBarWidthMultiplier: (multiplier: number) => void;
|
||||
@@ -269,6 +271,7 @@ export const useProjectStore = create<ProjectState>((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<ProjectState>((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
|
||||
|
||||
Reference in New Issue
Block a user