= ({ isVisible }) => {
-
-
-
+
+
+
{!activeMidiRegion ? (
@@ -587,14 +757,6 @@ const ListEventPanel: React.FC
= ({ isVisible }) => {
>
-
@@ -638,113 +800,118 @@ const ListEventPanel: React.FC = ({ isVisible }) => {
- {noteRows.map((row, index) => (
- (() => {
- const positionText = formatMidiEventPosition(row.absoluteStartBeat, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
- const statusText = 'Note';
- const noteText = pitchToNoteNameString(row.note.getPitch());
- const velocityText = String(row.note.getVelocity());
- const lengthText = formatMidiEventLength(row.durationBeats, MIDI_EVENT_TICKS_PER_BEAT);
- const isEditingPosition = editingCell?.noteId === row.id && editingCell.column === 'position';
- const isEditingNum = editingCell?.noteId === row.id && editingCell.column === 'num';
- const isEditingVal = editingCell?.noteId === row.id && editingCell.column === 'val';
- const isEditingLength = editingCell?.noteId === row.id && editingCell.column === 'length';
+ {eventRows.map((row, index) => {
+ const absoluteBeat = row.type === 'note' ? row.absoluteStartBeat : row.absoluteBeat;
+ const positionText = formatMidiEventPosition(absoluteBeat, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
+ const statusText = row.type === 'note' ? 'Note' : 'Pitch Bend';
+ const numText = row.type === 'note' ? pitchToNoteNameString(row.note.getPitch()) : '';
+ const valText = row.type === 'note'
+ ? String(row.note.getVelocity())
+ : String(midiPitchBendToSignedValue(row.pitchBend.getValue()));
+ const lengthText = row.type === 'note'
+ ? formatMidiEventLength(row.durationBeats, MIDI_EVENT_TICKS_PER_BEAT)
+ : formatPitchBendInfo(row.pitchBend.getValue());
+ const isEditingPosition = editingCell?.eventId === row.id && editingCell.column === 'position';
+ const isEditingNum = editingCell?.eventId === row.id && editingCell.column === 'num';
+ const isEditingVal = editingCell?.eventId === row.id && editingCell.column === 'val';
+ const isEditingLength = editingCell?.eventId === row.id && editingCell.column === 'length';
- return (
- handleRowClick(row.id, index, event)}
+ return (
+
handleRowClick(row.id, index, event)}
+ onDoubleClick={(event) => {
+ event.stopPropagation();
+ clearPendingSingleClickSelection();
+ }}
+ >
+ | {
event.stopPropagation();
- clearPendingSingleClickSelection();
+ startEditingCell(row.id, 'position', positionText);
}}
>
- | {
- event.stopPropagation();
- startEditingCell(row.id, 'position', positionText);
- }}
- >
- {isEditingPosition ? (
- setEditingCell({ ...editingCell, value: event.target.value })}
- onBlur={handleEditInputBlur}
- onClick={(event) => event.stopPropagation()}
- onDoubleClick={(event) => event.stopPropagation()}
- onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
- />
- ) : positionText}
- |
- {statusText} |
- {
- event.stopPropagation();
- startEditingCell(row.id, 'num', noteText);
- }}
- >
- {isEditingNum ? (
- setEditingCell({ ...editingCell, value: event.target.value })}
- onBlur={handleEditInputBlur}
- onClick={(event) => event.stopPropagation()}
- onDoubleClick={(event) => event.stopPropagation()}
- onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
- />
- ) : noteText}
- |
- {
- event.stopPropagation();
- startEditingCell(row.id, 'val', velocityText);
- }}
- >
- {isEditingVal ? (
- setEditingCell({ ...editingCell, value: event.target.value })}
- onBlur={handleEditInputBlur}
- onClick={(event) => event.stopPropagation()}
- onDoubleClick={(event) => event.stopPropagation()}
- onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
- />
- ) : velocityText}
- |
- {
- event.stopPropagation();
- startEditingCell(row.id, 'length', lengthText);
- }}
- >
- {isEditingLength ? (
- setEditingCell({ ...editingCell, value: event.target.value })}
- onBlur={handleEditInputBlur}
- onClick={(event) => event.stopPropagation()}
- onDoubleClick={(event) => event.stopPropagation()}
- onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
- />
- ) : lengthText}
- |
-
- );
- })()
- ))}
+ {isEditingPosition ? (
+ setEditingCell({ ...editingCell, value: event.target.value })}
+ onBlur={handleEditInputBlur}
+ onClick={(event) => event.stopPropagation()}
+ onDoubleClick={(event) => event.stopPropagation()}
+ onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
+ />
+ ) : positionText}
+
+ {statusText} |
+ {
+ if (row.type !== 'note') return;
+ event.stopPropagation();
+ startEditingCell(row.id, 'num', numText);
+ }}
+ >
+ {isEditingNum ? (
+ setEditingCell({ ...editingCell, value: event.target.value })}
+ onBlur={handleEditInputBlur}
+ onClick={(event) => event.stopPropagation()}
+ onDoubleClick={(event) => event.stopPropagation()}
+ onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
+ />
+ ) : numText}
+ |
+ {
+ event.stopPropagation();
+ startEditingCell(row.id, 'val', valText);
+ }}
+ >
+ {isEditingVal ? (
+ setEditingCell({ ...editingCell, value: event.target.value })}
+ onBlur={handleEditInputBlur}
+ onClick={(event) => event.stopPropagation()}
+ onDoubleClick={(event) => event.stopPropagation()}
+ onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
+ />
+ ) : valText}
+ |
+ {
+ if (row.type !== 'note') return;
+ event.stopPropagation();
+ startEditingCell(row.id, 'length', lengthText);
+ }}
+ >
+ {isEditingLength ? (
+ setEditingCell({ ...editingCell, value: event.target.value })}
+ onBlur={handleEditInputBlur}
+ onClick={(event) => event.stopPropagation()}
+ onDoubleClick={(event) => event.stopPropagation()}
+ onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
+ />
+ ) : lengthText}
+ |
+
+ );
+ })}
diff --git a/src/core/KGCore.ts b/src/core/KGCore.ts
index fab304c..20bf703 100644
--- a/src/core/KGCore.ts
+++ b/src/core/KGCore.ts
@@ -6,6 +6,7 @@ import { KGProjectStorage } from './io/KGProjectStorage';
import { KGConfigUpgrader } from './config-upgrader/KGConfigUpgrader';
import { KGMidiRegion } from './region/KGMidiRegion';
import { KGMidiNote } from './midi/KGMidiNote';
+import { KGMidiPitchBend } from './midi/KGMidiPitchBend';
import { KGRegion } from './region/KGRegion';
import { generateUniqueId } from '../util/miscUtil';
import { KGCommand, KGCommandHistory } from './commands';
@@ -551,6 +552,13 @@ export class KGCore {
);
clonedRegion.addNote(clonedNote);
});
+ region.getPitchBends().forEach(pitchBend => {
+ clonedRegion.addPitchBend(new KGMidiPitchBend(
+ generateUniqueId('KGMidiPitchBend'),
+ pitchBend.getBeat(),
+ pitchBend.getValue()
+ ));
+ });
clonedItems.push(clonedRegion);
break;
diff --git a/src/core/audio-interface/KGAudioBus.test.ts b/src/core/audio-interface/KGAudioBus.test.ts
new file mode 100644
index 0000000..f5f8b88
--- /dev/null
+++ b/src/core/audio-interface/KGAudioBus.test.ts
@@ -0,0 +1,69 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { MockBufferSource, MockSampler } from '../../test/mocks/tone';
+
+vi.mock('tone', async () => {
+ const { ToneMock } = await import('../../test/mocks/tone');
+ return ToneMock;
+});
+
+const getToneAudioBuffersMock = vi.fn();
+const createSamplerMock = vi.fn();
+
+vi.mock('./KGToneBuffersPool', () => ({
+ KGToneBuffersPool: {
+ instance: () => ({
+ getToneAudioBuffers: getToneAudioBuffersMock,
+ }),
+ },
+}));
+
+vi.mock('./KGToneSamplerFactory', () => ({
+ KGToneSamplerFactory: {
+ instance: () => ({
+ createSampler: createSamplerMock,
+ }),
+ },
+}));
+
+import { KGAudioBus } from './KGAudioBus';
+
+describe('KGAudioBus live MIDI pitch bend', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ MockSampler.mockClear();
+ MockBufferSource.mockClear();
+
+ const sampler = MockSampler();
+ createSamplerMock.mockResolvedValue(sampler);
+ getToneAudioBuffersMock.mockResolvedValue({
+ loaded: true,
+ has: (key: string) => key === 'C4',
+ get: (key: string) => key === 'C4' ? { duration: 1 } : undefined,
+ });
+ });
+
+ it('retunes held live MIDI notes when pitch bend changes', async () => {
+ const audioBus = await KGAudioBus.create('acoustic_grand_piano');
+
+ audioBus.triggerLiveMidiAttack(60, 0, 1);
+
+ expect(MockBufferSource).toHaveBeenCalledTimes(1);
+ const source = MockBufferSource.mock.results[0].value;
+ expect(source.start).toHaveBeenCalledWith(0, 0, 1, 1);
+ expect(source.playbackRate.value).toBeCloseTo(1, 5);
+
+ audioBus.setLiveMidiPitchBend(1);
+
+ expect(source.playbackRate.value).toBeCloseTo(Math.pow(2, 2 / 12), 5);
+ });
+
+ it('stops active live MIDI sources on release and reset paths', async () => {
+ const audioBus = await KGAudioBus.create('acoustic_grand_piano');
+
+ audioBus.triggerLiveMidiAttack(60, 0, 0.5);
+ const source = MockBufferSource.mock.results[0].value;
+
+ audioBus.releaseLiveMidiNote(60, 1.25);
+ expect(source.stop).toHaveBeenCalledWith(1.25);
+ });
+});
diff --git a/src/core/audio-interface/KGAudioBus.ts b/src/core/audio-interface/KGAudioBus.ts
index af6679e..6f6e7c5 100644
--- a/src/core/audio-interface/KGAudioBus.ts
+++ b/src/core/audio-interface/KGAudioBus.ts
@@ -1,24 +1,39 @@
import * as Tone from 'tone';
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
+import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
import type { InstrumentType } from '../track/KGMidiTrack';
+import { KGToneBuffersPool } from './KGToneBuffersPool';
import { KGToneSamplerFactory } from './KGToneSamplerFactory';
// InstrumentType is defined in KGMidiTrack and re-used here
+interface LiveMidiSource {
+ source: Tone.ToneBufferSource;
+ basePlaybackRate: number;
+}
+
/**
* KGAudioBus - Represents a complete audio bus for a track
* Replaces the separate trackSynths, trackInstruments, trackVolumes, trackMuted, trackSolo maps
* Each instance manages a single track's audio processing chain
*/
export class KGAudioBus {
+ // Fixed at +/-2 semitones for now. Future work: make this user-configurable
+ // or honor MIDI RPN 0,0 (Pitch Bend Sensitivity).
+ private static readonly LIVE_MIDI_PITCH_BEND_RANGE_SEMITONES = 2;
+ private static readonly LIVE_MIDI_NOTE_NAMES = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B'];
+
// Core audio components
private sampler: Tone.Sampler;
+ private audioBuffers: Tone.ToneAudioBuffers;
private instrument: InstrumentType;
// Audio properties
private volume: number;
private muted: boolean;
private solo: boolean;
+ private liveMidiPitchBend: number = 0;
+ private liveMidiSources: Map = new Map();
// Audio processing chain (for future expansion)
// private gain: Tone.Gain;
@@ -29,12 +44,14 @@ export class KGAudioBus {
*/
private constructor(
sampler: Tone.Sampler,
+ audioBuffers: Tone.ToneAudioBuffers,
instrument: InstrumentType,
volume: number,
muted: boolean,
solo: boolean
) {
this.sampler = sampler;
+ this.audioBuffers = audioBuffers;
this.instrument = instrument;
this.volume = volume;
this.muted = muted;
@@ -60,11 +77,15 @@ export class KGAudioBus {
console.log(`Creating KGAudioBus for ${instrument}...`);
// Create the sampler using the factory
- const samplerFactory = KGToneSamplerFactory.instance();
- const sampler = await samplerFactory.createSampler(String(instrument));
+ const samplerFactory = KGToneSamplerFactory.instance();
+ const buffersPool = KGToneBuffersPool.instance();
+ const [sampler, audioBuffers] = await Promise.all([
+ samplerFactory.createSampler(String(instrument)),
+ buffersPool.getToneAudioBuffers(String(instrument)),
+ ]);
// Create the audio bus instance
- const audioBus = new KGAudioBus(sampler, instrument, volume, muted, solo);
+ const audioBus = new KGAudioBus(sampler, audioBuffers, instrument, volume, muted, solo);
console.log(`KGAudioBus created successfully for ${instrument}`);
return audioBus;
@@ -117,6 +138,42 @@ export class KGAudioBus {
}
}
+ /**
+ * Trigger note attack for live MIDI keyboard monitoring.
+ * This path tracks the underlying buffer sources so pitch bend can retune held notes.
+ */
+ public triggerLiveMidiAttack(
+ pitch: number,
+ time?: number,
+ velocity?: number
+ ): void {
+ this.triggerPitchBendAwareAttack(pitch, time, velocity);
+ }
+
+ public triggerPitchBendAwareAttack(
+ pitch: number,
+ time?: number,
+ velocity?: number,
+ duration?: number
+ ): void {
+ if (!this.shouldPlay()) {
+ return;
+ }
+
+ try {
+ const liveSource = this.createPitchBendAwareSource(pitch, time, velocity, duration);
+ if (!liveSource) {
+ return;
+ }
+
+ const activeSources = this.liveMidiSources.get(pitch) ?? [];
+ activeSources.push(liveSource);
+ this.liveMidiSources.set(pitch, activeSources);
+ } catch (error) {
+ console.error(`Error triggering live MIDI attack for pitch ${pitch} on ${this.instrument}:`, error);
+ }
+ }
+
/**
* Release a specific note
* Used for ending sustained notes like piano key releases
@@ -132,12 +189,61 @@ export class KGAudioBus {
}
}
+ /**
+ * Release a live MIDI note and forget any active bent sources tied to that pitch.
+ */
+ public releaseLiveMidiNote(pitch: number, time?: number): void {
+ try {
+ const activeSources = this.liveMidiSources.get(pitch);
+ if (!activeSources || activeSources.length === 0) {
+ return;
+ }
+
+ const stopTime = time ?? Tone.now();
+ activeSources.forEach(({ source }) => {
+ try {
+ source.stop(stopTime);
+ } catch (error) {
+ console.error(`Error stopping live MIDI source for pitch ${pitch} on ${this.instrument}:`, error);
+ }
+ });
+ this.liveMidiSources.delete(pitch);
+ } catch (error) {
+ console.error(`Error releasing live MIDI note ${pitch} on ${this.instrument}:`, error);
+ }
+ }
+
+ public setLiveMidiPitchBend(normalizedBend: number): void {
+ this.liveMidiPitchBend = Math.max(-1, Math.min(1, normalizedBend));
+
+ for (const activeSources of this.liveMidiSources.values()) {
+ activeSources.forEach(({ source, basePlaybackRate }) => {
+ source.playbackRate.value = this.applyPitchBendToPlaybackRate(basePlaybackRate);
+ });
+ }
+ }
+
+ public resetLiveMidiPitchBend(): void {
+ this.setLiveMidiPitchBend(0);
+ }
+
/**
* Release all currently playing notes
*/
public releaseAll(): void {
try {
this.sampler.releaseAll();
+ this.liveMidiSources.forEach((activeSources) => {
+ activeSources.forEach(({ source }) => {
+ try {
+ source.stop();
+ } catch (error) {
+ console.error(`Error stopping live MIDI source on ${this.instrument}:`, error);
+ }
+ });
+ });
+ this.liveMidiSources.clear();
+ this.resetLiveMidiPitchBend();
} catch (error) {
console.error(`Error releasing all notes on ${this.instrument}:`, error);
}
@@ -206,12 +312,20 @@ export class KGAudioBus {
try {
console.log(`Changing instrument from ${this.instrument} to ${newInstrument}...`);
+ this.releaseAll();
+
// Dispose of the current sampler
this.sampler.dispose();
// Create new sampler with new instrument
const samplerFactory = KGToneSamplerFactory.instance();
- this.sampler = await samplerFactory.createSampler(String(newInstrument));
+ const buffersPool = KGToneBuffersPool.instance();
+ const [sampler, audioBuffers] = await Promise.all([
+ samplerFactory.createSampler(String(newInstrument)),
+ buffersPool.getToneAudioBuffers(String(newInstrument)),
+ ]);
+ this.sampler = sampler;
+ this.audioBuffers = audioBuffers;
this.instrument = newInstrument;
// Restore volume settings
@@ -269,6 +383,7 @@ export class KGAudioBus {
*/
public dispose(): void {
try {
+ this.releaseAll();
this.sampler.dispose();
console.log(`Disposed KGAudioBus for ${this.instrument}`);
} catch (error) {
@@ -352,4 +467,80 @@ export class KGAudioBus {
solo: this.solo
};
}
-}
\ No newline at end of file
+
+ private createPitchBendAwareSource(
+ pitch: number,
+ time?: number,
+ velocity?: number,
+ duration?: number
+ ): LiveMidiSource | null {
+ const closestPitch = this.findClosestBufferedPitch(pitch);
+ if (closestPitch === null) {
+ console.warn(`No audio buffer found for live MIDI pitch ${pitch} on ${this.instrument}`);
+ return null;
+ }
+
+ const bufferKey = this.midiPitchToBufferKey(closestPitch);
+ const buffer = this.audioBuffers.get(bufferKey);
+ if (!buffer) {
+ console.warn(`Missing audio buffer ${bufferKey} for ${this.instrument}`);
+ return null;
+ }
+
+ const basePlaybackRate = Math.pow(2, (pitch - closestPitch) / 12);
+ const source = new Tone.ToneBufferSource({
+ url: buffer,
+ fadeIn: this.sampler.attack,
+ fadeOut: this.sampler.release,
+ curve: this.sampler.curve,
+ playbackRate: this.applyPitchBendToPlaybackRate(basePlaybackRate),
+ }).connect(this.sampler.output);
+
+ source.onended = () => {
+ const currentSources = this.liveMidiSources.get(pitch);
+ if (!currentSources) {
+ return;
+ }
+
+ const nextSources = currentSources.filter((entry) => entry.source !== source);
+ if (nextSources.length === 0) {
+ this.liveMidiSources.delete(pitch);
+ } else {
+ this.liveMidiSources.set(pitch, nextSources);
+ }
+ };
+
+ source.start(time, 0, duration ?? buffer.duration / basePlaybackRate, velocity ?? 1);
+ return { source, basePlaybackRate };
+ }
+
+ private applyPitchBendToPlaybackRate(basePlaybackRate: number): number {
+ const bendSemitones = this.liveMidiPitchBend * KGAudioBus.LIVE_MIDI_PITCH_BEND_RANGE_SEMITONES;
+ return basePlaybackRate * Math.pow(2, bendSemitones / 12);
+ }
+
+ private findClosestBufferedPitch(targetPitch: number): number | null {
+ const [minPitch, maxPitch] = FLUIDR3_INSTRUMENT_MAP[this.instrument]?.pitchRange || [21, 108];
+ const boundedPitch = Math.max(minPitch, Math.min(maxPitch, targetPitch));
+
+ for (let offset = 0; offset <= 96; offset++) {
+ const upwardPitch = boundedPitch + offset;
+ if (upwardPitch <= maxPitch && this.audioBuffers.has(this.midiPitchToBufferKey(upwardPitch))) {
+ return upwardPitch;
+ }
+
+ const downwardPitch = boundedPitch - offset;
+ if (downwardPitch >= minPitch && this.audioBuffers.has(this.midiPitchToBufferKey(downwardPitch))) {
+ return downwardPitch;
+ }
+ }
+
+ return null;
+ }
+
+ private midiPitchToBufferKey(pitch: number): string {
+ const octave = Math.floor((pitch - 12) / 12);
+ const noteIndex = (pitch - 12) % 12;
+ return `${KGAudioBus.LIVE_MIDI_NOTE_NAMES[noteIndex]}${octave}`;
+ }
+}
diff --git a/src/core/audio-interface/KGAudioInterface.ts b/src/core/audio-interface/KGAudioInterface.ts
index f556cba..b3e9972 100644
--- a/src/core/audio-interface/KGAudioInterface.ts
+++ b/src/core/audio-interface/KGAudioInterface.ts
@@ -1,8 +1,9 @@
import type { KGProject } from '../KGProject';
import type { KGMidiNote } from '../midi/KGMidiNote';
+import type { KGMidiPitchBend } from '../midi/KGMidiPitchBend';
import { TIME_CONSTANTS, AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
-import { pitchToNoteNameString } from '../../util/midiUtil';
+import { midiPitchBendToNormalized, pitchToNoteNameString } from '../../util/midiUtil';
import * as Tone from 'tone';
import { KGAudioBus } from './KGAudioBus';
import { KGAudioPlayerBus } from './KGAudioPlayerBus';
@@ -399,6 +400,7 @@ export class KGAudioInterface {
// Clear any existing scheduled events
this.clearScheduledEvents();
this.clearDelayedTransportStart();
+ this.trackAudioBuses.forEach(audioBus => audioBus.resetLiveMidiPitchBend());
console.log("Preparing playback");
@@ -476,20 +478,31 @@ export class KGAudioInterface {
// Schedule MIDI track events
if (audioBus && track.getType() === 'MIDI') {
+ const trackPitchBends: Array<{ pitchBend: KGMidiPitchBend; absoluteBeat: number }> = [];
+ const trackNotes: Array<{ note: KGMidiNote; absoluteStartBeat: number; absoluteEndBeat: number }> = [];
+
track.getRegions().forEach(region => {
console.log(`Region ${region.getId().toString()}: type: ${region.getCurrentType()}`);
if (region.getCurrentType() === 'KGMidiRegion') {
- const midiRegion = region as unknown as { getNotes: () => KGMidiNote[] };
+ const midiRegion = region as unknown as { getNotes: () => KGMidiNote[]; getPitchBends: () => KGMidiPitchBend[] };
+ const regionStartBeat = region.getStartFromBeat();
+
+ if (midiRegion.getPitchBends) {
+ midiRegion.getPitchBends().forEach((pitchBend: KGMidiPitchBend) => {
+ trackPitchBends.push({
+ pitchBend,
+ absoluteBeat: regionStartBeat + pitchBend.getBeat(),
+ });
+ });
+ }
// Get notes from region (assuming it has a getNotes method)
if (midiRegion.getNotes) {
midiRegion.getNotes().forEach((note: KGMidiNote) => {
// Calculate absolute note timing in beats (note position + region start position)
- const regionStartBeat = region.getStartFromBeat();
const noteStartBeat = note.getStartBeat() + regionStartBeat;
const noteEndBeat = note.getEndBeat() + regionStartBeat;
- const noteDurationBeats = note.getEndBeat() - note.getStartBeat();
// Skip notes outside loop range when looping
if (noteStartBeat >= scheduleEndBeat || noteEndBeat <= scheduleStartBeat) {
@@ -500,33 +513,69 @@ export class KGAudioInterface {
if (noteStartBeat < startPosition) {
return; // Skip notes that would have already finished before playback starts
}
-
- // Convert beats to Tone.js time format for scheduling
- const noteStartTime = this.beatsToToneTime(noteStartBeat);
- const noteDuration = this.beatsToToneTime(noteDurationBeats);
-
- // Convert MIDI note number to note name
- const noteName = pitchToNoteNameString(note.getPitch());
- const velocity = note.getVelocity() / 127; // Normalize to 0-1
-
- console.log(
- `Scheduling note ${noteName} at beat ${Number(noteStartBeat.toFixed ? noteStartBeat.toFixed(3) : noteStartBeat.toLocaleString(undefined, {maximumFractionDigits: 3}))}, Tone time: ${Number(Number(noteStartTime).toFixed(3))}, duration: ${Number(Number(noteDuration).toFixed(3))}, delay: ${playbackDelay}s`
- );
-
- // Schedule the note with delay offset
- const eventId = Tone.Transport.schedule((time) => {
- // Check if track should play considering solo logic
- const hasSoloedTracks = this.hasSoloedTracks();
- if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) {
- audioBus.triggerAttackRelease(noteName, noteDuration, time + playbackDelay, velocity);
- }
- }, noteStartTime);
-
- this.scheduledEvents.add(eventId);
+ trackNotes.push({
+ note,
+ absoluteStartBeat: noteStartBeat,
+ absoluteEndBeat: noteEndBeat,
+ });
});
}
}
});
+
+ const boundedTrackPitchBends = trackPitchBends
+ .filter(({ absoluteBeat }) => absoluteBeat >= scheduleStartBeat && absoluteBeat < scheduleEndBeat)
+ .sort((a, b) => a.absoluteBeat - b.absoluteBeat);
+ const initialPitchBend = [...boundedTrackPitchBends]
+ .reverse()
+ .find(({ absoluteBeat }) => absoluteBeat <= startPosition);
+ audioBus.setLiveMidiPitchBend(midiPitchBendToNormalized(initialPitchBend?.pitchBend.getValue() ?? 8192));
+
+ if (isLooping && !boundedTrackPitchBends.some(({ absoluteBeat }) => absoluteBeat === scheduleStartBeat)) {
+ const eventId = Tone.Transport.schedule((time) => {
+ const hasSoloedTracks = this.hasSoloedTracks();
+ if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) {
+ audioBus.setLiveMidiPitchBend(0);
+ }
+ }, this.beatsToToneTime(scheduleStartBeat));
+ this.scheduledEvents.add(eventId);
+ }
+
+ boundedTrackPitchBends.forEach(({ pitchBend, absoluteBeat }) => {
+ if (absoluteBeat < startPosition) {
+ return;
+ }
+
+ const eventId = Tone.Transport.schedule(() => {
+ const hasSoloedTracks = this.hasSoloedTracks();
+ if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) {
+ audioBus.setLiveMidiPitchBend(midiPitchBendToNormalized(pitchBend.getValue()));
+ }
+ }, this.beatsToToneTime(absoluteBeat));
+
+ this.scheduledEvents.add(eventId);
+ });
+
+ trackNotes.forEach(({ note, absoluteStartBeat, absoluteEndBeat }) => {
+ const noteDurationBeats = absoluteEndBeat - absoluteStartBeat;
+ const noteStartTime = this.beatsToToneTime(absoluteStartBeat);
+ const noteDuration = this.beatsToToneTime(noteDurationBeats);
+ const velocity = note.getVelocity() / 127;
+ const noteName = pitchToNoteNameString(note.getPitch());
+
+ console.log(
+ `Scheduling note ${noteName} at beat ${Number(absoluteStartBeat.toFixed ? absoluteStartBeat.toFixed(3) : absoluteStartBeat.toLocaleString(undefined, {maximumFractionDigits: 3}))}, Tone time: ${Number(Number(noteStartTime).toFixed(3))}, duration: ${Number(Number(noteDuration).toFixed(3))}, delay: ${playbackDelay}s`
+ );
+
+ const eventId = Tone.Transport.schedule((time) => {
+ const hasSoloedTracks = this.hasSoloedTracks();
+ if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) {
+ audioBus.triggerPitchBendAwareAttack(note.getPitch(), time + playbackDelay, velocity, Tone.Time(noteDuration).toSeconds());
+ }
+ }, noteStartTime);
+
+ this.scheduledEvents.add(eventId);
+ });
}
// Schedule audio/wav track events
@@ -771,6 +820,31 @@ export class KGAudioInterface {
}
}
+ /**
+ * Trigger note attack for live MIDI keyboard monitoring.
+ * Unlike piano-roll audition, this path tracks active sources so pitch bend can retune them.
+ */
+ public triggerLiveMidiNoteAttack(trackId: string, pitch: number, velocity: number = 127, time?: number): void {
+ try {
+ const audioBus = this.trackAudioBuses.get(trackId);
+ if (!audioBus) {
+ console.warn(`No audio bus found for track ${trackId}`);
+ return;
+ }
+
+ const normalizedVelocity = velocity / 127;
+ const triggerTime = time ?? Tone.now();
+
+ const hasSoloedTracks = this.hasSoloedTracks();
+ if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) {
+ audioBus.triggerLiveMidiAttack(pitch, triggerTime, normalizedVelocity);
+ console.log(`Triggered live MIDI attack for pitch ${pitch} on track ${trackId}`);
+ }
+ } catch (error) {
+ console.error(`Error triggering live MIDI note attack for track ${trackId}:`, error);
+ }
+ }
+
/**
* Release a specific note
* Used for piano key release
@@ -793,6 +867,37 @@ export class KGAudioInterface {
}
}
+ public releaseLiveMidiNote(trackId: string, pitch: number, time?: number): void {
+ try {
+ const audioBus = this.trackAudioBuses.get(trackId);
+ if (!audioBus) {
+ console.warn(`No audio bus found for track ${trackId}`);
+ return;
+ }
+
+ const releaseTime = time ?? Tone.now();
+ audioBus.releaseLiveMidiNote(pitch, releaseTime);
+ console.log(`Released live MIDI note ${pitch} on track ${trackId}`);
+ } catch (error) {
+ console.error(`Error releasing live MIDI note for track ${trackId}:`, error);
+ }
+ }
+
+ public setLiveMidiPitchBend(trackId: string, normalizedBend: number): void {
+ try {
+ const audioBus = this.trackAudioBuses.get(trackId);
+ if (!audioBus) {
+ console.warn(`No audio bus found for track ${trackId}`);
+ return;
+ }
+
+ audioBus.setLiveMidiPitchBend(normalizedBend);
+ console.log(`Set live MIDI pitch bend to ${normalizedBend} on track ${trackId}`);
+ } catch (error) {
+ console.error(`Error setting live MIDI pitch bend for track ${trackId}:`, error);
+ }
+ }
+
/**
* Clear all scheduled events
*/
diff --git a/src/core/commands/index.ts b/src/core/commands/index.ts
index 1d2e429..9817ec7 100644
--- a/src/core/commands/index.ts
+++ b/src/core/commands/index.ts
@@ -35,6 +35,8 @@ export { ResizeNotesCommand } from './note/ResizeNotesCommand';
export { MoveNotesCommand } from './note/MoveNotesCommand';
export { PasteNotesCommand } from './note/PasteNotesCommand';
export { UpdateNotePropertiesCommand } from './note/UpdateNotePropertiesCommand';
+export { UpdatePitchBendPropertiesCommand } from './note/UpdatePitchBendPropertiesCommand';
+export { CreateMidiEventsCommand, type PitchBendCreationData, type NoteCreationData } from './note/CreateMidiEventsCommand';
// Project commands
export { ChangeProjectPropertyCommand, type ProjectUpdateProperties } from './project/ChangeProjectPropertyCommand';
diff --git a/src/core/commands/note/CreateMidiEventsCommand.ts b/src/core/commands/note/CreateMidiEventsCommand.ts
new file mode 100644
index 0000000..a9185eb
--- /dev/null
+++ b/src/core/commands/note/CreateMidiEventsCommand.ts
@@ -0,0 +1,131 @@
+import { KGCommand } from '../KGCommand';
+import { KGCore } from '../../KGCore';
+import { KGMidiNote } from '../../midi/KGMidiNote';
+import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
+import { KGMidiRegion } from '../../region/KGMidiRegion';
+import { KGTrack } from '../../track/KGTrack';
+import { generateUniqueId } from '../../../util/miscUtil';
+
+export interface NoteCreationData {
+ regionId: string;
+ startBeat: number;
+ endBeat: number;
+ pitch: number;
+ velocity: number;
+ noteId?: string;
+}
+
+export interface PitchBendCreationData {
+ regionId: string;
+ beat: number;
+ value: number;
+ pitchBendId?: string;
+}
+
+export class CreateMidiEventsCommand extends KGCommand {
+ private noteCreationData: NoteCreationData[];
+ private pitchBendCreationData: PitchBendCreationData[];
+ private createdNotes: Array<{ note: KGMidiNote; regionId: string }> = [];
+ private createdPitchBends: Array<{ pitchBend: KGMidiPitchBend; regionId: string }> = [];
+
+ constructor(noteCreationData: NoteCreationData[], pitchBendCreationData: PitchBendCreationData[] = []) {
+ super();
+ this.noteCreationData = noteCreationData.map(data => ({
+ ...data,
+ noteId: data.noteId || generateUniqueId('KGMidiNote'),
+ }));
+ this.pitchBendCreationData = pitchBendCreationData.map(data => ({
+ ...data,
+ pitchBendId: data.pitchBendId || generateUniqueId('KGMidiPitchBend'),
+ }));
+ }
+
+ execute(): void {
+ const tracks = KGCore.instance().getCurrentProject().getTracks();
+ this.createdNotes = [];
+ this.createdPitchBends = [];
+
+ for (const noteData of this.noteCreationData) {
+ const targetRegion = this.resolveRegion(tracks, noteData.regionId);
+ const newNote = new KGMidiNote(
+ noteData.noteId!,
+ noteData.startBeat,
+ noteData.endBeat,
+ noteData.pitch,
+ noteData.velocity
+ );
+ targetRegion.addNote(newNote);
+ this.createdNotes.push({ note: newNote, regionId: noteData.regionId });
+ }
+
+ for (const pitchBendData of this.pitchBendCreationData) {
+ const targetRegion = this.resolveRegion(tracks, pitchBendData.regionId);
+ const newPitchBend = new KGMidiPitchBend(
+ pitchBendData.pitchBendId!,
+ pitchBendData.beat,
+ pitchBendData.value
+ );
+ targetRegion.addPitchBend(newPitchBend);
+ this.createdPitchBends.push({ pitchBend: newPitchBend, regionId: pitchBendData.regionId });
+ }
+ }
+
+ undo(): void {
+ const core = KGCore.instance();
+ const tracks = core.getCurrentProject().getTracks();
+
+ for (const data of this.createdNotes) {
+ const region = this.resolveRegion(tracks, data.regionId);
+ region.removeNote(data.note.getId());
+ const selectedNote = core.getSelectedItems().find(item => item instanceof KGMidiNote && item.getId() === data.note.getId());
+ if (selectedNote) {
+ core.removeSelectedItem(selectedNote);
+ }
+ }
+
+ for (const data of this.createdPitchBends) {
+ const region = this.resolveRegion(tracks, data.regionId);
+ region.removePitchBend(data.pitchBend.getId());
+ const selectedPitchBend = core.getSelectedItems().find(item => item instanceof KGMidiPitchBend && item.getId() === data.pitchBend.getId());
+ if (selectedPitchBend) {
+ core.removeSelectedItem(selectedPitchBend);
+ }
+ }
+ }
+
+ getDescription(): string {
+ const noteCount = this.noteCreationData.length;
+ const pitchBendCount = this.pitchBendCreationData.length;
+
+ if (noteCount > 0 && pitchBendCount > 0) {
+ return `Create ${noteCount} note${noteCount === 1 ? '' : 's'} and ${pitchBendCount} pitch bend${pitchBendCount === 1 ? '' : 's'}`;
+ }
+ if (pitchBendCount > 0) {
+ return pitchBendCount === 1 ? 'Create pitch bend' : `Create ${pitchBendCount} pitch bends`;
+ }
+ return noteCount === 1 ? 'Create note' : `Create ${noteCount} notes`;
+ }
+
+ public getNoteCreationData(): NoteCreationData[] {
+ return this.noteCreationData;
+ }
+
+ public getCreatedNotes(): Array<{ note: KGMidiNote; regionId: string }> {
+ return this.createdNotes;
+ }
+
+ public getCreatedNoteIds(): string[] {
+ return this.noteCreationData.map(data => data.noteId!);
+ }
+
+ private resolveRegion(tracks: KGTrack[], regionId: string): KGMidiRegion {
+ for (const track of tracks) {
+ const region = track.getRegions().find(candidate => candidate.getId() === regionId);
+ if (region instanceof KGMidiRegion) {
+ return region;
+ }
+ }
+
+ throw new Error(`MIDI region with ID ${regionId} not found`);
+ }
+}
diff --git a/src/core/commands/note/CreateNotesCommand.ts b/src/core/commands/note/CreateNotesCommand.ts
index 8634382..29f1bfa 100644
--- a/src/core/commands/note/CreateNotesCommand.ts
+++ b/src/core/commands/note/CreateNotesCommand.ts
@@ -1,164 +1,28 @@
-import { KGCommand } from '../KGCommand';
-import { KGCore } from '../../KGCore';
import { KGMidiNote } from '../../midi/KGMidiNote';
-import { KGMidiRegion } from '../../region/KGMidiRegion';
-import { generateUniqueId } from '../../../util/miscUtil';
+import { CreateMidiEventsCommand, type NoteCreationData } from './CreateMidiEventsCommand';
-/**
- * Data structure for a note to be created
- */
-export interface NoteCreationData {
- regionId: string;
- startBeat: number;
- endBeat: number;
- pitch: number;
- velocity: number;
- noteId?: string;
-}
+export type { NoteCreationData } from './CreateMidiEventsCommand';
/**
* Command to create multiple MIDI notes in regions
* Handles bulk creation as a single undoable operation
*/
-export class CreateNotesCommand extends KGCommand {
- private noteCreationData: NoteCreationData[];
- private createdNotes: Array<{
- note: KGMidiNote;
- regionId: string;
- }> = [];
-
+export class CreateNotesCommand extends CreateMidiEventsCommand {
constructor(noteCreationData: NoteCreationData[]) {
- super();
- this.noteCreationData = noteCreationData.map(data => ({
- ...data,
- noteId: data.noteId || generateUniqueId('KGMidiNote')
- }));
- }
-
- execute(): void {
- const core = KGCore.instance();
- const currentProject = core.getCurrentProject();
- const tracks = currentProject.getTracks();
-
- // Clear any existing created note data to prevent duplicates on re-execution
- this.createdNotes = [];
-
- // Create all notes
- for (const noteData of this.noteCreationData) {
- // Find the target region
- let targetRegion: KGMidiRegion | null = null;
-
- for (const track of tracks) {
- const regions = track.getRegions();
- const region = regions.find(r => r.getId() === noteData.regionId);
- if (region && region instanceof KGMidiRegion) {
- targetRegion = region;
- break;
- }
- }
-
- if (!targetRegion) {
- throw new Error(`MIDI region with ID ${noteData.regionId} not found`);
- }
-
- // Create the new MIDI note
- const newNote = new KGMidiNote(
- noteData.noteId!,
- noteData.startBeat,
- noteData.endBeat,
- noteData.pitch,
- noteData.velocity
- );
-
- // Add the note to the region
- targetRegion.addNote(newNote);
-
- // Store for undo
- this.createdNotes.push({
- note: newNote,
- regionId: noteData.regionId
- });
- }
-
- const noteCount = this.createdNotes.length;
- const regionCount = new Set(this.createdNotes.map(data => data.regionId)).size;
- console.log(`Created ${noteCount} notes in ${regionCount} region${regionCount > 1 ? 's' : ''}`);
- }
-
- undo(): void {
- if (this.createdNotes.length === 0) {
- throw new Error('Cannot undo: no notes were created');
- }
-
- const core = KGCore.instance();
- const currentProject = core.getCurrentProject();
- const tracks = currentProject.getTracks();
-
- // Remove all created notes from their regions
- for (const data of this.createdNotes) {
- // Find the region
- for (const track of tracks) {
- const regions = track.getRegions();
- const region = regions.find(r => r.getId() === data.regionId);
-
- if (region && region instanceof KGMidiRegion) {
- region.removeNote(data.note.getId());
-
- // Clear selection if this note was selected
- const selectedItems = core.getSelectedItems();
- const selectedNote = selectedItems.find(item =>
- item instanceof KGMidiNote && item.getId() === data.note.getId()
- );
- if (selectedNote) {
- core.removeSelectedItem(selectedNote);
- }
-
- break;
- }
- }
- }
-
- console.log(`Removed ${this.createdNotes.length} created notes from ${new Set(this.createdNotes.map(d => d.regionId)).size} regions`);
+ super(noteCreationData, []);
}
getDescription(): string {
- if (this.noteCreationData.length === 1) {
- const noteData = this.noteCreationData[0];
+ const noteCreationData = this.getNoteCreationData();
+ if (noteCreationData.length === 1) {
+ const noteData = noteCreationData[0];
// Convert MIDI pitch to note name for user-friendly description
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
const octave = Math.floor(noteData.pitch / 12) - 1;
const noteName = noteNames[noteData.pitch % 12];
return `Create note ${noteName}${octave}`;
}
- return `Create ${this.noteCreationData.length} notes`;
- }
-
- /**
- * Get the note creation data that was/will be processed
- */
- public getNoteCreationData(): NoteCreationData[] {
- return this.noteCreationData;
- }
-
- /**
- * Get the created note instances (only available after execute)
- */
- public getCreatedNotes(): Array<{note: KGMidiNote; regionId: string}> {
- return this.createdNotes;
- }
-
- /**
- * Get the regions that were affected by this creation
- */
- public getAffectedRegionIds(): string[] {
- return Array.from(new Set(this.noteCreationData.map(data => data.regionId)));
- }
-
- /**
- * Get the IDs of notes that were/will be created
- */
- public getCreatedNoteIds(): string[] {
- return this.noteCreationData.map(data => data.noteId!);
+ return `Create ${noteCreationData.length} notes`;
}
}
@@ -255,4 +119,4 @@ export class CreateNoteCommand extends CreateNotesCommand {
velocity
);
}
-}
\ No newline at end of file
+}
diff --git a/src/core/commands/note/UpdatePitchBendPropertiesCommand.ts b/src/core/commands/note/UpdatePitchBendPropertiesCommand.ts
new file mode 100644
index 0000000..bddc964
--- /dev/null
+++ b/src/core/commands/note/UpdatePitchBendPropertiesCommand.ts
@@ -0,0 +1,82 @@
+import { KGCommand } from '../KGCommand';
+import { KGCore } from '../../KGCore';
+import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
+import { KGMidiRegion } from '../../region/KGMidiRegion';
+import { KGTrack } from '../../track/KGTrack';
+
+interface PitchBendSnapshot {
+ pitchBendId: string;
+ beat: number;
+ value: number;
+}
+
+interface PitchBendUpdate {
+ pitchBendId: string;
+ beat?: number;
+ value?: number;
+}
+
+export class UpdatePitchBendPropertiesCommand extends KGCommand {
+ private regionId: string;
+ private snapshots: PitchBendSnapshot[];
+ private updates: PitchBendUpdate[];
+ private targetRegion: KGMidiRegion | null = null;
+ private parentTrack: KGTrack | null = null;
+
+ constructor(regionId: string, snapshots: PitchBendSnapshot[], updates: PitchBendUpdate[]) {
+ super();
+ this.regionId = regionId;
+ this.snapshots = [...snapshots];
+ this.updates = [...updates];
+ }
+
+ execute(): void {
+ const tracks = KGCore.instance().getCurrentProject().getTracks();
+
+ for (const track of tracks) {
+ const region = track.getRegions().find(r => r.getId() === this.regionId) as KGMidiRegion | undefined;
+ if (region) {
+ this.targetRegion = region;
+ this.parentTrack = track;
+ break;
+ }
+ }
+
+ if (!this.targetRegion) {
+ throw new Error(`Region with ID ${this.regionId} not found`);
+ }
+
+ const pitchBends = this.targetRegion.getPitchBends();
+ for (const update of this.updates) {
+ const pitchBend = pitchBends.find(candidate => candidate.getId() === update.pitchBendId);
+ if (pitchBend) {
+ if (update.beat !== undefined) pitchBend.setBeat(update.beat);
+ if (update.value !== undefined) pitchBend.setValue(update.value);
+ }
+ }
+ }
+
+ undo(): void {
+ if (!this.targetRegion) {
+ throw new Error('Cannot undo: command was not executed');
+ }
+
+ const pitchBends = this.targetRegion.getPitchBends();
+ this.snapshots.forEach(snapshot => {
+ const pitchBend = pitchBends.find(candidate => candidate.getId() === snapshot.pitchBendId);
+ if (pitchBend) {
+ pitchBend.setBeat(snapshot.beat);
+ pitchBend.setValue(snapshot.value);
+ }
+ });
+ }
+
+ getDescription(): string {
+ const count = this.snapshots.length;
+ return count === 1 ? 'Update pitch bend properties' : `Update ${count} pitch bends' properties`;
+ }
+
+ public getParentTrack(): KGTrack | null {
+ return this.parentTrack;
+ }
+}
diff --git a/src/core/commands/region/MergeMidiRegionsCommand.ts b/src/core/commands/region/MergeMidiRegionsCommand.ts
index cbaaaa2..4a9cc9c 100644
--- a/src/core/commands/region/MergeMidiRegionsCommand.ts
+++ b/src/core/commands/region/MergeMidiRegionsCommand.ts
@@ -2,6 +2,7 @@ import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { KGMidiRegion } from '../../region/KGMidiRegion';
import { KGMidiNote } from '../../midi/KGMidiNote';
+import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
import { KGTrack } from '../../track/KGTrack';
import { useProjectStore } from '../../../stores/projectStore';
@@ -16,6 +17,11 @@ interface RegionSnapshot {
pitch: number;
velocity: number;
}>;
+ pitchBends: Array<{
+ id: string;
+ beat: number;
+ value: number;
+ }>;
}
interface ResolvedRegion {
@@ -33,6 +39,14 @@ function cloneNote(note: KGMidiNote, startBeat: number, endBeat: number): KGMidi
);
}
+function clonePitchBend(pitchBend: KGMidiPitchBend, beat: number): KGMidiPitchBend {
+ return new KGMidiPitchBend(
+ pitchBend.getId(),
+ beat,
+ pitchBend.getValue()
+ );
+}
+
export class MergeMidiRegionsCommand extends KGCommand {
private readonly regionIdsToMerge: string[];
private targetTrack: KGTrack | null = null;
@@ -102,6 +116,11 @@ export class MergeMidiRegionsCommand extends KGCommand {
pitch: note.getPitch(),
velocity: note.getVelocity(),
})),
+ pitchBends: region.getPitchBends().map(pitchBend => ({
+ id: pitchBend.getId(),
+ beat: pitchBend.getBeat(),
+ value: pitchBend.getValue(),
+ })),
});
});
@@ -116,6 +135,7 @@ export class MergeMidiRegionsCommand extends KGCommand {
), survivingRegionStart + this.survivingRegion.getLength());
const mergedNotes = [...this.survivingRegion.getNotes()];
+ const mergedPitchBends = [...this.survivingRegion.getPitchBends()];
for (const { region } of resolvedRegions.slice(1)) {
const regionStart = region.getStartFromBeat();
region.getNotes().forEach(note => {
@@ -127,10 +147,17 @@ export class MergeMidiRegionsCommand extends KGCommand {
absoluteEnd - survivingRegionStart
));
});
+ region.getPitchBends().forEach(pitchBend => {
+ mergedPitchBends.push(clonePitchBend(
+ pitchBend,
+ regionStart + pitchBend.getBeat() - survivingRegionStart
+ ));
+ });
}
this.survivingRegion.setLength(mergedEndBeat - survivingRegionStart);
this.survivingRegion.setNotes(mergedNotes);
+ this.survivingRegion.setPitchBends(mergedPitchBends);
const removedRegionIds = new Set(this.removedRegions.map(({ region }) => region.getId()));
const nextRegions = resolvedTargetTrack.getRegions().filter(region => !removedRegionIds.has(region.getId()));
@@ -165,6 +192,11 @@ export class MergeMidiRegionsCommand extends KGCommand {
note.pitch,
note.velocity
)));
+ this.survivingRegion.setPitchBends(survivingSnapshot.pitchBends.map(pitchBend => new KGMidiPitchBend(
+ pitchBend.id,
+ pitchBend.beat,
+ pitchBend.value
+ )));
for (const { region } of this.removedRegions) {
const snapshot = this.originalRegionSnapshots.get(region.getId());
@@ -180,6 +212,11 @@ export class MergeMidiRegionsCommand extends KGCommand {
note.pitch,
note.velocity
)));
+ region.setPitchBends(snapshot.pitchBends.map(pitchBend => new KGMidiPitchBend(
+ pitchBend.id,
+ pitchBend.beat,
+ pitchBend.value
+ )));
}
const regions = [...this.targetTrack.getRegions()];
diff --git a/src/core/commands/region/PasteRegionsCommand.ts b/src/core/commands/region/PasteRegionsCommand.ts
index bdda9e2..be7f85c 100644
--- a/src/core/commands/region/PasteRegionsCommand.ts
+++ b/src/core/commands/region/PasteRegionsCommand.ts
@@ -3,6 +3,7 @@ import { KGCore } from '../../KGCore';
import { KGRegion } from '../../region/KGRegion';
import { KGMidiRegion } from '../../region/KGMidiRegion';
import { KGMidiNote } from '../../midi/KGMidiNote';
+import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
import { KGTrack } from '../../track/KGTrack';
import { generateUniqueId } from '../../../util/miscUtil';
import { useProjectStore } from '../../../stores/projectStore';
@@ -82,6 +83,13 @@ export class PasteRegionsCommand extends KGCommand {
);
(newRegion as KGMidiRegion).addNote(copiedNote);
});
+ originalRegion.getPitchBends().forEach(pitchBend => {
+ (newRegion as KGMidiRegion).addPitchBend(new KGMidiPitchBend(
+ generateUniqueId('KGMidiPitchBend'),
+ pitchBend.getBeat(),
+ pitchBend.getValue()
+ ));
+ });
console.log(`Created MIDI region "${newRegion.getName()}" with ${originalNotes.length} notes`);
} else {
@@ -203,4 +211,4 @@ export class PasteRegionsCommand extends KGCommand {
public static fromRegions(targetTrackId: string, pastePosition: number, regions: KGRegion[]): PasteRegionsCommand {
return new PasteRegionsCommand(targetTrackId, pastePosition, regions);
}
-}
\ No newline at end of file
+}
diff --git a/src/core/commands/region/ResizeRegionCommand.ts b/src/core/commands/region/ResizeRegionCommand.ts
index ccd02d4..61d8b6e 100644
--- a/src/core/commands/region/ResizeRegionCommand.ts
+++ b/src/core/commands/region/ResizeRegionCommand.ts
@@ -4,6 +4,7 @@ import { KGRegion } from '../../region/KGRegion';
import { KGMidiRegion } from '../../region/KGMidiRegion';
import { KGAudioRegion } from '../../region/KGAudioRegion';
import { KGMidiNote } from '../../midi/KGMidiNote';
+import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
/**
* Command to resize a region (change start position and/or length)
@@ -23,6 +24,10 @@ export class ResizeRegionCommand extends KGCommand {
originalStartBeat: number;
originalEndBeat: number;
}> = [];
+ private pitchBendAdjustments: Array<{
+ pitchBendId: string;
+ originalBeat: number;
+ }> = [];
// Audio region clip offset support
private newClipStartOffsetSeconds?: number;
@@ -81,6 +86,13 @@ export class ResizeRegionCommand extends KGCommand {
note.setStartBeat(note.getStartBeat() - beatOffset);
note.setEndBeat(note.getEndBeat() - beatOffset);
});
+ targetRegion.getPitchBends().forEach((pitchBend: KGMidiPitchBend) => {
+ this.pitchBendAdjustments.push({
+ pitchBendId: pitchBend.getId(),
+ originalBeat: pitchBend.getBeat(),
+ });
+ pitchBend.setBeat(pitchBend.getBeat() - beatOffset);
+ });
console.log(`Adjusted ${notes.length} notes by offset ${-beatOffset} beats to maintain absolute positions`);
}
@@ -122,6 +134,15 @@ export class ResizeRegionCommand extends KGCommand {
console.log(`Restored ${this.noteAdjustments.length} notes to their original positions`);
}
+ if (this.pitchBendAdjustments.length > 0 && this.targetRegion instanceof KGMidiRegion) {
+ const pitchBends = this.targetRegion.getPitchBends();
+ this.pitchBendAdjustments.forEach(adjustment => {
+ const pitchBend = pitchBends.find(candidate => candidate.getId() === adjustment.pitchBendId);
+ if (pitchBend) {
+ pitchBend.setBeat(adjustment.originalBeat);
+ }
+ });
+ }
// Restore clip offset for audio regions
if (this.targetRegion instanceof KGAudioRegion && this.newClipStartOffsetSeconds !== undefined) {
@@ -214,4 +235,4 @@ export class ResizeRegionCommand extends KGCommand {
return new ResizeRegionCommand(regionId, newStartFromBeat, newLength, newClipStartOffsetSeconds);
}
-}
\ No newline at end of file
+}
diff --git a/src/core/commands/region/SplitRegionCommand.ts b/src/core/commands/region/SplitRegionCommand.ts
index 24a1b50..5aad431 100644
--- a/src/core/commands/region/SplitRegionCommand.ts
+++ b/src/core/commands/region/SplitRegionCommand.ts
@@ -4,6 +4,7 @@ import { KGRegion } from '../../region/KGRegion';
import { KGMidiRegion } from '../../region/KGMidiRegion';
import { KGAudioRegion } from '../../region/KGAudioRegion';
import { KGMidiNote } from '../../midi/KGMidiNote';
+import { KGMidiPitchBend } from '../../midi/KGMidiPitchBend';
import { KGTrack } from '../../track/KGTrack';
import { generateUniqueId } from '../../../util/miscUtil';
import { useProjectStore } from '../../../stores/projectStore';
@@ -114,6 +115,22 @@ export class SplitRegionCommand extends KGCommand {
}
}
+ for (const pitchBend of originalRegion.getPitchBends()) {
+ if (pitchBend.getBeat() < splitOffsetBeats) {
+ region1.addPitchBend(new KGMidiPitchBend(
+ generateUniqueId('KGMidiPitchBend'),
+ pitchBend.getBeat(),
+ pitchBend.getValue()
+ ));
+ } else {
+ region2.addPitchBend(new KGMidiPitchBend(
+ generateUniqueId('KGMidiPitchBend'),
+ pitchBend.getBeat() - splitOffsetBeats,
+ pitchBend.getValue()
+ ));
+ }
+ }
+
this.region1 = region1;
this.region2 = region2;
diff --git a/src/core/commands/region/TransformRegionsCommand.ts b/src/core/commands/region/TransformRegionsCommand.ts
index 3bc2a18..ed445a4 100644
--- a/src/core/commands/region/TransformRegionsCommand.ts
+++ b/src/core/commands/region/TransformRegionsCommand.ts
@@ -25,6 +25,11 @@ interface NoteAdjustment {
originalEndBeat: number;
}
+interface PitchBendAdjustment {
+ pitchBendId: string;
+ originalBeat: number;
+}
+
const EPSILON = 1e-9;
function getRegionById(tracks: KGTrack[], regionId: string): { region: KGRegion; track: KGTrack } | null {
@@ -168,6 +173,7 @@ export class ResizeMultipleRegionsCommand extends KGCommand {
private originalStates: RegionSnapshot[] = [];
private targetRegions: KGRegion[] = [];
private noteAdjustments = new Map();
+ private pitchBendAdjustments = new Map();
constructor(
primaryRegionId: string,
@@ -282,6 +288,13 @@ export class ResizeMultipleRegionsCommand extends KGCommand {
note.setStartBeat(note.getStartBeat() - beatOffset);
note.setEndBeat(note.getEndBeat() - beatOffset);
});
+ this.pitchBendAdjustments.set(region.getId(), region.getPitchBends().map(pitchBend => ({
+ pitchBendId: pitchBend.getId(),
+ originalBeat: pitchBend.getBeat(),
+ })));
+ region.getPitchBends().forEach(pitchBend => {
+ pitchBend.setBeat(pitchBend.getBeat() - beatOffset);
+ });
}
if (region instanceof KGAudioRegion && projectedState.clipStartOffsetSeconds !== undefined) {
@@ -315,6 +328,13 @@ export class ResizeMultipleRegionsCommand extends KGCommand {
note.setEndBeat(adjustment.originalEndBeat);
}
});
+ const pitchBendAdjustments = this.pitchBendAdjustments.get(region.getId()) ?? [];
+ pitchBendAdjustments.forEach(adjustment => {
+ const pitchBend = region.getPitchBends().find(candidate => candidate.getId() === adjustment.pitchBendId);
+ if (pitchBend) {
+ pitchBend.setBeat(adjustment.originalBeat);
+ }
+ });
}
if (region instanceof KGAudioRegion && originalState.clipStartOffsetSeconds !== undefined) {
diff --git a/src/core/midi-input/KGMidiInput.test.ts b/src/core/midi-input/KGMidiInput.test.ts
new file mode 100644
index 0000000..c962ed0
--- /dev/null
+++ b/src/core/midi-input/KGMidiInput.test.ts
@@ -0,0 +1,64 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const { getStateMock, audioInterfaceMock } = vi.hoisted(() => ({
+ getStateMock: vi.fn(),
+ audioInterfaceMock: {
+ getIsInitialized: vi.fn(),
+ getIsAudioContextStarted: vi.fn(),
+ startAudioContext: vi.fn(),
+ triggerLiveMidiNoteAttack: vi.fn(),
+ releaseLiveMidiNote: vi.fn(),
+ setLiveMidiPitchBend: vi.fn(),
+ },
+}));
+
+vi.mock('../../stores/projectStore', () => ({
+ useProjectStore: {
+ getState: getStateMock,
+ },
+}));
+
+vi.mock('../audio-interface/KGAudioInterface', () => ({
+ KGAudioInterface: {
+ instance: () => audioInterfaceMock,
+ },
+}));
+
+import { KGMidiInput } from './KGMidiInput';
+
+describe('KGMidiInput pitch bend', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ getStateMock.mockReturnValue({ selectedTrackId: 'track-1' });
+ audioInterfaceMock.getIsInitialized.mockReturnValue(true);
+ audioInterfaceMock.getIsAudioContextStarted.mockReturnValue(true);
+ audioInterfaceMock.startAudioContext.mockResolvedValue(undefined);
+ (KGMidiInput as unknown as { _instance: KGMidiInput | null })._instance = null;
+ });
+
+ it('routes live MIDI note on/off through the live monitoring path', () => {
+ const midiInput = KGMidiInput.instance() as unknown as {
+ handleMIDIMessage: (event: { data: Uint8Array }) => void;
+ };
+
+ midiInput.handleMIDIMessage({ data: new Uint8Array([0x90, 60, 100]) });
+ midiInput.handleMIDIMessage({ data: new Uint8Array([0x80, 60, 0]) });
+
+ expect(audioInterfaceMock.triggerLiveMidiNoteAttack).toHaveBeenCalledWith('track-1', 60, 100);
+ expect(audioInterfaceMock.releaseLiveMidiNote).toHaveBeenCalledWith('track-1', 60);
+ });
+
+ it('normalizes MIDI pitch bend and forwards it to the selected track', () => {
+ const midiInput = KGMidiInput.instance() as unknown as {
+ handleMIDIMessage: (event: { data: Uint8Array }) => void;
+ };
+
+ midiInput.handleMIDIMessage({ data: new Uint8Array([0xe0, 0x00, 0x40]) });
+ midiInput.handleMIDIMessage({ data: new Uint8Array([0xe0, 0x7f, 0x7f]) });
+
+ expect(audioInterfaceMock.setLiveMidiPitchBend).toHaveBeenNthCalledWith(1, 'track-1', 0);
+ expect(audioInterfaceMock.setLiveMidiPitchBend).toHaveBeenCalledTimes(2);
+ expect(audioInterfaceMock.setLiveMidiPitchBend.mock.calls[1]?.[0]).toBe('track-1');
+ expect(audioInterfaceMock.setLiveMidiPitchBend.mock.calls[1]?.[1]).toBeCloseTo(8191 / 8192, 5);
+ });
+});
diff --git a/src/core/midi-input/KGMidiInput.ts b/src/core/midi-input/KGMidiInput.ts
index d5301fe..1435992 100644
--- a/src/core/midi-input/KGMidiInput.ts
+++ b/src/core/midi-input/KGMidiInput.ts
@@ -7,6 +7,9 @@ import { useProjectStore } from '../../stores/projectStore';
* Handles Web MIDI API integration for keyboard input
*/
export class KGMidiInput {
+ private static readonly PITCH_BEND_CENTER = 8192;
+ private static readonly PITCH_BEND_MAX_OFFSET = 8192;
+
// Private static instance for singleton pattern
private static _instance: KGMidiInput | null = null;
@@ -18,6 +21,7 @@ export class KGMidiInput {
// Recording callbacks
private onRecordNoteOn: ((pitch: number, velocity: number) => void) | null = null;
private onRecordNoteOff: ((pitch: number) => void) | null = null;
+ private onRecordPitchBend: ((value: number) => void) | null = null;
// Private constructor to prevent direct instantiation
private constructor() {
@@ -182,7 +186,8 @@ export class KGMidiInput {
else if (command === 0xe0) {
const pitchBendValue = (velocity << 7) | pitch;
console.log(`MIDI Pitch Bend: value=${pitchBendValue}, channel=${channel}`);
- // TODO: Handle pitch bend
+ this.triggerPitchBend(this.normalizePitchBend(pitchBendValue));
+ this.onRecordPitchBend?.(pitchBendValue);
}
}
@@ -212,7 +217,7 @@ export class KGMidiInput {
// Trigger note attack if audio context is ready
if (audioInterface.getIsAudioContextStarted()) {
- audioInterface.triggerNoteAttack(selectedTrackId, pitch, velocity);
+ audioInterface.triggerLiveMidiNoteAttack(selectedTrackId, pitch, velocity);
console.log(`MIDI triggered note attack: pitch=${pitch}, velocity=${velocity}, track=${selectedTrackId}`);
}
}
@@ -237,7 +242,7 @@ export class KGMidiInput {
// Get audio interface and stop playing the note
const audioInterface = KGAudioInterface.instance();
if (audioInterface.getIsInitialized() && audioInterface.getIsAudioContextStarted()) {
- audioInterface.releaseNote(selectedTrackId, pitch);
+ audioInterface.releaseLiveMidiNote(selectedTrackId, pitch);
console.log(`MIDI released note: pitch=${pitch}, track=${selectedTrackId}`);
}
} catch (error) {
@@ -245,6 +250,27 @@ export class KGMidiInput {
}
}
+ private triggerPitchBend(normalizedBend: number): void {
+ try {
+ const selectedTrackId = useProjectStore.getState().selectedTrackId;
+ if (!selectedTrackId) {
+ return;
+ }
+
+ const audioInterface = KGAudioInterface.instance();
+ if (audioInterface.getIsInitialized() && audioInterface.getIsAudioContextStarted()) {
+ audioInterface.setLiveMidiPitchBend(selectedTrackId, normalizedBend);
+ }
+ } catch (error) {
+ console.error(`Error applying MIDI pitch bend (${normalizedBend}):`, error);
+ }
+ }
+
+ private normalizePitchBend(pitchBendValue: number): number {
+ const normalizedBend = (pitchBendValue - KGMidiInput.PITCH_BEND_CENTER) / KGMidiInput.PITCH_BEND_MAX_OFFSET;
+ return Math.max(-1, Math.min(1, normalizedBend));
+ }
+
/**
* Clean up MIDI resources
*/
@@ -274,10 +300,12 @@ export class KGMidiInput {
public setRecordingCallbacks(
onNoteOn: ((pitch: number, velocity: number) => void) | null,
- onNoteOff: ((pitch: number) => void) | null
+ onNoteOff: ((pitch: number) => void) | null,
+ onPitchBend: ((value: number) => void) | null = null
): void {
this.onRecordNoteOn = onNoteOn;
this.onRecordNoteOff = onNoteOff;
+ this.onRecordPitchBend = onPitchBend;
}
// ===== GETTERS =====
diff --git a/src/core/midi/KGMidiPitchBend.test.ts b/src/core/midi/KGMidiPitchBend.test.ts
new file mode 100644
index 0000000..2e1d06a
--- /dev/null
+++ b/src/core/midi/KGMidiPitchBend.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest';
+import { KGMidiPitchBend } from './KGMidiPitchBend';
+
+describe('KGMidiPitchBend', () => {
+ it('stores beat and raw pitch bend value', () => {
+ const event = new KGMidiPitchBend('bend-1', 1.5, 4096);
+
+ expect(event.getId()).toBe('bend-1');
+ expect(event.getBeat()).toBe(1.5);
+ expect(event.getValue()).toBe(4096);
+ expect(event.getCurrentType()).toBe('KGMidiPitchBend');
+ });
+
+ it('supports selection state', () => {
+ const event = new KGMidiPitchBend('bend-1', 0, 8192);
+
+ expect(event.isSelected()).toBe(false);
+ event.select();
+ expect(event.isSelected()).toBe(true);
+ event.deselect();
+ expect(event.isSelected()).toBe(false);
+ });
+});
diff --git a/src/core/midi/KGMidiPitchBend.ts b/src/core/midi/KGMidiPitchBend.ts
new file mode 100644
index 0000000..44bee6b
--- /dev/null
+++ b/src/core/midi/KGMidiPitchBend.ts
@@ -0,0 +1,66 @@
+import { Expose } from 'class-transformer';
+import type { Selectable } from '../../components/interfaces';
+
+export class KGMidiPitchBend implements Selectable {
+ @Expose()
+ private id: string = '';
+
+ @Expose()
+ private beat: number = 0;
+
+ @Expose()
+ private value: number = 8192;
+
+ @Expose()
+ private selected: boolean = false;
+
+ constructor(id: string, beat: number = 0, value: number = 8192) {
+ this.id = id;
+ this.beat = beat;
+ this.value = value;
+ }
+
+ public getId(): string {
+ return this.id;
+ }
+
+ public getBeat(): number {
+ return this.beat;
+ }
+
+ public getValue(): number {
+ return this.value;
+ }
+
+ public setId(id: string): void {
+ this.id = id;
+ }
+
+ public setBeat(beat: number): void {
+ this.beat = beat;
+ }
+
+ public setValue(value: number): void {
+ this.value = value;
+ }
+
+ public select(): void {
+ this.selected = true;
+ }
+
+ public deselect(): void {
+ this.selected = false;
+ }
+
+ public isSelected(): boolean {
+ return this.selected;
+ }
+
+ public getRootType(): string {
+ return 'KGMidiPitchBend';
+ }
+
+ public getCurrentType(): string {
+ return 'KGMidiPitchBend';
+ }
+}
diff --git a/src/core/region/KGMidiRegion.test.ts b/src/core/region/KGMidiRegion.test.ts
index 4131c4e..71e0fee 100644
--- a/src/core/region/KGMidiRegion.test.ts
+++ b/src/core/region/KGMidiRegion.test.ts
@@ -1,7 +1,9 @@
import { describe, it, expect, beforeEach } from 'vitest';
+import { instanceToPlain, plainToInstance } from 'class-transformer';
import { KGMidiRegion } from './KGMidiRegion';
import { KGRegion } from './KGRegion';
import { KGMidiNote } from '../midi/KGMidiNote';
+import { KGMidiPitchBend } from '../midi/KGMidiPitchBend';
import { createMockMidiNote } from '../../test/utils/mock-data';
describe('KGMidiRegion', () => {
@@ -36,6 +38,7 @@ describe('KGMidiRegion', () => {
expect(testRegion.getStartFromBeat()).toBe(4);
expect(testRegion.getLength()).toBe(8);
expect(testRegion.getNotes()).toEqual([]);
+ expect(testRegion.getPitchBends()).toEqual([]);
});
it('should use default values for optional parameters', () => {
@@ -44,6 +47,7 @@ describe('KGMidiRegion', () => {
expect(defaultRegion.getStartFromBeat()).toBe(0);
expect(defaultRegion.getLength()).toBe(0);
expect(defaultRegion.getNotes()).toEqual([]);
+ expect(defaultRegion.getPitchBends()).toEqual([]);
});
it('should set the correct type identifier', () => {
@@ -214,6 +218,37 @@ describe('KGMidiRegion', () => {
});
});
+ describe('pitch bend management', () => {
+ let pitchBend1: KGMidiPitchBend;
+ let pitchBend2: KGMidiPitchBend;
+
+ beforeEach(() => {
+ pitchBend1 = new KGMidiPitchBend('bend-1', 0.5, 8192);
+ pitchBend2 = new KGMidiPitchBend('bend-2', 1.5, 12288);
+ });
+
+ it('adds and returns pitch bends', () => {
+ region.addPitchBend(pitchBend1);
+ region.addPitchBend(pitchBend2);
+
+ expect(region.getPitchBends()).toEqual([pitchBend1, pitchBend2]);
+ });
+
+ it('removes pitch bends by id', () => {
+ region.setPitchBends([pitchBend1, pitchBend2]);
+ region.removePitchBend('bend-1');
+
+ expect(region.getPitchBends()).toEqual([pitchBend2]);
+ });
+
+ it('replaces all pitch bends when setting a new array', () => {
+ region.setPitchBends([pitchBend1]);
+ region.setPitchBends([pitchBend2]);
+
+ expect(region.getPitchBends()).toEqual([pitchBend2]);
+ });
+ });
+
describe('inheritance from KGRegion', () => {
it('should inherit all base region properties', () => {
expect(region.getId()).toBe('test-region-1');
@@ -349,5 +384,18 @@ describe('KGMidiRegion', () => {
expect(finalNotes).toContain(notes[2]); // concurrent-3
expect(finalNotes).toContain(newNote); // concurrent-4
});
+
+ it('preserves pitch bends through class-transformer serialization', () => {
+ region.addNote(createMockMidiNote({ id: 'note-1', pitch: 60, startBeat: 0, endBeat: 1 }));
+ region.addPitchBend(new KGMidiPitchBend('bend-1', 0.5, 12288));
+
+ const plain = instanceToPlain(region);
+ const restored = plainToInstance(KGMidiRegion, plain);
+
+ expect(restored.getNotes()).toHaveLength(1);
+ expect(restored.getPitchBends()).toHaveLength(1);
+ expect(restored.getPitchBends()[0]).toBeInstanceOf(KGMidiPitchBend);
+ expect(restored.getPitchBends()[0].getValue()).toBe(12288);
+ });
});
-});
\ No newline at end of file
+});
diff --git a/src/core/region/KGMidiRegion.ts b/src/core/region/KGMidiRegion.ts
index 31a40eb..267b934 100644
--- a/src/core/region/KGMidiRegion.ts
+++ b/src/core/region/KGMidiRegion.ts
@@ -1,6 +1,7 @@
import { Expose, Type } from 'class-transformer';
import { KGRegion } from './KGRegion';
import { KGMidiNote } from '../midi/KGMidiNote';
+import { KGMidiPitchBend } from '../midi/KGMidiPitchBend';
/**
* KGMidiRegion - Class representing a MIDI region in the DAW
@@ -14,6 +15,10 @@ export class KGMidiRegion extends KGRegion {
@Type(() => KGMidiNote)
protected notes: KGMidiNote[] = [];
+ @Expose()
+ @Type(() => KGMidiPitchBend)
+ protected pitchBends: KGMidiPitchBend[] = [];
+
constructor(id: string, trackId: string, trackIndex: number, name: string, startFromBeat: number = 0, length: number = 0) {
super(id, trackId, trackIndex, name, startFromBeat, length);
this.__type = 'KGMidiRegion';
@@ -29,6 +34,14 @@ export class KGMidiRegion extends KGRegion {
this.notes = notes;
}
+ public getPitchBends(): KGMidiPitchBend[] {
+ return this.pitchBends;
+ }
+
+ public setPitchBends(pitchBends: KGMidiPitchBend[]): void {
+ this.pitchBends = pitchBends;
+ }
+
// Add a single note
public addNote(note: KGMidiNote): void {
this.notes.push(note);
@@ -39,6 +52,14 @@ export class KGMidiRegion extends KGRegion {
this.notes = this.notes.filter(note => note.getId() !== noteId);
}
+ public addPitchBend(pitchBend: KGMidiPitchBend): void {
+ this.pitchBends.push(pitchBend);
+ }
+
+ public removePitchBend(pitchBendId: string): void {
+ this.pitchBends = this.pitchBends.filter(pitchBend => pitchBend.getId() !== pitchBendId);
+ }
+
// Override getCurrentType to return specific subclass type
public override getCurrentType(): string {
return 'KGMidiRegion';
diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts
index 821f1e4..0206d39 100644
--- a/src/stores/projectStore.ts
+++ b/src/stores/projectStore.ts
@@ -20,8 +20,9 @@ import { TOOLBAR_CONSTANTS } from '../constants/uiConstants';
import * as Tone from 'tone';
import { KGMidiInput } from '../core/midi-input/KGMidiInput';
import { KGMidiRegion } from '../core/region/KGMidiRegion';
-import { CreateNotesCommand } from '../core/commands/note/CreateNotesCommand';
-import type { NoteCreationData } from '../core/commands/note/CreateNotesCommand';
+import { KGMidiPitchBend } from '../core/midi/KGMidiPitchBend';
+import { CreateMidiEventsCommand, type NoteCreationData, type PitchBendCreationData } from '../core/commands/note/CreateMidiEventsCommand';
+import { MIDI_PITCH_BEND_CENTER } from '../util/midiUtil';
/**
* Update CSS custom property for time signature numerator
@@ -72,6 +73,7 @@ interface ProjectState {
// Selection state for UI reactivity
selectedNoteIds: string[];
+ selectedPitchBendIds: string[];
selectedRegionIds: string[];
selectedTrackId: string | null;
@@ -105,6 +107,7 @@ interface ProjectState {
isRecording: boolean;
recordingTargetRegionId: string | null;
recordingNotes: Array<{ pitch: number; startBeat: number; endBeat: number; velocity: number }>;
+ recordingPitchBends: Array<{ beat: number; value: number }>;
recordingOriginalPlayhead: number;
// Undo/redo state
@@ -207,6 +210,7 @@ interface ProjectState {
// Module-level recording state (not reactive — only used for timing during active recording)
let _recordingActiveNotes: Map = new Map(); // pitch → note-on data
let _recordingRegionStartBeat: number = 0;
+let _lastRecordedPitchBendValue: number | null = null;
function getRecordingLoopEndBeatRelative(): number | null {
const project = KGCore.instance().getCurrentProject();
@@ -270,11 +274,14 @@ export const useProjectStore = create((set, get) => {
const noteIds = selectedItems
.filter(item => item instanceof KGMidiNote)
.map(item => item.getId());
+ const pitchBendIds = selectedItems
+ .filter(item => item instanceof KGMidiPitchBend)
+ .map(item => item.getId());
const regionIds = selectedItems
.filter(item => item instanceof KGRegion)
.map(item => item.getId());
- set({ selectedNoteIds: noteIds, selectedRegionIds: regionIds });
+ set({ selectedNoteIds: noteIds, selectedPitchBendIds: pitchBendIds, selectedRegionIds: regionIds });
};
// Register the sync callback with KGCore
@@ -339,6 +346,7 @@ export const useProjectStore = create((set, get) => {
// Initial selection state
selectedNoteIds: [],
+ selectedPitchBendIds: [],
selectedRegionIds: [],
selectedTrackId: initialSelectedTrackId,
@@ -377,6 +385,7 @@ export const useProjectStore = create((set, get) => {
isRecording: false,
recordingTargetRegionId: null,
recordingNotes: [],
+ recordingPitchBends: [],
recordingOriginalPlayhead: 0,
// Initial cross-component scroll request state
@@ -875,10 +884,12 @@ export const useProjectStore = create((set, get) => {
_recordingRegionStartBeat = targetRegion.getStartFromBeat();
_recordingActiveNotes = new Map();
+ _lastRecordedPitchBendValue = null;
set({
isRecording: true,
recordingNotes: [],
+ recordingPitchBends: [],
recordingTargetRegionId: activeRegionId,
recordingOriginalPlayhead: playheadPosition,
});
@@ -911,6 +922,17 @@ export const useProjectStore = create((set, get) => {
}],
}));
}
+ },
+ (value: number) => {
+ if (_lastRecordedPitchBendValue === value) {
+ return;
+ }
+
+ _lastRecordedPitchBendValue = value;
+ const beat = buildCorrectedBeat();
+ set(state => ({
+ recordingPitchBends: [...state.recordingPitchBends, { beat, value }],
+ }));
}
);
@@ -929,10 +951,11 @@ export const useProjectStore = create((set, get) => {
},
stopRecording: async () => {
- const { recordingNotes, recordingTargetRegionId, recordingOriginalPlayhead, stopPlaying, setPlayheadPosition, refreshProjectState } = get();
+ const { recordingNotes, recordingPitchBends, recordingTargetRegionId, recordingOriginalPlayhead, stopPlaying, setPlayheadPosition, refreshProjectState } = get();
// Finalize any held keys
const finalNotes = [...recordingNotes];
+ const finalPitchBends = [...recordingPitchBends];
const bpm = get().bpm;
const playbackDelaySec = (ConfigManager.instance().get('audio.playback_delay') as number) ?? 0.2;
const recordingOffsetSec = (ConfigManager.instance().get('audio.recording_offset') as number) ?? 0;
@@ -949,9 +972,17 @@ export const useProjectStore = create((set, get) => {
});
_recordingActiveNotes.clear();
- KGMidiInput.instance().setRecordingCallbacks(null, null);
+ if (_lastRecordedPitchBendValue !== null && _lastRecordedPitchBendValue !== MIDI_PITCH_BEND_CENTER) {
+ finalPitchBends.push({
+ beat: endBeatForHeld,
+ value: MIDI_PITCH_BEND_CENTER,
+ });
+ _lastRecordedPitchBendValue = MIDI_PITCH_BEND_CENTER;
+ }
- if (finalNotes.length > 0 && recordingTargetRegionId) {
+ KGMidiInput.instance().setRecordingCallbacks(null, null, null);
+
+ if ((finalNotes.length > 0 || finalPitchBends.length > 0) && recordingTargetRegionId) {
const noteData: NoteCreationData[] = finalNotes.map(n => ({
regionId: recordingTargetRegionId,
startBeat: n.startBeat,
@@ -959,14 +990,20 @@ export const useProjectStore = create((set, get) => {
pitch: n.pitch,
velocity: n.velocity,
}));
- const command = new CreateNotesCommand(noteData);
+ const pitchBendData: PitchBendCreationData[] = finalPitchBends.map(event => ({
+ regionId: recordingTargetRegionId,
+ beat: event.beat,
+ value: event.value,
+ }));
+ const command = new CreateMidiEventsCommand(noteData, pitchBendData);
KGCore.instance().executeCommand(command);
refreshProjectState();
}
await stopPlaying();
setPlayheadPosition(recordingOriginalPlayhead);
- set({ isRecording: false, recordingNotes: [], recordingTargetRegionId: null });
+ set({ isRecording: false, recordingNotes: [], recordingPitchBends: [], recordingTargetRegionId: null });
+ _lastRecordedPitchBendValue = null;
},
toggleLoop: () => {
@@ -1297,5 +1334,3 @@ export const useProjectStore = create((set, get) => {
}
};
});
-
-
diff --git a/src/test/integration/store/project-store-sync.integration.test.ts b/src/test/integration/store/project-store-sync.integration.test.ts
index 8cf8a77..27461c0 100644
--- a/src/test/integration/store/project-store-sync.integration.test.ts
+++ b/src/test/integration/store/project-store-sync.integration.test.ts
@@ -361,13 +361,59 @@ describe('Project Store Synchronization Integration Tests', () => {
expect(storeState.isRecording).toBe(false);
expect(storeState.isPlaying).toBe(false);
expect(storeState.recordingNotes).toHaveLength(0);
+ expect(storeState.recordingPitchBends).toHaveLength(0);
expect(executeCommandSpy).toHaveBeenCalled();
expect(mockAudioInterface.stopPlayback).toHaveBeenCalled();
- expect(setRecordingCallbacksSpy).toHaveBeenLastCalledWith(null, null);
+ expect(setRecordingCallbacksSpy).toHaveBeenLastCalledWith(null, null, null);
expect(testRegion.getNotes()).toHaveLength(1);
expect(testRegion.getNotes()[0].getVelocity()).toBe(96);
});
+ it('records pitch bends and skips consecutive duplicates', async () => {
+ const testTrack = new KGMidiTrack('Recording Track', 0, 'acoustic_grand_piano');
+ const testRegion = new KGMidiRegion('record-region', 'track-0', 0, 'Recording Region', 16, 16);
+ testTrack.addRegion(testRegion);
+ testProject.setTracks([testTrack]);
+
+ await act(async () => {
+ await useProjectStore.getState().loadProject(testProject);
+ });
+
+ const core = KGCore.instance();
+ vi.spyOn(core, 'startPlaying').mockResolvedValue(undefined);
+ vi.spyOn(mockAudioInterface, 'getTransportPosition')
+ .mockReturnValueOnce(20)
+ .mockReturnValueOnce(20.5)
+ .mockReturnValueOnce(21);
+ const setRecordingCallbacksSpy = vi.spyOn(KGMidiInput.instance(), 'setRecordingCallbacks');
+ const { setActiveRegionId, setPlayheadPosition, startRecording, stopTransport } = useProjectStore.getState();
+
+ act(() => {
+ setActiveRegionId(testRegion.getId());
+ setPlayheadPosition(18);
+ });
+
+ await act(async () => {
+ await startRecording();
+ });
+
+ const onPitchBend = setRecordingCallbacksSpy.mock.calls.at(-1)?.[2];
+ expect(onPitchBend).toBeTypeOf('function');
+
+ act(() => {
+ onPitchBend?.(8192);
+ onPitchBend?.(8192);
+ onPitchBend?.(12288);
+ });
+
+ await act(async () => {
+ await stopTransport();
+ });
+
+ expect(testRegion.getPitchBends()).toHaveLength(2);
+ expect(testRegion.getPitchBends().map(event => event.getValue())).toEqual([8192, 12288]);
+ });
+
it('should cut a held looped recording note at the loop end when note off arrives after wrap', async () => {
const testTrack = new KGMidiTrack('Recording Track', 0, 'acoustic_grand_piano');
const testRegion = new KGMidiRegion('record-region', 'track-0', 0, 'Recording Region', 16, 16);
diff --git a/src/test/mocks/audio-interface.ts b/src/test/mocks/audio-interface.ts
index 5201046..94fb438 100644
--- a/src/test/mocks/audio-interface.ts
+++ b/src/test/mocks/audio-interface.ts
@@ -27,6 +27,9 @@ export const mockAudioInterface = {
scheduleNote: vi.fn().mockReturnValue(undefined),
scheduleNotes: vi.fn().mockReturnValue(undefined),
clearScheduledNotes: vi.fn().mockReturnValue(undefined),
+ triggerLiveMidiNoteAttack: vi.fn().mockReturnValue(undefined),
+ releaseLiveMidiNote: vi.fn().mockReturnValue(undefined),
+ setLiveMidiPitchBend: vi.fn().mockReturnValue(undefined),
// Transport
getCurrentBeat: vi.fn().mockReturnValue(0),
diff --git a/src/test/mocks/tone-js.ts b/src/test/mocks/tone-js.ts
index 13f50b5..7ab023a 100644
--- a/src/test/mocks/tone-js.ts
+++ b/src/test/mocks/tone-js.ts
@@ -11,6 +11,10 @@ export const mockSampler = {
triggerRelease: vi.fn(),
dispose: vi.fn(),
loaded: true,
+ attack: 0,
+ release: 0.1,
+ curve: 'exponential',
+ output: {},
toDestination: vi.fn().mockReturnThis(),
connect: vi.fn().mockReturnThis(),
disconnect: vi.fn().mockReturnThis(),
@@ -18,6 +22,14 @@ export const mockSampler = {
get: vi.fn().mockReturnValue({}),
};
+export const mockBufferSource = {
+ playbackRate: { value: 1 },
+ connect: vi.fn(),
+ start: vi.fn(),
+ stop: vi.fn(),
+ onended: undefined as (() => void) | undefined,
+};
+
// Mock Transport
export const mockTransport = {
start: vi.fn(),
@@ -35,6 +47,26 @@ export const mockTransport = {
// Mock Tone namespace
export const mockTone = {
Sampler: vi.fn().mockImplementation(() => mockSampler),
+ BufferSource: vi.fn().mockImplementation(() => {
+ const instance = {
+ ...mockBufferSource,
+ playbackRate: { value: 1 },
+ };
+ instance.connect.mockImplementation(() => instance);
+ instance.start.mockImplementation(() => instance);
+ instance.stop.mockImplementation(() => instance);
+ return instance;
+ }),
+ ToneBufferSource: vi.fn().mockImplementation(() => {
+ const instance = {
+ ...mockBufferSource,
+ playbackRate: { value: 1 },
+ };
+ instance.connect.mockImplementation(() => instance);
+ instance.start.mockImplementation(() => instance);
+ instance.stop.mockImplementation(() => instance);
+ return instance;
+ }),
Transport: mockTransport,
Buffer: vi.fn().mockImplementation(() => ({
loaded: true,
@@ -55,4 +87,4 @@ export const mockTone = {
state: 'running',
resume: vi.fn().mockResolvedValue(undefined),
},
-};
\ No newline at end of file
+};
diff --git a/src/test/mocks/tone.ts b/src/test/mocks/tone.ts
index 94fd3b2..44e26b7 100644
--- a/src/test/mocks/tone.ts
+++ b/src/test/mocks/tone.ts
@@ -12,6 +12,10 @@ export const MockSampler = vi.fn().mockImplementation(() => ({
triggerRelease: vi.fn(),
dispose: vi.fn(),
loaded: true,
+ attack: 0,
+ release: 0.1,
+ curve: 'exponential',
+ output: {},
volume: {
value: -12
},
@@ -20,6 +24,19 @@ export const MockSampler = vi.fn().mockImplementation(() => ({
toDestination: vi.fn()
}));
+export const MockBufferSource = vi.fn().mockImplementation((options?: { playbackRate?: number }) => {
+ const instance = {
+ playbackRate: {
+ value: options?.playbackRate ?? 1,
+ },
+ connect: vi.fn(() => instance),
+ start: vi.fn(() => instance),
+ stop: vi.fn(() => instance),
+ onended: undefined as (() => void) | undefined,
+ };
+ return instance;
+});
+
// Mock Transport object
export const MockTransport = {
start: vi.fn(),
@@ -94,6 +111,8 @@ export const MockMeter = vi.fn().mockImplementation(() => ({
// Complete Tone.js mock
export const ToneMock = {
Sampler: MockSampler,
+ BufferSource: MockBufferSource,
+ ToneBufferSource: MockBufferSource,
Loop: MockLoop,
Transport: MockTransport,
Destination: MockDestination,
diff --git a/src/test/utils/mock-data.ts b/src/test/utils/mock-data.ts
index eedfcf5..dead07a 100644
--- a/src/test/utils/mock-data.ts
+++ b/src/test/utils/mock-data.ts
@@ -1,4 +1,5 @@
import { KGMidiNote } from '../../core/midi/KGMidiNote';
+import { KGMidiPitchBend } from '../../core/midi/KGMidiPitchBend';
import { KGProject } from '../../core/KGProject';
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
@@ -41,6 +42,7 @@ export const createMockMidiRegion = (overrides: Partial<{
startFromBeat: number
length: number
notes: KGMidiNote[]
+ pitchBends: KGMidiPitchBend[]
}> = {}): KGMidiRegion => {
const defaults = {
id: 'test-region-1',
@@ -65,10 +67,28 @@ export const createMockMidiRegion = (overrides: Partial<{
if (overrides.notes) {
overrides.notes.forEach(note => region.addNote(note));
}
+ if (overrides.pitchBends) {
+ overrides.pitchBends.forEach(pitchBend => region.addPitchBend(pitchBend));
+ }
return region;
};
+export const createMockMidiPitchBend = (overrides: Partial<{
+ id: string
+ beat: number
+ value: number
+}> = {}): KGMidiPitchBend => {
+ const defaults = {
+ id: 'test-bend-1',
+ beat: 0,
+ value: 8192,
+ ...overrides,
+ };
+
+ return new KGMidiPitchBend(defaults.id, defaults.beat, defaults.value);
+};
+
export const createMockMidiTrack = (overrides: Partial<{
name: string
id: number
@@ -156,4 +176,4 @@ export const createBasicProjectWithTrack = (): { project: KGProject; track: KGMi
});
return { project, track, region };
-};
\ No newline at end of file
+};
diff --git a/src/util/midiUtil.ts b/src/util/midiUtil.ts
index e86344b..2310510 100644
--- a/src/util/midiUtil.ts
+++ b/src/util/midiUtil.ts
@@ -22,6 +22,11 @@ export const pianoRollIndexToPitch = (index: number) => {
};
export const MIDI_EVENT_TICKS_PER_BEAT = 480;
+export const MIDI_PITCH_BEND_MIN = 0;
+export const MIDI_PITCH_BEND_CENTER = 8192;
+export const MIDI_PITCH_BEND_MAX = 16383;
+export const MIDI_PITCH_BEND_MIN_SIGNED = -8192;
+export const MIDI_PITCH_BEND_MAX_SIGNED = 8191;
export const pitchToNoteName = (pitch: number) => {
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
@@ -36,6 +41,22 @@ export const pitchToNoteNameString = (pitch: number) => {
return `${note}${octave}`;
};
+export const clampMidiPitchBendValue = (value: number): number => (
+ Math.max(MIDI_PITCH_BEND_MIN, Math.min(MIDI_PITCH_BEND_MAX, Math.round(value)))
+);
+
+export const midiPitchBendToSignedValue = (value: number): number => (
+ clampMidiPitchBendValue(value) - MIDI_PITCH_BEND_CENTER
+);
+
+export const signedPitchBendToMidiValue = (value: number): number => (
+ clampMidiPitchBendValue(value + MIDI_PITCH_BEND_CENTER)
+);
+
+export const midiPitchBendToNormalized = (value: number): number => (
+ midiPitchBendToSignedValue(value) / MIDI_PITCH_BEND_CENTER
+);
+
export const noteNameToPitch = (noteName: string): number => {
const noteMap: { [key: string]: number } = {
'C': 0, 'C#': 1, 'Cb': -1, 'D': 2, 'D#': 3, 'Db': 1, 'E': 4, 'E#': 5, 'Eb': 3,