Skip to content

Commit 388da9d

Browse files
authored
Add pitch bend support
1 parent 88dcfc2 commit 388da9d

9 files changed

+174
-37
lines changed

index.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,12 @@ h1, h2, h3 {
9696
align-self: center;
9797
}
9898

99+
#pitchbend img {
100+
align-self: center;
101+
width: 60%;
102+
height: auto;
103+
}
104+
99105
ol, ul {
100106
margin: 0;
101107
}

index.html

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,19 @@ <h2><label for="midifile">MIDI file</label></h2>
5353
title="What quantization snap to use for warning about unquantized notes. This should be a power of 2. Alternatively, setting this to 0 will ignore all quantization warnings. Triplets (snap 12) are recognized automatically, regardless.">i</abbr>
5454
<label for="snap">Snap</label>
5555
<input type="number" id="snap" name="snap" min="0" max="192" value="32">
56+
57+
<abbr
58+
title="What range to treat pitch bend events (e.g. if set to 2, pitch bend events can move notes up/down by a maximum of 2 semitones)">i</abbr>
59+
<label for="pitchbendrange">Pitch Bend Range (semitones)</label>
60+
<select name="pitchbendrange" id="pitchbendrange">
61+
<option value="1">1</option>
62+
<option value="2" selected="selected">2</option>
63+
<option value="3">3</option>
64+
<option value="4">4</option>
65+
<option value="5">5</option>
66+
<option value="6">6</option>
67+
<option value="7">7</option>
68+
</select>
5669
</div>
5770
<div id="midiwarnings" class="warnings"></div>
5871
</div>
@@ -222,8 +235,52 @@ <h3>tccc mode</h3>
222235
<p>Overlapping more than two notes is undefined behavior in tccc mode. Don't do it.</p>
223236
</div>
224237

238+
<div id="pitchbend" class="container">
239+
<h2>Pitch Bend Events</h2>
240+
241+
<p>TCCC can perform additional adjustments to the pitch of notes using MIDI Pitch Bend events, allowing you to
242+
set notes to microtonal values.</p>
243+
244+
<ul>
245+
<li>The Pitch Bend Range option controls what range (in semitones) the MIDI events are converted to.</li>
246+
<ul>
247+
<li>E.g. At a Pitch Bend Range of 2, a maximum value pitch bend MIDI event will raise/lower the note pitch by 2 semitones.</li>
248+
<li>Make sure this range matches the range of the DAW output device you used when making the MIDI (e.g. your
249+
synth plugin, soundfont player, etc).</li>
250+
<ul>
251+
<li>Most default to +-2 semitones, but it varies across devices so it's best to check your setup.</li>
252+
</ul>
253+
</ul>
254+
<br>
255+
<li>TCCC takes the pitch bend values at the start and end of a particular note, and shifts the start/end by that amount.</li>
256+
</ul>
257+
258+
<h3>Examples</h3>
259+
<p>Pitch bend event covers all notes, so all are shifted up by the same amount (the unadjusted note is shown faded
260+
out). Make sure your desired pitch bend finishes after the note end.</p>
261+
<img src="res/pitch_example_both_notes.png"/>
262+
263+
<p>Pitch bend event covers just the start of the first note of the slide, so the start is shifted down.</p>
264+
<img src="res/pitch_example_first_note.png"/>
265+
266+
<p>You can shift the start/end of a single-note slide by placing the pitch bend around just the start/end note.
267+
In this case the end of the slide is shifted down.</p>
268+
<img src="res/pitch_example_single_slide.png"/>
269+
270+
<p>When shifting a note connected to the end of a slide, make sure to shift both the end note and the slide note
271+
that connects to it.</p>
272+
<img src="res/pitch_example_second_note.png"/>
273+
274+
<p>For pitch shift gradients/curves, the pitch shift amount is taken at the start and end of each note.</p>
275+
<img src="res/pitch_example_gradient.png"/>
276+
</div>
277+
225278
<div class="container">
226279
<h2>Version history</h2>
280+
<p>
281+
v1.8:<br>
282+
Added support for converting MIDI pitch bend events into note pitch adjustments
283+
</p>
227284
<p>
228285
v1.7e:<br>
229286
Added CONTRIBUTING.md to the Github repo, along with test resources
@@ -314,7 +371,7 @@ <h2>Version history</h2>
314371
</div>
315372
<div class="footer">
316373
<p>
317-
<a href="https://github.com/TC-Chart-Converter/TC-Chart-Converter.github.io/">Trombone Champ Chart Converter</a> by RShields and contributors<br>
374+
<a href="https://github.com/TC-Chart-Converter/TC-Chart-Converter.github.io/">Trombone Champ Chart Converter</a> by RShields, Gloomhonk, and contributors<br>
318375
Licensed under the <a href="https://github.com/TC-Chart-Converter/TC-Chart-Converter.github.io/blob/main/LICENSE">GNU Affero General Public License v3.0</a><br>
319376
<br>
320377
<a href="https://github.com/colxi/midi-parser-js">MidiParser.js</a> by Sergi Guzman and contributors<br>

