UNPKG

abcjs

Version:

Renderer for abc music notation

638 lines (571 loc) 21.8 kB
// // The algorithm for chords is: // - The chords are done in a separate track. // - If there are notes before the first chord, then put that much silence to start the track. // - The pattern of chord expression depends on the meter, and how many chords are in a measure. // - There is a possibility that a measure will have an incorrect number of beats, if that is the case, then // start the pattern anew on the next measure number. // - If a chord root is not A-G, then ignore it as if the chord wasn't there at all. // - If a chord modification isn't in our supported list, change it to a major triad. // // - There is a standard pattern of boom-chick for each time sig, or it can be overridden. // - For any unrecognized meter, play the full chord on each beat. // // - If there is a chord specified that is not on a beat, move it earlier to the previous beat, unless there is already a chord on that beat. // - Otherwise, move it later, unless there is already a chord on that beat. // - Otherwise, ignore it. (TODO-PER: expand this as more support is added.) // // If there is any note in the melody that has a rhythm head, then assume the melody controls the rhythm, so there is no chord added for that entire measure. var ChordTrack = function ChordTrack(numVoices, chordsOff, midiOptions, meter) { this.chordTrack = []; this.chordTrackFinished = false; this.chordChannel = numVoices; // first free channel for chords this.currentChords = []; this.lastChord; this.chordLastBar; this.chordsOff = !!chordsOff this.gChordTacet = this.chordsOff; this.hasRhythmHead = false; this.transpose = 0; this.lastBarTime = 0; this.meter = meter; this.tempoChangeFactor = 1; // MAE 17 Jun 2024 - To allow for bass and chord instrument octave shifts this.bassInstrument = midiOptions.bassprog && midiOptions.bassprog.length >= 1 ? midiOptions.bassprog[0] : 0; this.chordInstrument = midiOptions.chordprog && midiOptions.chordprog.length >= 1 ? midiOptions.chordprog[0] : 0; // MAE For octave shifted bass and chords this.bassOctaveShift = midiOptions.bassprog && midiOptions.bassprog.length === 2 ? midiOptions.bassprog[1] : 0; this.chordOctaveShift = midiOptions.chordprog && midiOptions.chordprog.length === 2 ? midiOptions.chordprog[1] : 0; this.boomVolume = midiOptions.bassvol && midiOptions.bassvol.length === 1 ? midiOptions.bassvol[0] : 64; this.chickVolume = midiOptions.chordvol && midiOptions.chordvol.length === 1 ? midiOptions.chordvol[0] : 48; // This allows for an initial %%MIDI gchord with no string if (midiOptions.gchord && (midiOptions.gchord.length > 0)) { this.overridePattern = parseGChord(midiOptions.gchord[0]) } else { this.overridePattern = undefined; } }; ChordTrack.prototype.setMeter = function (meter) { this.meter = meter }; ChordTrack.prototype.setTempoChangeFactor = function (tempoChangeFactor) { this.tempoChangeFactor = tempoChangeFactor }; ChordTrack.prototype.setLastBarTime = function (lastBarTime) { this.lastBarTime = lastBarTime }; ChordTrack.prototype.setTranspose = function (transpose) { this.transpose = transpose }; ChordTrack.prototype.setRhythmHead = function (isRhythmHead, elem) { this.hasRhythmHead = isRhythmHead var ePitches = []; if (isRhythmHead) { if (this.lastChord && this.lastChord.chick) { for (var i2 = 0; i2 < this.lastChord.chick.length; i2++) { var note2 = Object.assign({},elem.pitches[0]); note2.actualPitch = this.lastChord.chick[i2]; ePitches.push(note2); } } } return ePitches }; ChordTrack.prototype.barEnd = function (element) { if (this.chordTrack.length > 0 && !this.chordTrackFinished) { this.resolveChords(this.lastBarTime, timeToRealTime(element.time)); this.currentChords = []; } this.chordLastBar = this.lastChord; }; ChordTrack.prototype.gChordOn = function (element) { if (!this.chordsOff) this.gChordTacet = element.tacet; }; ChordTrack.prototype.paramChange = function (element) { switch (element.el_type) { case "gchord": // Skips gchord elements that don't have pattern strings if (element.param && element.param.length > 0) { this.overridePattern = parseGChord(element.param); // Generate a default duration scale based on the pattern //this.gchordduration = generateDefaultDurationScale(element.param); } else this.overridePattern = undefined; break; case "bassprog": this.bassInstrument = element.value; if ((element.octaveShift != undefined) && (element.octaveShift != null)) { this.bassOctaveShift = element.octaveShift; } else { this.bassOctaveShift = 0; } break; case "chordprog": this.chordInstrument = element.value; if ((element.octaveShift != undefined) && (element.octaveShift != null)) { this.chordOctaveShift = element.octaveShift; } else { this.chordOctaveShift = 0; } break; case "bassvol": this.boomVolume = element.param; break; case "chordvol": this.chickVolume = element.param; break; default: console.log("unhandled midi param", element) } }; ChordTrack.prototype.finish = function () { if (!this.chordTrackEmpty()) // Don't do chords on more than one track, so turn off chord detection after we create it. this.chordTrackFinished = true; }; ChordTrack.prototype.addTrack = function (tracks) { if (!this.chordTrackEmpty()) tracks.push(this.chordTrack); }; ChordTrack.prototype.findChord = function (elem) { if (this.gChordTacet) return 'break'; // TODO-PER: Just using the first chord if there are more than one. if (this.chordTrackFinished || !elem.chord || elem.chord.length === 0) return null; // Return the first annotation that is a regular chord: that is, it is in the default place or is a recognized "tacet" phrase. for (var i = 0; i < elem.chord.length; i++) { var ch = elem.chord[i]; if (ch.position === 'default') return ch.name; if (this.breakSynonyms.indexOf(ch.name.toLowerCase()) >= 0) return 'break'; } return null; } ChordTrack.prototype.interpretChord = function (name) { // chords have the format: // [root][acc][modifier][/][bass][acc] // (The chord might be surrounded by parens. Just ignore them.) // root must be present and must be from A-G. // acc is optional and can be # or b // The modifier can be a wide variety of things, like "maj7". As they are discovered, more are supported here. // If there is a slash, then there is a bass note, which can be from A-G, with an optional acc. // If the root is unrecognized, then "undefined" is returned and there is no chord. // If the modifier is unrecognized, a major triad is returned. // If the bass notes is unrecognized, it is ignored. if (name.length === 0) return undefined; if (name === 'break') return { chick: [] }; var root = name.substring(0, 1); if (root === '(') { name = name.substring(1, name.length - 1); if (name.length === 0) return undefined; root = name.substring(0, 1); } var bass = this.basses[root]; if (!bass) // If the bass note isn't listed, then this was an unknown root. Only A-G are accepted. return undefined; // Don't transpose the chords more than an octave. var chordTranspose = this.transpose; while (chordTranspose < -8) chordTranspose += 12; while (chordTranspose > 8) chordTranspose -= 12; bass += chordTranspose; // MAE 31 Aug 2024 - For visual transpose backup range issue // If transposed below A or above G, bring it back in the normal backup range if (bass < 33){ bass += 12; } else if (bass > 44){ bass -= 12; } // MAE 17 Jun 2024 - Supporting octave shifted bass and chords var unshiftedBass = bass; bass += this.bassOctaveShift * 12; var bass2 = bass - 5; // The alternating bass is a 4th below var chick; if (name.length === 1) chick = this.chordNotes(bass, ''); var remaining = name.substring(1); var acc = remaining.substring(0, 1); if (acc === 'b' || acc === '♭') { unshiftedBass--; bass--; bass2--; remaining = remaining.substring(1); } else if (acc === '#' || acc === '♯') { unshiftedBass++; bass++; bass2++; remaining = remaining.substring(1); } var arr = remaining.split('/'); chick = this.chordNotes(unshiftedBass, arr[0]); // If the 5th is altered then the bass is altered. Normally the bass is 7 from the root, so adjust if it isn't. if (chick.length >= 3) { var fifth = chick[2] - chick[0]; bass2 = bass2 + fifth - 7; } if (arr.length === 2) { var explicitBass = this.basses[arr[1].substring(0, 1)]; if (explicitBass) { var bassAcc = arr[1].substring(1); var bassShift = { '#': 1, '♯': 1, 'b': -1, '♭': -1 }[bassAcc] || 0; bass = this.basses[arr[1].substring(0, 1)] + bassShift + chordTranspose; // MAE 22 May 2024 - Supporting octave shifted bass and chords bass += this.bassOctaveShift * 12; bass2 = bass; } } return { boom: bass, boom2: bass2, chick: chick }; } ChordTrack.prototype.chordNotes = function (bass, modifier) { var intervals = this.chordIntervals[modifier]; if (!intervals) { if (modifier.slice(0, 2).toLowerCase() === 'ma' || modifier[0] === 'M') intervals = this.chordIntervals.M; else if (modifier[0] === 'm' || modifier[0] === '-') intervals = this.chordIntervals.m; else intervals = this.chordIntervals.M; } bass += 12; // the chord is an octave above the bass note. // MAE 22 May 2024 - For chick octave shift bass += (this.chordOctaveShift * 12); var notes = []; for (var i = 0; i < intervals.length; i++) { notes.push(bass + intervals[i]); } return notes; } ChordTrack.prototype.writeNote = function (note, beatLength, volume, beat, noteLength, instrument) { // undefined means there is a stop time. if (note !== undefined) this.chordTrack.push({ cmd: 'note', pitch: note, volume: volume, start: this.lastBarTime + beat * durationRounded(beatLength, this.tempoChangeFactor), duration: durationRounded(noteLength, this.tempoChangeFactor), gap: 0, instrument: instrument }); } ChordTrack.prototype.chordTrackEmpty = function () { var isEmpty = true; for (var i = 0; i < this.chordTrack.length && isEmpty; i++) { if (this.chordTrack[i].cmd === 'note') isEmpty = false } return isEmpty; } ChordTrack.prototype.resolveChords = function (startTime, endTime) { // If there is a rhythm head anywhere in the measure then don't add a separate rhythm track if (this.hasRhythmHead) return var num = this.meter.num; var den = this.meter.den; var beatLength = 1 / den; var noteLength = beatLength / 2; var thisMeasureLength = parseInt(num, 10) / parseInt(den, 10); var portionOfAMeasure = thisMeasureLength - (endTime - startTime) / this.tempoChangeFactor; if (Math.abs(portionOfAMeasure) < 0.00001) portionOfAMeasure = 0; // there wasn't a new chord this measure, so use the last chord declared. // also the case where there is a chord declared in the measure, but not on its first beat. if (this.currentChords.length === 0 || this.currentChords[0].beat !== 0) { this.currentChords.unshift({ beat: 0, chord: this.chordLastBar }); } //console.log(this.currentChords) var currentChordsExpanded = expandCurrentChords(this.currentChords, 8*num/den, beatLength) //console.log(currentChordsExpanded) var thisPattern = this.overridePattern ? this.overridePattern : this.rhythmPatterns[num + '/' + den] if (portionOfAMeasure) { thisPattern = []; var beatsPresent = ((endTime - startTime) / this.tempoChangeFactor) * 8; for (var p = 0; p < beatsPresent/2; p += 2) { thisPattern.push("chick"); thisPattern.push(""); } } if (!thisPattern) { thisPattern = [] for (var p = 0; p < (8*num/den)/2; p++) { thisPattern.push('chick') thisPattern.push(""); } } var firstBoom = true // If the pattern is overridden, it might be longer than the length of a measure. If so, then ignore the rest of it var minLength = Math.min(thisPattern.length, currentChordsExpanded.length) for (var p = 0; p < minLength; p++) { if (p > 0 && currentChordsExpanded[p-1] && currentChordsExpanded[p] && currentChordsExpanded[p-1].boom !== currentChordsExpanded[p].boom) firstBoom = true var type = thisPattern[p] var isBoom = type.indexOf('boom') >= 0 // If we changed chords at a time when we're not expecting a bass note, then add an extra bass note in if the first thing in the pattern is a bass note. var newBass = !isBoom && p !== 0 && thisPattern[0].indexOf('boom') >= 0 && (!currentChordsExpanded[p-1] || currentChordsExpanded[p-1].boom !== currentChordsExpanded[p].boom) var pitches = resolvePitch(currentChordsExpanded[p], type, firstBoom, newBass) if (isBoom) firstBoom = false for (var oo = 0; oo < pitches.length; oo++) { this.writeNote(pitches[oo], 0.125, isBoom || newBass ? this.boomVolume : this.chickVolume, p, noteLength, isBoom || newBass ? this.bassInstrument : this.chordInstrument ) if (newBass) newBass = false else isBoom = false // only the first note in a chord is a bass note. This handles the case where bass and chord are played at the same time. } } return } ChordTrack.prototype.processChord = function (elem) { if (this.chordTrackFinished) return var chord = this.findChord(elem); if (chord) { var c = this.interpretChord(chord); // If this isn't a recognized chord, just completely ignore it. if (c) { // If we ever have a chord in this voice, then we add the chord track. // However, if there are chords on more than one voice, then just use the first voice. if (this.chordTrack.length === 0) { this.chordTrack.push({ cmd: 'program', channel: this.chordChannel, instrument: this.chordInstrument }); } this.lastChord = c; var barBeat = calcBeat(this.lastBarTime, timeToRealTime(elem.time)); this.currentChords.push({ chord: this.lastChord, beat: barBeat, start: timeToRealTime(elem.time) }); } } } function resolvePitch(currentChord, type, firstBoom, newBass) { var ret = [] if (!currentChord) return ret if (type.indexOf('boom') >= 0) ret.push(firstBoom ? currentChord.boom : currentChord.boom2) else if (newBass) ret.push(currentChord.boom) var numChordNotes = currentChord.chick.length if (type.indexOf('chick') >= 0) { for (var i = 0; i < numChordNotes; i++) ret.push(currentChord.chick[i]) } switch (type) { case 'DO': ret.push(currentChord.chick[0]); break; case 'MI': ret.push(currentChord.chick[1]); break; case 'SOL': ret.push(extractNote(currentChord,2)); break; case 'TI': ret.push(extractNote(currentChord,3)); break; case 'TOP': ret.push(extractNote(currentChord,4)); break; case 'do': ret.push(currentChord.chick[0]+12); break; case 'mi': ret.push(currentChord.chick[1]+12); break; case 'sol': ret.push(extractNote(currentChord,2)+12); break; case 'ti': ret.push(extractNote(currentChord,3)+12); break; case 'top': ret.push(extractNote(currentChord,4)+12); break; } return ret } function extractNote(chord, index) { // This creates an arpeggio note no matter how many notes are in the chord - if it runs out of notes it continues in the next octave var octave = Math.floor(index / chord.chick.length) var note = chord.chick[index % chord.chick.length] //console.log(chord.chick, {index, octave, note}, index % chord.chick.length) return note + octave * 12 } function parseGChord(gchord) { // TODO-PER: The spec is more complicated than this but for now this will not try to do anything with error cases like the wrong number of beats. var pattern = [] for (var i = 0; i < gchord.length; i++) { var ch = gchord[i] switch(ch) { case 'z' : pattern.push(''); break; case '2' : pattern.push(''); break; // TODO-PER: This should extend the last note, but that's a small effect case 'c' : pattern.push('chick'); break; case 'b' : pattern.push('boom&chick'); break; case 'f' : pattern.push('boom'); break; case 'G' : pattern.push('DO'); break; case 'H' : pattern.push('MI'); break; case 'I' : pattern.push('SOL'); break; case 'J' : pattern.push('TI'); break; case 'K' : pattern.push('TOP'); break; case 'g' : pattern.push('do'); break; case 'h' : pattern.push('mi'); break; case 'i' : pattern.push('sol'); break; case 'j' : pattern.push('ti'); break; case 'k' : pattern.push('top'); break; } } return pattern } // This returns an array that has a chord for each 1/8th note position in the current measure function expandCurrentChords(currentChords, num8thNotes, beatLength) { beatLength = beatLength * 8 // this is expressed as a fraction, so that 0.25 is a quarter notes. We want it to be the number of 8th notes var chords = [] if (currentChords.length === 0) return chords var currentChord = currentChords[0].chord for (var i = 1; i < currentChords.length; i++) { var current = currentChords[i] while (chords.length < current.beat) { chords.push(currentChord) } currentChord = current.chord } while (chords.length < num8thNotes) chords.push(currentChord) return chords } function calcBeat(measureStart, currTime) { var distanceFromStart = currTime - measureStart; return distanceFromStart * 8; } ChordTrack.prototype.breakSynonyms = ['break', '(break)', 'no chord', 'n.c.', 'tacet']; ChordTrack.prototype.basses = { 'A': 33, 'B': 35, 'C': 36, 'D': 38, 'E': 40, 'F': 41, 'G': 43 }; ChordTrack.prototype.chordIntervals = { // diminished (all flat 5 chords) 'dim': [0, 3, 6], '°': [0, 3, 6], '˚': [0, 3, 6], 'dim7': [0, 3, 6, 9], '°7': [0, 3, 6, 9], '˚7': [0, 3, 6, 9], 'ø7': [0, 3, 6, 10], 'm7(b5)': [0, 3, 6, 10], 'm7b5': [0, 3, 6, 10], 'm7♭5': [0, 3, 6, 10], '-7(b5)': [0, 3, 6, 10], '-7b5': [0, 3, 6, 10], '7b5': [0, 4, 6, 10], '7(b5)': [0, 4, 6, 10], '7♭5': [0, 4, 6, 10], '7(b9,b5)': [0, 4, 6, 10, 13], '7b9,b5': [0, 4, 6, 10, 13], '7(#9,b5)': [0, 4, 6, 10, 15], '7#9b5': [0, 4, 6, 10, 15], 'maj7(b5)': [0, 4, 6, 11], 'maj7b5': [0, 4, 6, 11], '13(b5)': [0, 4, 6, 10, 14, 21], '13b5': [0, 4, 6, 10, 14, 21], // minor (all normal 5, minor 3 chords) 'm': [0, 3, 7], '-': [0, 3, 7], 'm6': [0, 3, 7, 9], '-6': [0, 3, 7, 9], 'm7': [0, 3, 7, 10], '-7': [0, 3, 7, 10], '-(b6)': [0, 3, 7, 8], '-b6': [0, 3, 7, 8], '-6/9': [0, 3, 7, 9, 14], '-7(b9)': [0, 3, 7, 10, 13], '-7b9': [0, 3, 7, 10, 13], '-maj7': [0, 3, 7, 11], '-9+7': [0, 3, 7, 11, 13], '-11': [0, 3, 7, 11, 14, 17], 'm11': [0, 3, 7, 11, 14, 17], '-maj9': [0, 3, 7, 11, 14], '-∆9': [0, 3, 7, 11, 14], 'mM9': [0, 3, 7, 11, 14], // major (all normal 5, major 3 chords) 'M': [0, 4, 7], '6': [0, 4, 7, 9], '6/9': [0, 4, 7, 9, 14], '6add9': [0, 4, 7, 9, 14], '69': [0, 4, 7, 9, 14], '7': [0, 4, 7, 10], '9': [0, 4, 7, 10, 14], '11': [0, 7, 10, 14, 17], '13': [0, 4, 7, 10, 14, 21], '7b9': [0, 4, 7, 10, 13], '7♭9': [0, 4, 7, 10, 13], '7(b9)': [0, 4, 7, 10, 13], '7(#9)': [0, 4, 7, 10, 15], '7#9': [0, 4, 7, 10, 15], '(13)': [0, 4, 7, 10, 14, 21], '7(9,13)': [0, 4, 7, 10, 14, 21], '7(#9,b13)': [0, 4, 7, 10, 15, 20], '7(#11)': [0, 4, 7, 10, 14, 18], '7#11': [0, 4, 7, 10, 14, 18], '7(b13)': [0, 4, 7, 10, 20], '7b13': [0, 4, 7, 10, 20], '9(#11)': [0, 4, 7, 10, 14, 18], '9#11': [0, 4, 7, 10, 14, 18], '13(#11)': [0, 4, 7, 10, 18, 21], '13#11': [0, 4, 7, 10, 18, 21], 'maj7': [0, 4, 7, 11], '∆7': [0, 4, 7, 11], 'Δ7': [0, 4, 7, 11], 'maj9': [0, 4, 7, 11, 14], 'maj7(9)': [0, 4, 7, 11, 14], 'maj7(11)': [0, 4, 7, 11, 17], 'maj7(#11)': [0, 4, 7, 11, 18], 'maj7(13)': [0, 4, 7, 14, 21], 'maj7(9,13)': [0, 4, 7, 11, 14, 21], '7sus4': [0, 5, 7, 10], 'm7sus4': [0, 3, 7, 10, 17], 'sus4': [0, 5, 7], 'sus2': [0, 2, 7], '7sus2': [0, 2, 7, 10], '9sus4': [0, 5, 7, 10, 14], '13sus4': [0, 5, 7, 10, 14, 21], // augmented (all sharp 5 chords) 'aug7': [0, 4, 8, 10], '+7': [0, 4, 8, 10], '+': [0, 4, 8], '7#5': [0, 4, 8, 10], '7♯5': [0, 4, 8, 10], '7+5': [0, 4, 8, 10], '9#5': [0, 4, 8, 10, 14], '9♯5': [0, 4, 8, 10, 14], '9+5': [0, 4, 8, 10, 14], '-7(#5)': [0, 3, 8, 10], '-7#5': [0, 3, 8, 10], '7(#5)': [0, 4, 8, 10], '7(b9,#5)': [0, 4, 8, 10, 13], '7b9#5': [0, 4, 8, 10, 13], 'maj7(#5)': [0, 4, 8, 11], 'maj7#5': [0, 4, 8, 11], 'maj7(#5,#11)': [0, 4, 8, 11, 18], 'maj7#5#11': [0, 4, 8, 11, 18], '9(#5)': [0, 4, 8, 10, 14], '13(#5)': [0, 4, 8, 10, 14, 21], '13#5': [0, 4, 8, 10, 14, 21], // MAE Power chords added 10 April 2024 '5': [0, 7], '5(8)': [0, 7, 12], '5add8': [0, 7, 12] }; ChordTrack.prototype.rhythmPatterns = { "2/2": ['boom', '', '', '', 'chick', '', '', ''], "3/2": ['boom', '', '', '', 'chick', '', '', '', 'chick', '', '', ''], "4/2": ['boom', '', '', '', 'chick', '', '', '', 'boom', '', '', '', 'chick', '', '', ''], "2/4": ['boom', '', 'chick', ''], "3/4": ['boom', '', 'chick', '', 'chick', ''], "4/4": ['boom', '', 'chick', '', 'boom', '', 'chick', ''], "5/4": ['boom', '', 'chick', '', 'chick', '', 'boom', '', 'chick', ''], "6/4": ['boom', '', 'chick', '', 'boom', '', 'chick', '', 'boom', '', 'chick', ''], "3/8": ['boom', '', 'chick'], "5/8": ['boom', 'chick', 'chick', 'boom', 'chick'], "6/8": ['boom', '', 'chick', 'boom', '', 'chick'], "7/8": ['boom', 'chick', 'chick', 'boom', 'chick', 'boom', 'chick'], "9/8": ['boom', '', 'chick', 'boom', '', 'chick', 'boom', '', 'chick'], "10/8": ['boom', 'chick', 'chick', 'boom', 'chick', 'chick', 'boom', 'chick', 'boom', 'chick'], "11/8": ['boom', 'chick', 'chick', 'boom', 'chick', 'chick', 'boom', 'chick', 'boom', 'chick', 'chick'], "12/8": ['boom', '', 'chick', 'boom', '', 'chick', 'boom', '', 'chick', 'boom', '', 'chick'], }; // TODO-PER: these are repeated in flattener. Can it be shared? function timeToRealTime(time) { return time / 1000000; } function durationRounded(duration, tempoChangeFactor) { return Math.round(duration * tempoChangeFactor * 1000000) / 1000000; } module.exports = ChordTrack;