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
+38 -2
View File
@@ -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
*/
+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;
}
}
}