res/pitch_example_both_notes.png

20.6 KB
Loading

res/pitch_example_first_note.png

18.3 KB
Loading

res/pitch_example_gradient.png

28.9 KB
Loading

res/pitch_example_second_note.png

18.1 KB
Loading

res/pitch_example_single_slide.png

16.1 KB
Loading

src/midiToNotes.js

Lines changed: 106 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,16 @@ const MidiToNotes = (function () {
88
function generateNotes(midi) {
99
console.log(midi);
1010
MidiToNotes.notes = [];
11+
MidiToNotes.pitchBendEvents = [];
1112
midiWarnings.clear();
1213

1314
const { timeDivision } = midi;
1415

1516
/** All the events in the midi file, sorted by time */
1617
const sortedMidiEvents = getSortedMidiEvents(midi);
1718

19+
collectPitchBendEvents(sortedMidiEvents, timeDivision);
1820
generateWarnings(sortedMidiEvents, timeDivision);
19-
midiWarnings.display();
2021

2122
// Calculate endpoint
2223
for (let i = sortedMidiEvents.length - 1; i >= 0; i--) {
@@ -39,6 +40,7 @@ const MidiToNotes = (function () {
3940

4041
generateLyrics(sortedMidiEvents, timeDivision);
4142

43+
midiWarnings.display();
4244
Preview.display();
4345
}
4446

@@ -47,6 +49,8 @@ const MidiToNotes = (function () {
4749
return "noteOff";
4850
} else if (event.type === 9) {
4951
return "noteOn";
52+
} else if (event.type === 14) {
53+
return "pitchBend";
5054
} else if (event.type === 255) {
5155
return "meta";
5256
} else {
@@ -117,42 +121,51 @@ const MidiToNotes = (function () {
117121
// We ignore note-off events for pitches other than the current one
118122
// which prevents slide-start noteOffs from ending slides
119123
if (currentNote && pitch === currentNote.startPitch) {
120-
const { startTime, startPitch } = currentNote;
121-
124+
const { startTime, startPitch, startPitchBend } = currentNote;
122125
const length = event.time - startTime;
126+
const endPitchBend = getPitchBendAdjustmentAtTime(event.time);
127+
const tcStartPitch =
128+
convertPitch(startPitch, startPitchBend, startTime, timeDivision);
129+
const tcEndPitch =
130+
convertPitch(pitch, endPitchBend, event.time, timeDivision);
131+
const tcPitchDelta = tcEndPitch - tcStartPitch;
123132

124133
MidiToNotes.notes.push([
125134
startTime / timeDivision,
126135
length > 0 ? length / timeDivision : defaultNoteLength,
127-
(startPitch - 60) * 13.75,
128-
0,
129-
(startPitch - 60) * 13.75,
136+
tcStartPitch,
137+
tcPitchDelta,
138+
tcEndPitch,
130139
]);
131140

132141
currentNote = undefined;
133142
}
134143
} else if (getEventType(event) === "noteOn") {
135144
const pitch = event.data[0];
145+
const pitchBend = getPitchBendAdjustmentAtTime(event.time);
136146

137147
if (currentNote) {
138-
const { startTime, startPitch } = currentNote;
139-
148+
const { startTime, startPitch, startPitchBend } = currentNote;
140149
const length = event.time - startTime;
141-
const endPitch = pitch;
142-
const pitchDelta = endPitch - startPitch;
150+
const tcStartPitch =
151+
convertPitch(startPitch, startPitchBend, startTime, timeDivision);
152+
const tcEndPitch =
153+
convertPitch(pitch, pitchBend, event.time, timeDivision);
154+
const tcPitchDelta = tcEndPitch - tcStartPitch;
143155

144156
MidiToNotes.notes.push([
145157
startTime / timeDivision,
146158
length > 0 ? length / timeDivision : defaultNoteLength,
147-
(startPitch - 60) * 13.75,
148-
pitchDelta * 13.75,
149-
(endPitch - 60) * 13.75,
159+
tcStartPitch,
160+
tcPitchDelta,
161+
tcEndPitch,
150162
]);
151163
}
152164

153165
currentNote = {
154166
startTime: event.time,
155167
startPitch: pitch,
168+
startPitchBend: pitchBend,
156169
};
157170
}
158171
}
@@ -169,23 +182,28 @@ const MidiToNotes = (function () {
169182
// We ignore note-off events for pitches other than the current one
170183
// which prevents slide-start noteOffs from ending slides
171184
if (currentNote && pitch === currentNote.endPitch) {
172-
const { startTime, startPitch, endPitch } = currentNote;
173-
185+
const { startTime, startPitch, endPitch, startPitchBend } = currentNote;
186+
const endPitchBend = getPitchBendAdjustmentAtTime(event.time);
174187
const length = event.time - startTime;
175-
const pitchDelta = endPitch - startPitch;
188+
const tcStartPitch =
189+
convertPitch(startPitch, startPitchBend, startTime, timeDivision);
190+
const tcEndPitch =
191+
convertPitch(endPitch, endPitchBend, event.time, timeDivision);
192+
const tcPitchDelta = tcEndPitch - tcStartPitch;
176193

177194
MidiToNotes.notes.push([
178195
startTime / timeDivision,
179196
length > 0 ? length / timeDivision : defaultNoteLength,
180-
(startPitch - 60) * 13.75,
181-
pitchDelta * 13.75,
182-
(endPitch - 60) * 13.75,
197+
tcStartPitch,
198+
tcPitchDelta,
199+
tcEndPitch,
183200
]);
184201

185202
currentNote = undefined;
186203
}
187204
} else if (getEventType(event) === "noteOn") {
188205
const pitch = event.data[0];
206+
const pitchBend = getPitchBendAdjustmentAtTime(event.time);
189207

190208
if (currentNote) {
191209
currentNote.endPitch = pitch;
@@ -194,12 +212,80 @@ const MidiToNotes = (function () {
194212
startTime: event.time,
195213
startPitch: pitch,
196214
endPitch: pitch,
215+
startPitchBend: pitchBend
197216
};
198217
}
199218
}
200219
}
201220
}
202221

222+
/**
223+
* Collects all pitch bend events in the MIDI and converts the
224+
* pitch bend value into semitones.
225+
*/
226+
function collectPitchBendEvents(sortedMidiEvents) {
227+
MidiToNotes.pitchBendEvents = [];
228+
229+
for (const event of sortedMidiEvents) {
230+
if (getEventType(event) === "pitchBend") {
231+
// A MIDI pitch bend event consists of two bytes 0aaaaaaa and 0bbbbbbb,
232+
// These are combined (bbbbbbbaaaaaaa) to form a value of 0-16383,
233+
// with the median (8192) being no bend. This is converted to semitones
234+
// according to the range specified in the settings.
235+
const midiPitchBend = ((event.data[1] << 7 | event.data[0]) - 8192) / 8192;
236+
const pitchEvent = {
237+
time: event.time,
238+
value: midiPitchBend * Settings.getSetting("pitchbendrange")
239+
};
240+
241+
MidiToNotes.pitchBendEvents.push(pitchEvent)
242+
}
243+
}
244+
}
245+
246+
/**
247+
* Finds the pitch adjust amount at a given time in the MIDI. If the MIDI time
248+
* is between two pitch bend events then the amount is found by a linear
249+
* interpolation between the previous and next events. E.g. a time halfway between
250+
* events of +0.5 and +1.0 semitones will have an adjust amount of +0.75.
251+
*/
252+
function getPitchBendAdjustmentAtTime(midiTime) {
253+
const eventIndex = MidiToNotes.pitchBendEvents.findLastIndex((event) => event.time <= midiTime);
254+
if (eventIndex === -1) return 0;
255+
const pitchEvent = MidiToNotes.pitchBendEvents[eventIndex];
256+
257+
if (eventIndex === MidiToNotes.pitchBendEvents.length - 1) return pitchEvent.value;
258+
const nextPitchEvent = MidiToNotes.pitchBendEvents[eventIndex + 1];
259+
260+
const timeDelta = nextPitchEvent.time - pitchEvent.time;
261+
const pitchDelta = nextPitchEvent.value - pitchEvent.value;
262+
263+
return pitchEvent.value + (midiTime - pitchEvent.time) / timeDelta * pitchDelta;
264+
}
265+
266+
/**
267+
* Applies pitch bend, validates the final note, then converts to TC format.
268+
* Warnings are added for notes that are either out of range or unsnapped,
269+
* out of range pitches will also optionally be clamped.
270+
*/
271+
function convertPitch(midiPitch, midiPitchBend, midiTime, timeDivision) {
272+
const snaps = [Settings.getSetting("snap"), 12];
273+
const clampPitch = Settings.getSetting("clamppitch");
274+
let adjustedMidiPitch = midiPitch + midiPitchBend;
275+
276+
warnIfUnsnapped(midiTime, timeDivision, snaps);
277+
278+
if (adjustedMidiPitch < 47 || adjustedMidiPitch > 73) {
279+
midiWarnings.add(
280+
clampPitch ? "Pitch clamped" : "Pitch out of range",
281+
{ adjustedMidiPitch, beat: midiTime / timeDivision }
282+
);
283+
if (clampPitch) adjustedMidiPitch = Math.min(Math.max(adjustedMidiPitch, 47), 73);
284+
}
285+
286+
return (adjustedMidiPitch - 60) * 13.75;
287+
}
288+
203289
function generateLyrics(sortedMidiEvents, timeDivision) {
204290
MidiToNotes.lyrics = [];
205291

@@ -218,23 +304,9 @@ const MidiToNotes = (function () {
218304
}
219305

220306
function generateWarnings(sortedMidiEvents, timeDivision) {
221-
const clampPitch = Settings.getSetting("clamppitch");
222-
const snaps = [Settings.getSetting("snap"), 12];
223-
224307
for (const event of sortedMidiEvents) {
225308
const eventType = getEventType(event);
226-
if (eventType === "noteOn" || eventType === "noteOff") {
227-
warnIfUnsnapped(event.time, timeDivision, snaps);
228-
229-
let pitch = event.data[0];
230-
if (pitch < 47 || pitch > 73) {
231-
midiWarnings.add(
232-
clampPitch ? "Pitch clamped" : "Pitch out of range",
233-
{ pitch, beat: Math.floor(event.time / timeDivision) }
234-
);
235-
if (clampPitch) pitch = Math.min(Math.max(pitch, 47), 73);
236-
}
237-
} else if (eventType === "meta") {
309+
if (eventType === "meta") {
238310
if (event.metaType === 81 && event.time !== 0) {
239311
// tempo change
240312
midiWarnings.add("Tempo change (unsupported)", {

src/settings.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
const Settings = (function () {
22
const settings = {};
33

4-
const settingsNames = ["clamppitch", "snap", "slidemidi2tc", "slidetccc"];
4+
const settingsNames =
5+
["clamppitch", "snap", "slidemidi2tc", "slidetccc", "pitchbendrange"];
56

67
function getSetting(name) {
78
switch (name) {
@@ -10,7 +11,8 @@ const Settings = (function () {
1011
case "slidetccc":
1112
return settings[name].checked;
1213
case "snap":
13-
return Number(settings["snap"].value);
14+
case "pitchbendrange":
15+
return Number(settings[name].value);
1416
default:
1517
throw `Unknown setting: ${name}`;
1618
}

0 commit comments

Comments
 (0)