feat: added pitch bend support

This commit is contained in:
Xiaohan-Tian
2026-05-05 21:28:32 -07:00
parent 8f453f8f4c
commit 98cccf5c7a
28 changed files with 1704 additions and 507 deletions
+49
View File
@@ -0,0 +1,49 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import ListEventPanel from './ListEventPanel';
import { createMockMidiNote, createMockMidiPitchBend, createMockMidiRegion, createMockMidiTrack } from '../test/utils/mock-data';
const region = createMockMidiRegion({
id: 'region-1',
trackId: '1',
trackIndex: 0,
startFromBeat: 4,
notes: [createMockMidiNote({ id: 'note-1', pitch: 60, startBeat: 1, endBeat: 2, velocity: 96 })],
pitchBends: [createMockMidiPitchBend({ id: 'bend-1', beat: 0.5, value: 12288 })],
});
const track = createMockMidiTrack({ id: 1, regions: [region] });
vi.mock('../stores/projectStore', () => ({
useProjectStore: () => ({
tracks: [track],
activeRegionId: 'region-1',
selectedRegionIds: ['region-1'],
timeSignature: { numerator: 4, denominator: 4 },
selectedNoteIds: [],
selectedPitchBendIds: [],
playheadPosition: 4,
updateTrack: vi.fn().mockResolvedValue(undefined),
refreshProjectState: vi.fn(),
}),
}));
describe('ListEventPanel', () => {
it('renders note and pitch bend rows and toggles them independently', () => {
render(<ListEventPanel isVisible={true} />);
expect(screen.getByText('Pitch Bend')).toBeInTheDocument();
expect(screen.getByText('Note')).toBeInTheDocument();
expect(screen.getByText('Raw 12288 | 0.500 | 1.00 st')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Pitch Bends' }));
expect(screen.queryByText('Pitch Bend')).not.toBeInTheDocument();
expect(screen.getByText('Note')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Notes' }));
expect(screen.queryByText('Note')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Pitch Bends' }));
expect(screen.getByText('Pitch Bend')).toBeInTheDocument();
});
});
+477 -310
View File
@@ -6,22 +6,31 @@ import { useProjectStore } from '../stores/projectStore';
import { KGCore } from '../core/KGCore';
import { KGMidiRegion } from '../core/region/KGMidiRegion';
import { KGMidiNote } from '../core/midi/KGMidiNote';
import { KGMidiPitchBend } from '../core/midi/KGMidiPitchBend';
import { KGPianoRollState } from '../core/state/KGPianoRollState';
import {
clampMidiPitchBendValue,
formatMidiEventLength,
formatMidiEventPosition,
MIDI_EVENT_TICKS_PER_BEAT,
MIDI_PITCH_BEND_CENTER,
MIDI_PITCH_BEND_MAX_SIGNED,
MIDI_PITCH_BEND_MIN_SIGNED,
midiPitchBendToNormalized,
midiPitchBendToSignedValue,
noteNameToPitch,
parseMidiEventLengthDelta,
parseMidiEventLength,
parseMidiEventPositionDelta,
parseMidiEventPosition,
pitchToNoteNameString
pitchToNoteNameString,
signedPitchBendToMidiValue
} from '../util/midiUtil';
import { isModifierKeyPressed } from '../util/osUtil';
import { PIANO_ROLL_CONSTANTS } from '../constants';
import { CreateNoteCommand } from '../core/commands';
import { UpdateNotePropertiesCommand } from '../core/commands/note/UpdateNotePropertiesCommand';
import { UpdatePitchBendPropertiesCommand } from '../core/commands/note/UpdatePitchBendPropertiesCommand';
import { showAlert } from '../util/dialogUtil';
interface ListEventPanelProps {
@@ -30,21 +39,28 @@ interface ListEventPanelProps {
interface NoteRowData {
id: string;
type: 'note';
note: KGMidiNote;
absoluteStartBeat: number;
durationBeats: number;
}
interface PitchBendRowData {
id: string;
type: 'pitch-bend';
pitchBend: KGMidiPitchBend;
absoluteBeat: number;
}
type EventRowData = NoteRowData | PitchBendRowData;
type EditableColumn = 'position' | 'num' | 'val' | 'length';
interface EditingCell {
noteId: string;
eventId: string;
column: EditableColumn;
value: string;
}
const EVENT_TYPE_OPTIONS = [{ label: 'Notes', value: 'notes' }];
const parseVelocityInput = (raw: string): { velocity: number } | { error: string } => {
const trimmed = raw.trim();
if (!/^\d+$/.test(trimmed)) {
@@ -85,6 +101,35 @@ const parsePitchDeltaInput = (raw: string): { delta: number } | { error: string
return { delta: parseInt(trimmed, 10) };
};
const parsePitchBendInput = (raw: string): { value: number } | { error: string } => {
const trimmed = raw.trim();
if (!/^[+-]?\d+$/.test(trimmed)) {
return { error: `Pitch bend value must be an integer between ${MIDI_PITCH_BEND_MIN_SIGNED} and ${MIDI_PITCH_BEND_MAX_SIGNED}.` };
}
const signedValue = parseInt(trimmed, 10);
if (signedValue < MIDI_PITCH_BEND_MIN_SIGNED || signedValue > MIDI_PITCH_BEND_MAX_SIGNED) {
return { error: `Pitch bend value must be between ${MIDI_PITCH_BEND_MIN_SIGNED} and ${MIDI_PITCH_BEND_MAX_SIGNED}.` };
}
return { value: signedPitchBendToMidiValue(signedValue) };
};
const parsePitchBendDeltaInput = (raw: string): { delta: number } | { error: string } => {
const trimmed = raw.trim();
if (!/^[+-]\d+$/.test(trimmed)) {
return { error: 'Use pitch bend delta like +256 or -512.' };
}
return { delta: parseInt(trimmed, 10) };
};
const formatPitchBendInfo = (value: number): string => {
const normalized = midiPitchBendToNormalized(value);
const semitones = normalized * 2;
return `Raw ${value} | ${normalized.toFixed(3)} | ${semitones.toFixed(2)} st`;
};
const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
const {
tracks,
@@ -92,16 +137,18 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
selectedRegionIds,
timeSignature,
selectedNoteIds,
selectedPitchBendIds,
playheadPosition,
updateTrack,
refreshProjectState
} = useProjectStore();
const [eventType, setEventType] = useState('notes');
const [showNotes, setShowNotes] = useState(true);
const [showPitchBends, setShowPitchBends] = useState(true);
const [quantPosition, setQuantPosition] = useState<string>('1/8');
const [quantLength, setQuantLength] = useState<string>('1/8');
const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
const rangeAnchorNoteIdRef = useRef<string | null>(null);
const rangeAnchorEventIdRef = useRef<string | null>(null);
const editInputRef = useRef<HTMLInputElement | null>(null);
const suppressBlurCommitRef = useRef(false);
const pendingSingleClickSelectionRef = useRef<number | null>(null);
@@ -127,28 +174,45 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
}
const noteRows: NoteRowData[] = activeMidiRegion
? [...activeMidiRegion.getNotes()]
.sort((a, b) => {
if (a.getStartBeat() !== b.getStartBeat()) return a.getStartBeat() - b.getStartBeat();
if (a.getPitch() !== b.getPitch()) return a.getPitch() - b.getPitch();
return a.getId().localeCompare(b.getId());
})
.map(note => ({
id: note.getId(),
note,
absoluteStartBeat: activeMidiRegion!.getStartFromBeat() + note.getStartBeat(),
durationBeats: note.getEndBeat() - note.getStartBeat()
}))
? activeMidiRegion.getNotes().map(note => ({
id: note.getId(),
type: 'note',
note,
absoluteStartBeat: activeMidiRegion!.getStartFromBeat() + note.getStartBeat(),
durationBeats: note.getEndBeat() - note.getStartBeat(),
}))
: [];
const pitchBendRows: PitchBendRowData[] = activeMidiRegion
? activeMidiRegion.getPitchBends().map(pitchBend => ({
id: pitchBend.getId(),
type: 'pitch-bend',
pitchBend,
absoluteBeat: activeMidiRegion!.getStartFromBeat() + pitchBend.getBeat(),
}))
: [];
const eventRows: EventRowData[] = [
...(showNotes ? noteRows : []),
...(showPitchBends ? pitchBendRows : []),
].sort((a, b) => {
const beatDelta = (a.type === 'note' ? a.absoluteStartBeat : a.absoluteBeat)
- (b.type === 'note' ? b.absoluteStartBeat : b.absoluteBeat);
if (beatDelta !== 0) return beatDelta;
if (a.type !== b.type) return a.type === 'pitch-bend' ? -1 : 1;
return a.id.localeCompare(b.id);
});
const selectedNoteIdSet = new Set(selectedNoteIds);
const selectedPitchBendIdSet = new Set(selectedPitchBendIds);
const selectedEventIdSet = new Set([...selectedNoteIds, ...selectedPitchBendIds]);
useEffect(() => {
if (editingCell) {
editInputRef.current?.focus();
editInputRef.current?.select();
}
}, [editingCell?.noteId, editingCell?.column]);
}, [editingCell?.eventId, editingCell?.column]);
useEffect(() => {
return () => {
@@ -161,20 +225,23 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
const commitSelection = (nextSelectedIds: Set<string>) => {
if (!activeMidiRegion || !parentTrack) return;
const selectedNotes = activeMidiRegion.getNotes().filter(note => nextSelectedIds.has(note.getId()));
const selectedEvents = eventRows
.filter(row => nextSelectedIds.has(row.id))
.map(row => row.type === 'note' ? row.note : row.pitchBend);
const core = KGCore.instance();
activeMidiRegion.getNotes().forEach(note => {
if (nextSelectedIds.has(note.getId())) {
note.select();
} else {
note.deselect();
}
if (nextSelectedIds.has(note.getId())) note.select();
else note.deselect();
});
activeMidiRegion.getPitchBends().forEach(pitchBend => {
if (nextSelectedIds.has(pitchBend.getId())) pitchBend.select();
else pitchBend.deselect();
});
core.clearSelectedItems();
if (selectedNotes.length > 0) {
core.addSelectedItems(selectedNotes);
if (selectedEvents.length > 0) {
core.addSelectedItems(selectedEvents);
}
void updateTrack(parentTrack);
@@ -187,9 +254,9 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
}
};
const startEditingCell = (noteId: string, column: EditableColumn, value: string) => {
const startEditingCell = (eventId: string, column: EditableColumn, value: string) => {
clearPendingSingleClickSelection();
setEditingCell({ noteId, column, value });
setEditingCell({ eventId, column, value });
};
const cancelEditingCell = () => {
@@ -199,194 +266,283 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
const commitEditingCell = async () => {
if (!editingCell || !activeMidiRegion || !parentTrack) return;
const note = activeMidiRegion.getNotes().find(candidate => candidate.getId() === editingCell.noteId);
if (!note) {
const row = eventRows.find(candidate => candidate.id === editingCell.eventId);
if (!row) {
setEditingCell(null);
return;
}
const targetNotes = selectedNoteIdSet.has(note.getId()) && selectedNoteIds.length > 1
? activeMidiRegion.getNotes().filter(candidate => selectedNoteIdSet.has(candidate.getId()))
: [note];
const snapshots = targetNotes.map(targetNote => ({
noteId: targetNote.getId(),
pitch: targetNote.getPitch(),
velocity: targetNote.getVelocity(),
startBeat: targetNote.getStartBeat(),
endBeat: targetNote.getEndBeat()
}));
const updates: Array<{ noteId: string; pitch?: number; velocity?: number; startBeat?: number; endBeat?: number }> = [];
const trimmedValue = editingCell.value.trim();
const isDeltaEdit = trimmedValue.startsWith('+') || trimmedValue.startsWith('-');
if (editingCell.column === 'position') {
if (isDeltaEdit) {
const parsed = parseMidiEventPositionDelta(trimmedValue, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
if ('error' in parsed) {
await showAlert(parsed.error);
return;
}
if (row.type === 'note') {
const note = row.note;
const targetNotes = selectedNoteIdSet.has(note.getId()) && selectedNoteIds.length > 1
? activeMidiRegion.getNotes().filter(candidate => selectedNoteIdSet.has(candidate.getId()))
: [note];
for (const targetNote of targetNotes) {
const currentDuration = targetNote.getEndBeat() - targetNote.getStartBeat();
const nextStartBeat = targetNote.getStartBeat() + parsed.deltaBeats;
if (nextStartBeat < 0) {
await showAlert('Position delta would move one or more notes before the start of the current MIDI region.');
const snapshots = targetNotes.map(targetNote => ({
noteId: targetNote.getId(),
pitch: targetNote.getPitch(),
velocity: targetNote.getVelocity(),
startBeat: targetNote.getStartBeat(),
endBeat: targetNote.getEndBeat()
}));
const updates: Array<{ noteId: string; pitch?: number; velocity?: number; startBeat?: number; endBeat?: number }> = [];
if (editingCell.column === 'position') {
if (isDeltaEdit) {
const parsed = parseMidiEventPositionDelta(trimmedValue, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
if ('error' in parsed) {
await showAlert(parsed.error);
return;
}
updates.push({
noteId: targetNote.getId(),
startBeat: nextStartBeat,
endBeat: nextStartBeat + currentDuration
});
}
} else {
const parsed = parseMidiEventPosition(trimmedValue, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
if ('error' in parsed) {
await showAlert(parsed.error);
return;
}
for (const targetNote of targetNotes) {
const currentDuration = targetNote.getEndBeat() - targetNote.getStartBeat();
const nextStartBeat = targetNote.getStartBeat() + parsed.deltaBeats;
if (nextStartBeat < 0) {
await showAlert('Position delta would move one or more notes before the start of the current MIDI region.');
return;
}
const relativeStartBeat = parsed.absoluteBeat - activeMidiRegion.getStartFromBeat();
if (relativeStartBeat < 0) {
await showAlert('Position cannot be earlier than the start of the current MIDI region.');
return;
}
for (const targetNote of targetNotes) {
const currentDuration = targetNote.getEndBeat() - targetNote.getStartBeat();
updates.push({
noteId: targetNote.getId(),
startBeat: relativeStartBeat,
endBeat: relativeStartBeat + currentDuration
});
}
}
}
if (editingCell.column === 'num') {
if (isDeltaEdit) {
const parsed = parsePitchDeltaInput(trimmedValue);
if ('error' in parsed) {
await showAlert(parsed.error);
return;
}
for (const targetNote of targetNotes) {
const nextPitch = targetNote.getPitch() + parsed.delta;
if (nextPitch < 0 || nextPitch > 127) {
await showAlert('Num delta would move one or more notes outside the MIDI pitch range 0127.');
return;
updates.push({
noteId: targetNote.getId(),
startBeat: nextStartBeat,
endBeat: nextStartBeat + currentDuration
});
}
updates.push({ noteId: targetNote.getId(), pitch: nextPitch });
}
} else {
const parsed = parseNoteNameInput(trimmedValue);
if ('error' in parsed) {
await showAlert(parsed.error);
return;
}
for (const targetNote of targetNotes) {
updates.push({ noteId: targetNote.getId(), pitch: parsed.pitch });
}
}
}
if (editingCell.column === 'val') {
if (isDeltaEdit) {
const parsed = parseVelocityDeltaInput(trimmedValue);
if ('error' in parsed) {
await showAlert(parsed.error);
return;
}
for (const targetNote of targetNotes) {
const nextVelocity = targetNote.getVelocity() + parsed.delta;
if (nextVelocity < 0 || nextVelocity > 127) {
await showAlert('Velocity delta would move one or more notes outside the valid range 0127.');
return;
}
updates.push({ noteId: targetNote.getId(), velocity: nextVelocity });
}
} else {
const parsed = parseVelocityInput(trimmedValue);
if ('error' in parsed) {
await showAlert(parsed.error);
return;
}
for (const targetNote of targetNotes) {
updates.push({ noteId: targetNote.getId(), velocity: parsed.velocity });
}
}
}
if (editingCell.column === 'length') {
if (isDeltaEdit) {
const parsed = parseMidiEventLengthDelta(trimmedValue, MIDI_EVENT_TICKS_PER_BEAT);
if ('error' in parsed) {
await showAlert(parsed.error);
return;
}
for (const targetNote of targetNotes) {
const currentDuration = targetNote.getEndBeat() - targetNote.getStartBeat();
const nextDuration = currentDuration + parsed.deltaBeats;
if (nextDuration <= 0) {
await showAlert('Length delta would make one or more notes non-positive in duration.');
} else {
const parsed = parseMidiEventPosition(trimmedValue, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
if ('error' in parsed) {
await showAlert(parsed.error);
return;
}
updates.push({
noteId: targetNote.getId(),
endBeat: targetNote.getStartBeat() + nextDuration
});
}
} else {
const parsed = parseMidiEventLength(trimmedValue, MIDI_EVENT_TICKS_PER_BEAT);
if ('error' in parsed) {
await showAlert(parsed.error);
return;
}
const relativeStartBeat = parsed.absoluteBeat - activeMidiRegion.getStartFromBeat();
if (relativeStartBeat < 0) {
await showAlert('Position cannot be earlier than the start of the current MIDI region.');
return;
}
for (const targetNote of targetNotes) {
updates.push({
noteId: targetNote.getId(),
endBeat: targetNote.getStartBeat() + parsed.duration
});
for (const targetNote of targetNotes) {
const currentDuration = targetNote.getEndBeat() - targetNote.getStartBeat();
updates.push({
noteId: targetNote.getId(),
startBeat: relativeStartBeat,
endBeat: relativeStartBeat + currentDuration
});
}
}
}
if (editingCell.column === 'num') {
if (isDeltaEdit) {
const parsed = parsePitchDeltaInput(trimmedValue);
if ('error' in parsed) {
await showAlert(parsed.error);
return;
}
for (const targetNote of targetNotes) {
const nextPitch = targetNote.getPitch() + parsed.delta;
if (nextPitch < 0 || nextPitch > 127) {
await showAlert('Num delta would move one or more notes outside the MIDI pitch range 0127.');
return;
}
updates.push({ noteId: targetNote.getId(), pitch: nextPitch });
}
} else {
const parsed = parseNoteNameInput(trimmedValue);
if ('error' in parsed) {
await showAlert(parsed.error);
return;
}
for (const targetNote of targetNotes) {
updates.push({ noteId: targetNote.getId(), pitch: parsed.pitch });
}
}
}
if (editingCell.column === 'val') {
if (isDeltaEdit) {
const parsed = parseVelocityDeltaInput(trimmedValue);
if ('error' in parsed) {
await showAlert(parsed.error);
return;
}
for (const targetNote of targetNotes) {
const nextVelocity = targetNote.getVelocity() + parsed.delta;
if (nextVelocity < 0 || nextVelocity > 127) {
await showAlert('Velocity delta would move one or more notes outside the valid range 0127.');
return;
}
updates.push({ noteId: targetNote.getId(), velocity: nextVelocity });
}
} else {
const parsed = parseVelocityInput(trimmedValue);
if ('error' in parsed) {
await showAlert(parsed.error);
return;
}
for (const targetNote of targetNotes) {
updates.push({ noteId: targetNote.getId(), velocity: parsed.velocity });
}
}
}
if (editingCell.column === 'length') {
if (isDeltaEdit) {
const parsed = parseMidiEventLengthDelta(trimmedValue, MIDI_EVENT_TICKS_PER_BEAT);
if ('error' in parsed) {
await showAlert(parsed.error);
return;
}
for (const targetNote of targetNotes) {
const currentDuration = targetNote.getEndBeat() - targetNote.getStartBeat();
const nextDuration = currentDuration + parsed.deltaBeats;
if (nextDuration <= 0) {
await showAlert('Length delta would make one or more notes non-positive in duration.');
return;
}
updates.push({
noteId: targetNote.getId(),
endBeat: targetNote.getStartBeat() + nextDuration
});
}
} else {
const parsed = parseMidiEventLength(trimmedValue, MIDI_EVENT_TICKS_PER_BEAT);
if ('error' in parsed) {
await showAlert(parsed.error);
return;
}
for (const targetNote of targetNotes) {
updates.push({
noteId: targetNote.getId(),
endBeat: targetNote.getStartBeat() + parsed.duration
});
}
}
}
if (updates.length === 0) {
setEditingCell(null);
return;
}
KGCore.instance().executeCommand(new UpdateNotePropertiesCommand(activeMidiRegion.getId(), snapshots, updates));
} else {
const pitchBend = row.pitchBend;
const targetPitchBends = selectedPitchBendIdSet.has(pitchBend.getId()) && selectedPitchBendIds.length > 1
? activeMidiRegion.getPitchBends().filter(candidate => selectedPitchBendIdSet.has(candidate.getId()))
: [pitchBend];
const snapshots = targetPitchBends.map(targetPitchBend => ({
pitchBendId: targetPitchBend.getId(),
beat: targetPitchBend.getBeat(),
value: targetPitchBend.getValue(),
}));
const updates: Array<{ pitchBendId: string; beat?: number; value?: number }> = [];
if (editingCell.column === 'position') {
if (isDeltaEdit) {
const parsed = parseMidiEventPositionDelta(trimmedValue, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
if ('error' in parsed) {
await showAlert(parsed.error);
return;
}
for (const targetPitchBend of targetPitchBends) {
const nextBeat = targetPitchBend.getBeat() + parsed.deltaBeats;
if (nextBeat < 0) {
await showAlert('Position delta would move one or more pitch bends before the start of the current MIDI region.');
return;
}
updates.push({ pitchBendId: targetPitchBend.getId(), beat: nextBeat });
}
} else {
const parsed = parseMidiEventPosition(trimmedValue, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
if ('error' in parsed) {
await showAlert(parsed.error);
return;
}
const relativeBeat = parsed.absoluteBeat - activeMidiRegion.getStartFromBeat();
if (relativeBeat < 0) {
await showAlert('Position cannot be earlier than the start of the current MIDI region.');
return;
}
for (const targetPitchBend of targetPitchBends) {
updates.push({ pitchBendId: targetPitchBend.getId(), beat: relativeBeat });
}
}
}
if (editingCell.column === 'val') {
if (isDeltaEdit) {
const parsed = parsePitchBendDeltaInput(trimmedValue);
if ('error' in parsed) {
await showAlert(parsed.error);
return;
}
for (const targetPitchBend of targetPitchBends) {
const nextSignedValue = midiPitchBendToSignedValue(targetPitchBend.getValue()) + parsed.delta;
if (nextSignedValue < MIDI_PITCH_BEND_MIN_SIGNED || nextSignedValue > MIDI_PITCH_BEND_MAX_SIGNED) {
await showAlert(`Pitch bend delta would move one or more events outside the valid range ${MIDI_PITCH_BEND_MIN_SIGNED}${MIDI_PITCH_BEND_MAX_SIGNED}.`);
return;
}
updates.push({
pitchBendId: targetPitchBend.getId(),
value: signedPitchBendToMidiValue(nextSignedValue),
});
}
} else {
const parsed = parsePitchBendInput(trimmedValue);
if ('error' in parsed) {
await showAlert(parsed.error);
return;
}
for (const targetPitchBend of targetPitchBends) {
updates.push({ pitchBendId: targetPitchBend.getId(), value: clampMidiPitchBendValue(parsed.value) });
}
}
}
if (updates.length === 0) {
setEditingCell(null);
return;
}
KGCore.instance().executeCommand(new UpdatePitchBendPropertiesCommand(activeMidiRegion.getId(), snapshots, updates));
}
if (updates.length === 0) {
setEditingCell(null);
return;
}
const command = new UpdateNotePropertiesCommand(activeMidiRegion.getId(), snapshots, updates);
KGCore.instance().executeCommand(command);
await updateTrack(parentTrack);
refreshProjectState();
setEditingCell(null);
};
const handleRowClick = (noteId: string, rowIndex: number, event: React.MouseEvent<HTMLTableRowElement>) => {
const handleRowClick = (eventId: string, rowIndex: number, event: React.MouseEvent<HTMLTableRowElement>) => {
event.stopPropagation();
if (editingCell) return;
if (!activeMidiRegion) return;
const isModifierPressed = isModifierKeyPressed(event);
const nextSelectedIds = new Set(selectedNoteIdSet);
const isAlreadySelected = selectedNoteIdSet.has(noteId);
const hasMultiSelection = selectedNoteIds.length > 1;
const nextSelectedIds = new Set(selectedEventIdSet);
const isAlreadySelected = selectedEventIdSet.has(eventId);
const hasMultiSelection = selectedEventIdSet.size > 1;
if (event.shiftKey) {
clearPendingSingleClickSelection();
const anchorIndex = noteRows.findIndex(row => row.id === rangeAnchorNoteIdRef.current);
const anchorIndex = eventRows.findIndex(row => row.id === rangeAnchorEventIdRef.current);
const rangeStartIndex = anchorIndex >= 0 ? Math.min(anchorIndex, rowIndex) : rowIndex;
const rangeEndIndex = anchorIndex >= 0 ? Math.max(anchorIndex, rowIndex) : rowIndex;
@@ -395,22 +551,22 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
}
for (let index = rangeStartIndex; index <= rangeEndIndex; index += 1) {
nextSelectedIds.add(noteRows[index].id);
nextSelectedIds.add(eventRows[index].id);
}
} else if (isModifierPressed) {
clearPendingSingleClickSelection();
if (nextSelectedIds.has(noteId)) {
nextSelectedIds.delete(noteId);
if (nextSelectedIds.has(eventId)) {
nextSelectedIds.delete(eventId);
} else {
nextSelectedIds.add(noteId);
nextSelectedIds.add(eventId);
}
rangeAnchorNoteIdRef.current = noteId;
rangeAnchorEventIdRef.current = eventId;
} else {
if (isAlreadySelected && hasMultiSelection) {
clearPendingSingleClickSelection();
pendingSingleClickSelectionRef.current = window.setTimeout(() => {
const delayedSelection = new Set<string>([noteId]);
rangeAnchorNoteIdRef.current = noteId;
const delayedSelection = new Set<string>([eventId]);
rangeAnchorEventIdRef.current = eventId;
commitSelection(delayedSelection);
pendingSingleClickSelectionRef.current = null;
}, 220);
@@ -419,12 +575,12 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
clearPendingSingleClickSelection();
nextSelectedIds.clear();
nextSelectedIds.add(noteId);
rangeAnchorNoteIdRef.current = noteId;
nextSelectedIds.add(eventId);
rangeAnchorEventIdRef.current = eventId;
}
if (event.shiftKey && rangeAnchorNoteIdRef.current === null) {
rangeAnchorNoteIdRef.current = noteId;
if (event.shiftKey && rangeAnchorEventIdRef.current === null) {
rangeAnchorEventIdRef.current = eventId;
}
commitSelection(nextSelectedIds);
@@ -434,7 +590,7 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
event.stopPropagation();
if (event.target !== event.currentTarget) return;
clearPendingSingleClickSelection();
rangeAnchorNoteIdRef.current = null;
rangeAnchorEventIdRef.current = null;
commitSelection(new Set());
};
@@ -552,7 +708,7 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
createdNote.select();
KGCore.instance().clearSelectedItems();
KGCore.instance().addSelectedItem(createdNote);
rangeAnchorNoteIdRef.current = createdNote.getId();
rangeAnchorEventIdRef.current = createdNote.getId();
}
await updateTrack(parentTrack);
refreshProjectState();
@@ -566,9 +722,23 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
<div className="list-event-panel-body">
<div className="list-event-tabs" role="tablist" aria-label="Event types">
<button className="list-event-tab active" aria-pressed="true">Notes</button>
<button className="list-event-tab" aria-pressed="false">Pitch Bends</button>
<button className="list-event-tab" aria-pressed="false">Controller</button>
<button
className={`list-event-tab${showNotes ? ' active' : ''}`}
aria-pressed={showNotes}
type="button"
onClick={() => setShowNotes(value => !value)}
>
Notes
</button>
<button
className={`list-event-tab${showPitchBends ? ' active' : ''}`}
aria-pressed={showPitchBends}
type="button"
onClick={() => setShowPitchBends(value => !value)}
>
Pitch Bends
</button>
<button className="list-event-tab" aria-pressed="false" type="button" disabled>Controller</button>
</div>
{!activeMidiRegion ? (
@@ -587,14 +757,6 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
>
<FaPlus />
</button>
<KGDropdown
options={EVENT_TYPE_OPTIONS}
value={eventType}
onChange={setEventType}
label="Event Type"
buttonClassName="list-event-dropdown-button"
showValueAsLabel={true}
/>
</div>
<div className="list-event-toolbar-group list-event-toolbar-group-right">
@@ -638,113 +800,118 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
</tr>
</thead>
<tbody>
{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 (
<tr
key={row.id}
className={selectedNoteIdSet.has(row.id) ? 'selected' : ''}
onClick={(event) => handleRowClick(row.id, index, event)}
return (
<tr
key={row.id}
className={selectedEventIdSet.has(row.id) ? 'selected' : ''}
onClick={(event) => handleRowClick(row.id, index, event)}
onDoubleClick={(event) => {
event.stopPropagation();
clearPendingSingleClickSelection();
}}
>
<td
title={positionText}
onDoubleClick={(event) => {
event.stopPropagation();
clearPendingSingleClickSelection();
startEditingCell(row.id, 'position', positionText);
}}
>
<td
title={positionText}
onDoubleClick={(event) => {
event.stopPropagation();
startEditingCell(row.id, 'position', positionText);
}}
>
{isEditingPosition ? (
<input
ref={editInputRef}
className="list-event-cell-input"
value={editingCell.value}
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
onBlur={handleEditInputBlur}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
/>
) : positionText}
</td>
<td title={statusText}>{statusText}</td>
<td
title={noteText}
onDoubleClick={(event) => {
event.stopPropagation();
startEditingCell(row.id, 'num', noteText);
}}
>
{isEditingNum ? (
<input
ref={editInputRef}
className="list-event-cell-input"
value={editingCell.value}
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
onBlur={handleEditInputBlur}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
/>
) : noteText}
</td>
<td
title={velocityText}
onDoubleClick={(event) => {
event.stopPropagation();
startEditingCell(row.id, 'val', velocityText);
}}
>
{isEditingVal ? (
<input
ref={editInputRef}
className="list-event-cell-input"
value={editingCell.value}
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
onBlur={handleEditInputBlur}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
/>
) : velocityText}
</td>
<td
title={lengthText}
onDoubleClick={(event) => {
event.stopPropagation();
startEditingCell(row.id, 'length', lengthText);
}}
>
{isEditingLength ? (
<input
ref={editInputRef}
className="list-event-cell-input"
value={editingCell.value}
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
onBlur={handleEditInputBlur}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
/>
) : lengthText}
</td>
</tr>
);
})()
))}
{isEditingPosition ? (
<input
ref={editInputRef}
className="list-event-cell-input"
value={editingCell.value}
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
onBlur={handleEditInputBlur}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
/>
) : positionText}
</td>
<td title={statusText}>{statusText}</td>
<td
title={numText}
onDoubleClick={(event) => {
if (row.type !== 'note') return;
event.stopPropagation();
startEditingCell(row.id, 'num', numText);
}}
>
{isEditingNum ? (
<input
ref={editInputRef}
className="list-event-cell-input"
value={editingCell.value}
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
onBlur={handleEditInputBlur}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
/>
) : numText}
</td>
<td
title={valText}
onDoubleClick={(event) => {
event.stopPropagation();
startEditingCell(row.id, 'val', valText);
}}
>
{isEditingVal ? (
<input
ref={editInputRef}
className="list-event-cell-input"
value={editingCell.value}
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
onBlur={handleEditInputBlur}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
/>
) : valText}
</td>
<td
title={lengthText}
onDoubleClick={(event) => {
if (row.type !== 'note') return;
event.stopPropagation();
startEditingCell(row.id, 'length', lengthText);
}}
>
{isEditingLength ? (
<input
ref={editInputRef}
className="list-event-cell-input"
value={editingCell.value}
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
onBlur={handleEditInputBlur}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
/>
) : lengthText}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
+8
View File
@@ -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;
@@ -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);
});
});
+196 -5
View File
@@ -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<number, LiveMidiSource[]> = 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
};
}
}
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}`;
}
}
+132 -27
View File
@@ -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
*/
+2
View File
@@ -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';
@@ -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`);
}
}
+9 -145
View File
@@ -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
);
}
}
}
@@ -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;
}
}
@@ -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()];
@@ -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);
}
}
}
@@ -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);
}
}
}
@@ -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;
@@ -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<string, NoteAdjustment[]>();
private pitchBendAdjustments = new Map<string, PitchBendAdjustment[]>();
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) {
+64
View File
@@ -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);
});
});
+32 -4
View File
@@ -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 =====
+23
View File
@@ -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);
});
});
+66
View File
@@ -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';
}
}
+49 -1
View File
@@ -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);
});
});
});
});
+21
View File
@@ -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';
+45 -10
View File
@@ -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<number, { startBeat: number; velocity: number }> = 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<ProjectState>((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<ProjectState>((set, get) => {
// Initial selection state
selectedNoteIds: [],
selectedPitchBendIds: [],
selectedRegionIds: [],
selectedTrackId: initialSelectedTrackId,
@@ -377,6 +385,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
isRecording: false,
recordingTargetRegionId: null,
recordingNotes: [],
recordingPitchBends: [],
recordingOriginalPlayhead: 0,
// Initial cross-component scroll request state
@@ -875,10 +884,12 @@ export const useProjectStore = create<ProjectState>((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<ProjectState>((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<ProjectState>((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<ProjectState>((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<ProjectState>((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<ProjectState>((set, get) => {
}
};
});
@@ -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);
+3
View File
@@ -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),
+33 -1
View File
@@ -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),
},
};
};
+19
View File
@@ -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,
+21 -1
View File
@@ -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 };
};
};
+21
View File
@@ -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,