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