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 ;
533534const PADS_KEY = " chordboard:pads" ;
534535
535536function 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) {
901931import { reactive } from " vue" ;
902932const 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