UNPKG

smoosic

Version:

<sub>[Github site](https://github.com/Smoosic/smoosic) | [source documentation](https://smoosic.github.io/Smoosic/release/docs/modules.html) | [change notes](https://aarondavidnewman.github.io/Smoosic/changes.html) | [application](https://smoosic.github.i

601 lines (598 loc) 23.1 kB
// [Smoosic](https://github.com/AaronDavidNewman/Smoosic) // Copyright (c) Aaron David Newman 2021. import { SmoDynamicText, SmoMicrotone } from '../data/noteModifiers'; import { SmoSelector, SmoSelection } from './selections'; import { SmoStaffHairpin, StaffModifierBase } from '../data/staffModifiers'; import { SmoMusic } from '../data/music'; import { SmoTempoText, SmoBarline, SmoVolta } from '../data/measureModifiers'; import { SmoScore } from '../data/score'; import { SmoNote } from '../data/note'; import { Pitch } from '../data/common'; import { SmoSystemStaff } from '../data/systemStaff'; import { SmoAudioPitch } from '../data/music'; import { SmoTupletTree } from '../data/tuplet'; /** * @category SmoTransform */ export interface SmoAudioRepeat { startRepeat: number, endRepeat?: number, voltas: SmoAudioVolta[] } /** * @category SmoTransform */ export interface SmoAudioVolta { measureIndex: number, ending: number } /** * @category SmoTransform */ export interface SmoAudioRepeatMap { startMeasure: number, endMeasure: number } /** * @category SmoTransform */ export interface SmoAudioHairpin { hairpinType: number, startSelector: SmoSelector, endSelector: SmoSelector, delta: number, ticks: number } /** * @category SmoTransform */ export interface SmoAudioTie { startSelector: SmoSelector, endSelector: SmoSelector } /** * @category SmoTransform */ export interface SmoAudioNote { pitches: Pitch[], frequencies: number[], noteType: string, duration: number, offset: number, selector: SmoSelector, volume: number, padding?: boolean } /** * @category SmoTransform */ export interface SmoAudioTimeSignature { numerator: number, denominator: number } /** * @category SmoTransform */ export interface SmoAudioTrack { lastMeasure: number, notes: SmoAudioNote[], tempoMap: Record<string, number>, measureNoteMap: Record<number, SmoAudioNote[]>, keyMap: Record<number, string>, timeSignatureMap: Record<string, SmoAudioTimeSignature>, hairpins: SmoAudioHairpin[], volume: number, tiedNotes: SmoAudioTie[], repeats: [] } /** * @category SmoTransform */ export interface AudioTracks { tracks: SmoAudioTrack[], repeats: SmoAudioRepeat[], repeatMap: SmoAudioRepeatMap[], measureBeats: number[], tempoMap: number[] } /** * Convert a score into a JSON structure that can be rendered to audio. * the return value looks like this: * ` { tracks, repeats, repeatMap} ` * repeatMap is just an array of tuples with start/end measures. * each track contains: * ` { lastMeasure, notes, tempoMap, timeSignatureMap, hairpins, volume, tiedNotes } ` * where each note might contain: * `{ pitches, noteType, duration, selector, volume }` * _Note_: pitches are smo pitches, durations are adjusted for beatTime * (beatTime === 4096 uses Smo/Vex ticks, 128 is midi tick default) * volume is normalized 0-1 * @category SmoTransform */ export class SmoAudioScore { // ### dynamicVolumeMap // normalized dynamic static get dynamicVolumeMap(): Record<string, number> { // matches SmoDynamicText.dynamics return { pp: 0.3, p: 0.4, mp: 0.5, mf: 0.6, f: 0.7, ff: 0.8 }; } static get emptyTrack(): SmoAudioTrack { return { lastMeasure: 0, notes: [], tempoMap: {}, timeSignatureMap: {}, hairpins: [], measureNoteMap: {}, keyMap: {}, volume: 0, tiedNotes: [], repeats: [] }; } timeDiv: number; score: SmoScore; beatTime: number; volume: number = 0; constructor(score: SmoScore, beatTime: number) { this.timeDiv = 4096 / beatTime; this.score = score; this.beatTime = beatTime; } // ### volumeFromNote // Return a normalized volume from the dynamic setting of the note // or supplied default if none exists static volumeFromNote(smoNote: SmoNote, def?: number): number { if (typeof (def) === 'undefined' || def === 0) { def = SmoAudioScore.dynamicVolumeMap[SmoDynamicText.dynamics.PP]; } const dynamic: SmoDynamicText[] = smoNote.getModifiers('SmoDynamicText') as SmoDynamicText[]; if (dynamic.length < 1) { return def; } if (dynamic[0].text === SmoDynamicText.dynamics.SFZ) { return SmoAudioScore.dynamicVolumeMap[SmoDynamicText.dynamics.F]; } if (typeof (SmoAudioScore.dynamicVolumeMap[dynamic[0].text]) === 'undefined') { return def; } return SmoAudioScore.dynamicVolumeMap[dynamic[0].text]; } getVoltas(repeat: SmoAudioRepeat, measureIndex: number): SmoAudioVolta[] { let v1 = measureIndex; let endings = null; let currentEnding = -1; const rv: SmoAudioVolta[] = []; const staff = this.score.staves[0]; while (v1 > repeat.startRepeat) { endings = staff.measures[v1].getNthEndings(); if (endings.length && endings[0].endSelector) { currentEnding = endings[0].number; rv.push({ measureIndex: v1, ending: currentEnding }); v1 = endings[0].endSelector.measure + 1; break; } v1--; } if (currentEnding < 0 || !staff?.measures) { return rv; } while (endings?.length && v1 < staff.measures.length) { endings = staff.measures[v1].getNthEndings(); if (!endings.length) { break; } currentEnding = endings[0].number; rv.push({ measureIndex: v1, ending: currentEnding }); v1 = (endings[0].endSelector as SmoSelector).measure + 1; } rv.sort((a: SmoAudioVolta, b: SmoAudioVolta) => a.ending - b.ending); return rv; } // ### ticksFromSelection // return the count of ticks between the selectors, adjusted for // beatTime ticksFromSelection(startSelector: SmoSelector, endSelector: SmoSelector): number { const selection = SmoSelection.selectionFromSelector(this.score, startSelector); const note = selection?.note as SmoNote; let ticks: number = note.tickCount; let nextSelection: SmoSelection | null = SmoSelection.nextNoteSelectionFromSelector(this.score, startSelector); while (nextSelection && nextSelection.note && !SmoSelector.gt(nextSelection.selector, endSelector)) { ticks += nextSelection.note.tickCount; nextSelection = SmoSelection.nextNoteSelectionFromSelector(this.score, nextSelection.selector); } return ticks / this.timeDiv; } // ### getHairpinInfo // Get any hairpin starting at this selection, and calculate its // effect on the overall volume getHairpinInfo(track: SmoAudioTrack, selection: SmoSelection) { const staff: SmoSystemStaff = selection.staff; const selector: SmoSelector = selection.selector; const cp = (x: SmoSelector) => JSON.parse(JSON.stringify(x)); const hps: StaffModifierBase[] = staff.getModifiersAt(selector) .filter((hairpin) => hairpin.ctor === 'SmoStaffHairpin' && SmoSelector.eq(hairpin.startSelector, selector)); const rv: SmoAudioHairpin[] = []; // clear out old hairpins. // usually there will only be a single hairpin per voice, except // in the case of overlapping. track.hairpins.forEach((hairpin: SmoAudioHairpin) => { if (SmoSelector.gteq(selection.selector, hairpin.startSelector) && SmoSelector.lteq(selection.selector, hairpin.endSelector)) { rv.push(hairpin); } }); track.hairpins = rv; hps.forEach((hairpin) => { const ch = hairpin as SmoStaffHairpin; let endDynamic = 0; const trackHairpin: SmoAudioHairpin = { hairpinType: ch.hairpinType, startSelector: cp(hairpin.startSelector), endSelector: cp(hairpin.endSelector), delta: 0, ticks: 0 }; // For a hairpin, try to calculate the volume difference from start to end, // as a function of ticks const endSelection: SmoSelection | null = SmoSelection.selectionFromSelector(this.score, hairpin.endSelector); if (endSelection !== null && typeof (endSelection.note) !== 'undefined') { const endNote = endSelection.note as SmoNote; const curNote = selection.note as SmoNote; endDynamic = SmoAudioScore.volumeFromNote(endNote); const startDynamic = SmoAudioScore.volumeFromNote(curNote, track.volume); if (startDynamic === endDynamic) { const nextSelection = SmoSelection.nextNoteSelectionFromSelector(this.score, hairpin.endSelector); if (nextSelection) { const nextNote = nextSelection.note as SmoNote; endDynamic = SmoAudioScore.volumeFromNote(nextNote); } } if (startDynamic === endDynamic) { const offset = (hairpin as SmoStaffHairpin).hairpinType === SmoStaffHairpin.types.CRESCENDO ? 0.1 : -0.1; endDynamic = Math.max(endDynamic + offset, 0.1); endDynamic = Math.min(endDynamic, 1.0); } trackHairpin.delta = endDynamic - startDynamic; trackHairpin.ticks = this.ticksFromSelection(hairpin.startSelector, hairpin.endSelector); track.hairpins.push(trackHairpin); } }); } // ### computeVolume // come up with a current normalized volume based on dynamics // that appear in the music computeVolume(track: SmoAudioTrack, selection: SmoSelection) { const note = selection.note as SmoNote; if (track.volume === 0) { track.volume = SmoAudioScore.volumeFromNote(note, SmoAudioScore.dynamicVolumeMap.p); return; } if (track.hairpins.length) { const hp = track.hairpins[0]; const coff = (note.tickCount / this.timeDiv) / hp.ticks; track.volume += hp.delta * coff; } else { track.volume = SmoAudioScore.volumeFromNote(note, track.volume); } } getSlurInfo(track: SmoAudioTrack, selection: SmoSelection) { const tn: SmoAudioTie[] = []; const cp = (x: any) => JSON.parse(JSON.stringify(x)); track.tiedNotes.forEach((tie) => { if (SmoSelector.gteq(selection.selector, tie.startSelector) && SmoSelector.lteq(selection.selector, tie.endSelector)) { tn.push(tie); } }); track.tiedNotes = tn; const tieStart = selection.staff.getTiesStartingAt(selection.selector); tieStart.forEach((tie) => { tn.push({ startSelector: cp(tie.startSelector), endSelector: cp(tie.endSelector) }); }); } isTiedPitch(track: SmoAudioTrack, selection: SmoSelection, noteIx: number): boolean { if (noteIx < 1) { return false; } if (!track.tiedNotes.length) { return false; } if (track.notes[noteIx - 1].noteType !== 'n') { return false; } // Don't do this for first note of nth endings, because it will mess up // other endings. if (selection.selector.tick === 0) { const endings = selection.measure.getNthEndings(); if (endings.length) { return false; } } // the first note should be played, not tied if (SmoSelector.eq(track.tiedNotes[0].startSelector, selection.selector)) { return false; } return SmoMusic.pitchArraysMatch(track.notes[noteIx - 1].pitches, (selection.note as SmoNote).pitches); } static updateMeasureIndexMap(note: SmoAudioNote, measureIndexMap: Record<number, Record<number, SmoAudioNote[]>>) { if (note.noteType !== 'n') { return; } const selector = note.selector; if (typeof (measureIndexMap[selector.measure]) === 'undefined') { measureIndexMap[selector.measure] = {} as Record<number, SmoAudioNote[]>; } const measureIndex = measureIndexMap[selector.measure]; if (typeof (measureIndex[selector.tick]) === 'undefined') { measureIndex[selector.tick] = []; } if (note.noteType === 'n') { measureIndex[selector.tick].push(note); } } updateMeasureNoteMap(track: SmoAudioTrack, measureIndex: number, note: SmoAudioNote) { if (!track.measureNoteMap[measureIndex]) { track.measureNoteMap[measureIndex] = []; } track.measureNoteMap[measureIndex].push(note) } createTrackNote(track: SmoAudioTrack, selection: SmoSelection, duration: number, runningDuration: number, measureIndexMap: Record<number, Record<number, SmoAudioNote[]>>) { const noteIx = track.notes.length; if (this.isTiedPitch(track, selection, noteIx)) { track.notes[noteIx - 1].duration += duration; const restPad = this.createTrackRest(track, duration, runningDuration, selection.selector, measureIndexMap); // Indicate this rest is just padding for a previous tied note. Midi and audio render this // differently restPad.padding = true; track.notes.push(restPad); return; } const tpitches: Pitch[] = []; const frequencies: number[] = []; const xpose = selection.measure.transposeIndex; const smoNote = selection.note as SmoNote; smoNote.pitches.forEach((pitch, pitchIx) => { tpitches.push(SmoMusic.smoIntToPitch( SmoMusic.smoPitchToInt(pitch) - xpose)); const mtone: SmoMicrotone | null = smoNote.getMicrotone(pitchIx) ?? null; frequencies.push(SmoAudioPitch.smoPitchToFrequency(pitch, -1 * xpose, mtone)); }); const pitchArray = JSON.parse(JSON.stringify(tpitches)); const note: SmoAudioNote = { pitches: pitchArray, noteType: 'n', duration, offset: runningDuration, selector: selection.selector, volume: track.volume, frequencies }; this.updateMeasureNoteMap(track, selection.selector.measure, note); track.notes.push(note); SmoAudioScore.updateMeasureIndexMap(note, measureIndexMap); } createTrackRest(track: SmoAudioTrack, duration: number, runningDuration: number, selector: SmoSelector, measureIndexMap: Record<number, Record<number, SmoAudioNote[]>>): SmoAudioNote { const rest: SmoAudioNote = { duration, offset: runningDuration, noteType: 'r', selector, volume: 0, pitches: [], frequencies: [] }; SmoAudioScore.updateMeasureIndexMap(rest, measureIndexMap); this.updateMeasureNoteMap(track, selector.measure, rest); return rest; } createRepeatMap(repeats: SmoAudioRepeat[]): SmoAudioRepeatMap[] { let startm = 0; let j = 0; const staff = this.score.staves[0]; const repeatMap: SmoAudioRepeatMap[] = []; const endm = staff.measures.length - 1; repeats.forEach((repeat) => { // Include the current start to start of repeat, unless there is no start repeat if (repeat.startRepeat > 0) { repeatMap.push({ startMeasure: startm, endMeasure: repeat.startRepeat - 1 }); } // Include first time through if (repeat.endRepeat) { repeatMap.push({ startMeasure: repeat.startRepeat, endMeasure: repeat.endRepeat }); } startm = repeat.startRepeat; // nth time through, go to the start of volta 0, then to the start of volta n if (repeat.endRepeat && repeat.voltas.length < 1) { repeatMap.push({ startMeasure: repeat.startRepeat, endMeasure: repeat.endRepeat }); startm = repeat.endRepeat + 1; } for (j = 1; j < repeat.voltas.length; ++j) { const volta = repeat.voltas[j]; repeatMap.push({ startMeasure: repeat.startRepeat, endMeasure: repeat.voltas[0].measureIndex - 1 }); // If there are more endings, repeat to first volta if (j + 1 < repeat.voltas.length) { repeatMap.push({ startMeasure: volta.measureIndex, endMeasure: repeat.voltas[j + 1].measureIndex - 1 }); } else { startm = volta.measureIndex; } } }); if (startm <= endm) { repeatMap.push({ startMeasure: startm, endMeasure: endm }); } return repeatMap; } normalizeVolume(measureIndexMap: Record<number, Record<number, SmoAudioNote[]>>) { let i = 0; let j = 0; let runningSum = -1; const measureKeys = Object.keys(measureIndexMap); for (i = 0; i < measureKeys.length; ++i) { const measureNotes = measureIndexMap[i]; if (typeof (measureNotes) === 'undefined') { continue; } const tickKeys = Object.keys(measureNotes); for (j = 0; j < tickKeys.length; ++j) { let volumeSum = 0; let normalize = 1.0; const tickNotes = measureNotes[parseInt(tickKeys[j], 10)]; if (typeof (tickNotes) === 'undefined') { continue; } volumeSum = tickNotes.map((nn) => nn.volume).reduce((a, b) => a + b); if (volumeSum > 1.0) { normalize = 1.0 / volumeSum; volumeSum = 1.0; } if (runningSum < 0) { runningSum = volumeSum; } const diff = Math.abs(runningSum - volumeSum); if (diff > 0.6) { const avg = (volumeSum * 3 + runningSum) / 4; normalize = normalize * avg; } runningSum = volumeSum * normalize; tickNotes.forEach((nn) => { nn.volume *= normalize; }); runningSum = volumeSum; } } } convert(): AudioTracks { let measureIx = 0; const trackHash: Record<string, SmoAudioTrack> = {}; const measureBeats: number[] = []; const measureIndexMap = {}; const repeats: SmoAudioRepeat[] = []; let startRepeat = 0; const tempoMap: number[] = []; this.score.staves.forEach((staff, staffIx) => { let runningKey = staff.measures[0].keySignature; this.volume = 0; for (measureIx = 0; measureIx < staff.measures.length; ++measureIx) { const measure = staff.measures[measureIx]; measure.voices.forEach((voice, voiceIx) => { let duration = 0; const trackKey = (this.score.staves.length * voiceIx) + staffIx; if (typeof (trackHash[trackKey]) === 'undefined') { trackHash[trackKey] = SmoAudioScore.emptyTrack; } const measureSelector = SmoSelector.default; measureSelector.staff = staffIx; measureSelector.measure = measureIx; const track: SmoAudioTrack = trackHash[trackKey]; if (!measure.tempo) { measure.tempo = new SmoTempoText(SmoTempoText.defaults); } const tempo = measure.tempo.bpm * (measure.tempo.beatDuration / 4096); // staff 0/voice 0, set track values for the measure if (voiceIx === 0) { if (staffIx === 0) { track.keyMap[0] = runningKey; measureBeats.push(measure.getMaxTicksVoice() / this.timeDiv); const startBar = measure.getStartBarline(); const endBar = measure.getEndBarline(); if (startBar.barline === SmoBarline.barlines.startRepeat) { startRepeat = measureIx; } if (endBar.barline === SmoBarline.barlines.endRepeat) { const repeat: SmoAudioRepeat = { startRepeat, endRepeat: measureIx, voltas: [] }; repeat.voltas = this.getVoltas(repeat, measureIx); repeats.push(repeat); } tempoMap.push(tempo); } const selectorKey = SmoSelector.getMeasureKey(measureSelector); track.tempoMap[selectorKey] = Math.round(tempo); if (measure.keySignature !== runningKey) { runningKey = measure.keySignature; track.keyMap[measureIx] = runningKey; } track.timeSignatureMap[selectorKey] = { numerator: measure.timeSignature.actualBeats, denominator: measure.timeSignature.beatDuration }; } // If this voice is not in every measure, fill in the space // in its own channel. while (track.lastMeasure < measureIx) { track.notes.push(this.createTrackRest(track, measureBeats[track.lastMeasure], 0, { staff: staffIx, measure: track.lastMeasure, voice: voiceIx, tick: 0, pitches: [] }, measureIndexMap, )); track.lastMeasure += 1; } let tupletTicks = 0; let runningDuration = 0; voice.notes.forEach((note, noteIx) => { const selector = { staff: staffIx, measure: measureIx, voice: voiceIx, tick: noteIx, pitches: [] }; const selection = SmoSelection.selectionFromSelector(this.score, selector) as SmoSelection; // update staff features of slur/tie/cresc. this.getSlurInfo(track, selection); this.getHairpinInfo(track, selection); const tuplet = SmoTupletTree.getTupletForNoteIndex(measure.tupletTrees, voiceIx, noteIx); if (tuplet && tuplet.startIndex === noteIx) { tupletTicks = tuplet.tickCount / this.timeDiv; } if (tupletTicks) { // tuplet likely won't fit evenly in ticks, so // use remainder in last tuplet note. if (tuplet && tuplet.endIndex === noteIx) { duration = tupletTicks; tupletTicks = 0; } else { duration = note.tickCount / this.timeDiv; tupletTicks -= duration; } } else { duration = note.tickCount / this.timeDiv; } if (note.isRest() || note.isSlash()) { track.notes.push(this.createTrackRest(track, duration, runningDuration, selector, measureIndexMap)); } else { this.computeVolume(track, selection); this.createTrackNote(track, selection, duration, runningDuration, measureIndexMap); } runningDuration += duration; }); track.lastMeasure += 1; }); } }); // For voices that don't fill out the full piece, fill them in with rests const tracks = Object.keys(trackHash).map((key) => trackHash[key]); const maxMeasure = tracks[0].lastMeasure; tracks.forEach((track) => { while (track.lastMeasure < maxMeasure) { const staff = track.notes[0].selector.staff; const voice = track.notes[0].selector.voice; const rest: SmoAudioNote = this.createTrackRest(track, measureBeats[track.lastMeasure], 0, { staff, measure: track.lastMeasure, voice, tick: 0, pitches: [] }, measureIndexMap ); track.notes.push(rest); track.lastMeasure += 1; } }); const repeatMap = this.createRepeatMap(repeats); this.normalizeVolume(measureIndexMap); return { tracks, repeats, repeatMap, measureBeats, tempoMap }; } }