feat: added metronome feature
This commit is contained in:
@@ -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();
|
||||||
@@ -429,7 +442,12 @@ 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,7 +663,8 @@ 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 => {
|
||||||
audioBus.releaseAll();
|
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
|
* 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;
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user