Skip to content

Commit e7dfd29

Browse files
Merge pull request #6 from simoncoudeville/xy-settings
Xy settings
2 parents 125cdc3 + ad7ff4f commit e7dfd29

6 files changed

Lines changed: 387 additions & 54 deletions

File tree

src/App.vue

Lines changed: 208 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
:pad-button-label-html="padButtonLabelHtml"
5757
@start-pad="onStartPad"
5858
@stop-pad="onStopPad"
59+
@update-pad="onUpdatePad"
5960
@delete="requestDeletePad"
6061
@edit="openEditDialog"
6162
/>
@@ -529,7 +530,7 @@ function clearVisualDisplay() {
529530
lastPlayedNotes.value = [];
530531
}
531532
532-
const PAD_COUNT = 15;
533+
const PAD_COUNT = 12;
533534
const PADS_KEY = "chordboard:pads";
534535
535536
function defaultPad() {
@@ -552,6 +553,10 @@ function defaultPad() {
552553
inversion: "root",
553554
voicing: "close",
554555
},
556+
settings: {
557+
x: "none",
558+
y: "none",
559+
},
555560
};
556561
}
557562
@@ -561,8 +566,33 @@ function loadPads() {
561566
try {
562567
const raw = localStorage.getItem(PADS_KEY);
563568
const parsed = JSON.parse(raw || "null");
564-
if (Array.isArray(parsed) && parsed.length === PAD_COUNT) {
565-
pads.value = parsed.map((p) => ({ ...defaultPad(), ...p }));
569+
if (Array.isArray(parsed)) {
570+
// 1. Load what fits normally
571+
const nextPads = pads.value.map((p, i) =>
572+
parsed[i] ? { ...defaultPad(), ...parsed[i] } : p
573+
);
574+
575+
// 2. Identify overflow pads that are assigned
576+
const overflow = parsed
577+
.slice(PAD_COUNT)
578+
.filter((p) => p && p.mode !== "unassigned" && p.assigned !== false);
579+
580+
// 3. Try to place overflow pads into empty slots (unassigned) in the new grid
581+
if (overflow.length > 0) {
582+
let overflowIndex = 0;
583+
for (let i = 0; i < nextPads.length; i++) {
584+
if (overflowIndex >= overflow.length) break;
585+
// If this slot is unassigned, fill it
586+
if (
587+
nextPads[i].mode === "unassigned" ||
588+
nextPads[i].assigned === false
589+
) {
590+
nextPads[i] = { ...defaultPad(), ...overflow[overflowIndex] };
591+
overflowIndex++;
592+
}
593+
}
594+
}
595+
pads.value = nextPads;
566596
}
567597
} catch {}
568598
}
@@ -901,48 +931,209 @@ function padButtonLabelHtml(pad) {
901931
import { reactive } from "vue";
902932
const activePadNotes = reactive({});
903933
904-
function onStartPad(idx) {
934+
const padTimers = reactive({});
935+
const padSchedules = reactive({});
936+
937+
function onStartPad(idx, e, coords) {
905938
try {
939+
// Clear any existing state for this pad
940+
if (padTimers[idx]) {
941+
padTimers[idx].forEach((id) => clearTimeout(id));
942+
padTimers[idx] = [];
943+
} else {
944+
padTimers[idx] = [];
945+
}
946+
padSchedules[idx] = []; // Track scheduled notes { note, time }
947+
906948
const pad = pads.value?.[idx];
907949
const rawNotes = padNotes(pad);
908950
const notes = simplifyNoteList(rawNotes);
909951
if (!notes.length) return;
910952
const sel = getSelectedChannel();
911953
const ch = sel?.ch;
912954
if (!ch) return;
955+
956+
// Calculate start parameters
957+
let baseVelocity = 0.75;
958+
let strumDuration = 0;
959+
let humanizeAmount = 0;
960+
let tiltAmount = 0;
961+
962+
const settings = pad.settings || { x: "none", y: "none" };
963+
964+
// Helper to process axis
965+
const processAxis = (axisValue, func) => {
966+
if (func === "velocity") {
967+
baseVelocity = axisValue;
968+
} else if (func === "strum") {
969+
// Map 0-1 to 0-500ms
970+
strumDuration = axisValue * 500;
971+
} else if (func === "humanization") {
972+
humanizeAmount = axisValue;
973+
} else if (func === "velocity-tilt") {
974+
// Map 0-1 to -1 to 1
975+
tiltAmount = (axisValue - 0.5) * 2;
976+
}
977+
};
978+
979+
if (coords) {
980+
processAxis(coords.x, settings.x);
981+
processAxis(coords.y, settings.y);
982+
}
983+
913984
activePadNotes[idx] = notes.slice();
914985
lastPlayedNotes.value = notes.slice(); // Remember last played chord
915-
try {
916-
ch.playNote(notes);
917-
} catch {
918-
for (const n of notes) {
986+
987+
const now = WebMidi.time;
988+
const strumStep =
989+
strumDuration > 0 && notes.length > 1 ? strumDuration / notes.length : 0;
990+
991+
// Look-ahead time for buffering (send notes this many ms in advance)
992+
const BUFFER_MS = 50;
993+
994+
// Play notes with individual velocity and timing
995+
notes.forEach((n, i) => {
996+
let vel = baseVelocity;
997+
998+
// Apply Velocity Tilt
999+
if (tiltAmount !== 0 && notes.length > 1) {
1000+
// -1 (bass loud) to 1 (treble loud)
1001+
// Position in chord: 0 to 1
1002+
const pos = i / (notes.length - 1);
1003+
// Bias: -1 (bass) to 1 (treble)
1004+
const bias = pos * 2 - 1;
1005+
// Effect: tilt * bias.
1006+
// If tilt is -1 (bass loud): bass(bias=-1) -> +1, treble(bias=1) -> -1
1007+
// If tilt is 1 (treble loud): bass(bias=-1) -> -1, treble(bias=1) -> +1
1008+
// Scale factor: 0.25 (so +/- 0.25 velocity change)
1009+
vel += tiltAmount * bias * 0.25;
1010+
}
1011+
1012+
// Apply Humanization (Velocity)
1013+
if (humanizeAmount > 0) {
1014+
// +/- 0.2 (approx 25 velocity steps) at max
1015+
const delta = (Math.random() - 0.5) * 0.4 * humanizeAmount;
1016+
vel += delta;
1017+
}
1018+
1019+
// Clamp velocity
1020+
vel = Math.max(0.01, Math.min(1, vel));
1021+
1022+
// Calculate exact MIDI timestamp for this note
1023+
let noteTime = now + i * strumStep;
1024+
1025+
// Apply Humanization (Microtiming)
1026+
if (humanizeAmount > 0) {
1027+
// +/- 35ms * amount
1028+
const timeDelta = (Math.random() - 0.5) * 70 * humanizeAmount;
1029+
noteTime += timeDelta;
1030+
}
1031+
1032+
// Ensure we don't schedule in the past
1033+
if (noteTime < now) noteTime = now;
1034+
1035+
const schedule = () => {
9191036
try {
920-
ch.playNote(n);
1037+
// Use precise MIDI time
1038+
ch.playNote(n, { attack: vel, time: noteTime });
1039+
// Track that we sent this note to the driver
1040+
padSchedules[idx].push({ note: n, time: noteTime });
9211041
} catch {}
1042+
};
1043+
1044+
const timeUntilNote = noteTime - WebMidi.time;
1045+
1046+
// If note is far in future, wait before scheduling
1047+
// This allows us to cancel the setTimeout if user releases pad early
1048+
if (timeUntilNote > BUFFER_MS) {
1049+
const wakeupTime = timeUntilNote - BUFFER_MS;
1050+
const timerId = setTimeout(schedule, wakeupTime);
1051+
padTimers[idx].push(timerId);
1052+
} else {
1053+
// Close enough, schedule immediately
1054+
schedule();
9221055
}
1056+
});
1057+
1058+
// Send initial continuous values if applicable
1059+
if (coords) {
1060+
sendContinuousExpression(ch, settings.x, coords.x);
1061+
sendContinuousExpression(ch, settings.y, coords.y);
9231062
}
9241063
} catch {}
9251064
}
9261065
927-
function onStopPad(idx) {
1066+
function onUpdatePad(idx, coords) {
9281067
try {
929-
const notes = activePadNotes[idx] || [];
930-
if (!notes.length) return;
1068+
const pad = pads.value?.[idx];
1069+
if (!pad) return;
9311070
const sel = getSelectedChannel();
9321071
const ch = sel?.ch;
9331072
if (!ch) return;
1073+
1074+
const settings = pad.settings || { x: "none", y: "none" };
1075+
sendContinuousExpression(ch, settings.x, coords.x);
1076+
sendContinuousExpression(ch, settings.y, coords.y);
1077+
} catch {}
1078+
}
1079+
1080+
function sendContinuousExpression(ch, func, value) {
1081+
if (func === "aftertouch") {
1082+
// Send channel aftertouch
9341083
try {
935-
ch.stopNote(notes);
936-
} catch {
937-
for (const n of notes) {
1084+
ch.setChannelAftertouch(value);
1085+
} catch {}
1086+
}
1087+
}
1088+
1089+
function onStopPad(idx) {
1090+
try {
1091+
// 1. Cancel any future notes that haven't been sent to MIDI driver yet
1092+
if (padTimers[idx]) {
1093+
padTimers[idx].forEach((id) => clearTimeout(id));
1094+
padTimers[idx] = [];
1095+
}
1096+
1097+
// 2. Stop notes that were already sent or played
1098+
// We need to stop them SAFELY.
1099+
// If a note was scheduled for the future (e.g. now + 100ms),
1100+
// sending stopNote() NOW (at now) might be ignored or cause stuck note
1101+
// depending on the driver/synth because NoteOff comes before NoteOn.
1102+
// So we must schedule the stop to happen slightly AFTER the start.
1103+
const scheduled = padSchedules[idx] || [];
1104+
const now = WebMidi.time;
1105+
1106+
// We also stop 'activePadNotes' just in case, though padSchedules covers it
1107+
const fallbackNotes = activePadNotes[idx] || [];
1108+
const sel = getSelectedChannel();
1109+
const ch = sel?.ch;
1110+
1111+
if (ch) {
1112+
// Create a set of notes we know about from schedule
1113+
const handledNotes = new Set();
1114+
1115+
scheduled.forEach((item) => {
1116+
handledNotes.add(item.note);
9381117
try {
939-
ch.stopNote(n);
1118+
// Schedule stop at least 20ms after start, or now, whichever is later
1119+
const stopTime = Math.max(now, item.time + 20);
1120+
ch.stopNote(item.note, { time: stopTime });
9401121
} catch {}
941-
}
1122+
});
1123+
1124+
// Legacy cleanup for any notes not in our schedule list (safety net)
1125+
fallbackNotes.forEach((n) => {
1126+
if (!handledNotes.has(n)) {
1127+
try {
1128+
ch.stopNote(n);
1129+
} catch {}
1130+
}
1131+
});
9421132
}
9431133
} catch {
9441134
} finally {
9451135
activePadNotes[idx] = [];
1136+
padSchedules[idx] = [];
9461137
}
9471138
}
9481139
// Preview MIDI handling

0 commit comments

Comments
 (0)