import * as alphaTab from '@coderline/alphatab'; import type { ConversionWarning, SongsterrRevisionAutomationTempoPoint, SongsterrRevisionBeatPayload, SongsterrRevisionNotePayload, SongsterrRevisionTrackPayload, SongsterrRevisionVoicePayload, SongsterrStateMetaCurrent, SongsterrStateMetaCurrentTrack } from './types.ts'; import { mapSongsterrDuration } from './duration-mapper.ts'; import { mapSongsterrInstrumentToPlayback } from './instrument-map.ts'; /** * Input structure matching metadata to track-specific note revision payloads. */ export interface SongsterrRevisionTrackInput { trackMeta: SongsterrStateMetaCurrentTrack; revision: SongsterrRevisionTrackPayload; } /** * Top-level structure containing the inputs required to run the conversion process. */ interface SongsterrToGpInput { meta: SongsterrStateMetaCurrent; revisions: SongsterrRevisionTrackInput[]; } /** * Output payload containing the compiled file bytes and conversion warnings. */ interface SongsterrToAlphaTabOutput { data: Uint8Array; warnings: ConversionWarning[]; } /** * Intermediary result returned after compiling alphaTab models in memory. */ interface BuildScoreResult { score: alphaTab.model.Score; settings: alphaTab.Settings; warnings: ConversionWarning[]; } // Cap to prevent warning list overhead or infinite feedback loops const MAX_WARNINGS = 200; // Mapping of Songsterr dynamics string descriptors to standard alphaTab DynamicValues const velocityToDynamicMap: Record = { ppp: alphaTab.model.DynamicValue.PPP, pp: alphaTab.model.DynamicValue.PP, p: alphaTab.model.DynamicValue.P, mp: alphaTab.model.DynamicValue.MP, mf: alphaTab.model.DynamicValue.MF, f: alphaTab.model.DynamicValue.F, ff: alphaTab.model.DynamicValue.FF, fff: alphaTab.model.DynamicValue.FFF }; // Mapping of Songsterr harmonic type descriptors to standard alphaTab HarmonicTypes const harmonicTypeMap: Record = { natural: alphaTab.model.HarmonicType.Natural, artificial: alphaTab.model.HarmonicType.Artificial, pinch: alphaTab.model.HarmonicType.Pinch, tap: alphaTab.model.HarmonicType.Tap, semi: alphaTab.model.HarmonicType.Semi, feedback: alphaTab.model.HarmonicType.Feedback }; /** * Maps a Songsterr tuplet value to an [numerator, denominator] ratio for alphaTab. * E.g., a standard triplet (3) means: play 3 notes in the duration of 2. * * For non-standard/uncommon tuplets, it calculates a reasonable power-of-2 fallback denominator. * * @param tuplet Raw tuplet integer factor from Songsterr. * @returns Tuple representing the tuplet [numerator, denominator]. */ function getTupletRatio(tuplet: number): [number, number] { switch (tuplet) { case 3: return [3, 2]; case 5: return [5, 4]; case 6: return [6, 4]; case 7: return [7, 4]; case 9: return [9, 8]; case 10: return [10, 8]; case 12: return [12, 8]; default: if (tuplet > 1) { // Find the nearest lower power of 2 as the denominator (e.g., tuplet of 11 -> plays in space of 8) const denominator = Math.pow(2, Math.floor(Math.log2(tuplet))); return [tuplet, denominator]; } return [1, 1]; } } /** * Dynamic mapping generator that constructs a map of MIDI note numbers to alphaTab's default percussion articulation index. * * Guitar Pro format files (GP7) represent percussion/drum articulations not by raw MIDI note values, * but by their index position within the track's percussion articulation array. This function * instantiates a dummy score, exports it to GP7, reimports it, and registers the returned * articulation array order to bypass hardcoded lookup maps. * * @returns Map matching MIDI note numbers to their local zero-indexed drum articulation position. */ function buildPercussionIndexMap(): Map { const score = new alphaTab.model.Score(); const mb = new alphaTab.model.MasterBar(); score.addMasterBar(mb); const track = new alphaTab.model.Track(); track.playbackInfo.primaryChannel = 9; track.playbackInfo.secondaryChannel = 9; const staff = new alphaTab.model.Staff(); staff.isPercussion = true; track.addStaff(staff); const bar = new alphaTab.model.Bar(); const voice = new alphaTab.model.Voice(); const beat = new alphaTab.model.Beat(); beat.isEmpty = true; voice.addBeat(beat); bar.addVoice(voice); staff.addBar(bar); score.addTrack(track); const settings = new alphaTab.Settings(); score.finish(settings); const exporter = new alphaTab.exporter.Gp7Exporter(); const data = exporter.export(score, settings); const reimported = alphaTab.importer.ScoreLoader.loadScoreFromBytes( data, settings ); const map = new Map(); const articulations = reimported.tracks[0].percussionArticulations; for (let i = 0; i < articulations.length; i++) { const id = articulations[i].id; if (!map.has(id)) { map.set(id, i); } } return map; } // Singleton cache for the percussion mapping table let percussionIndexMap: Map | null = null; /** * Retrieves the local Guitar Pro drum articulation index corresponding to a standard MIDI note. * * @param midiNote General MIDI drum note (e.g., 38 for snare, 36 for kick). * @returns Articulation index inside the GP score model. */ function getPercussionArticulationIndex(midiNote: number): number { if (!percussionIndexMap) { percussionIndexMap = buildPercussionIndexMap(); } return percussionIndexMap.get(midiNote) ?? midiNote; } /** * Core translator class converting rich Songsterr track JSON databases to standard Guitar Pro 7 binary. */ export class SongsterrToAlphaTabConverter { /** * Translates the Songsterr payload into standard Guitar Pro 7 (.gp) binary bytes. * * @param input Collected Songsterr metadata and note payloads. * @returns Binary array representing GP7 file data. */ toGp7(input: SongsterrToGpInput): SongsterrToAlphaTabOutput { const { score, settings, warnings } = this.buildScore(input); const exporter = new alphaTab.exporter.Gp7Exporter(); const data = exporter.export(score, settings); return { data, warnings }; } /** * Translates the Songsterr payload into a standard MIDI (.mid) file. * * @param input Collected Songsterr metadata and note payloads. * @returns Binary array representing standard MIDI file data. */ toMidi(input: SongsterrToGpInput): SongsterrToAlphaTabOutput { const { score, settings, warnings } = this.buildScore(input); const midiFile = new alphaTab.midi.MidiFile(); const handler = new alphaTab.midi.AlphaSynthMidiFileHandler(midiFile, true); const generator = new alphaTab.midi.MidiFileGenerator(score, settings, handler); generator.generate(); const data = midiFile.toBinary(); return { data, warnings }; } /** * Top-down builder compiling alphaTab objects from Songsterr inputs. */ private buildScore({ meta, revisions }: SongsterrToGpInput): BuildScoreResult { const warnings: ConversionWarning[] = []; const score = new alphaTab.model.Score(); score.title = meta.title; score.artist = meta.artist; score.tab = 'Songsterr Downloader'; // Locate the longest/most complete track to represent the master timeline baseline const masterTrack = this.pickMasterTrack(revisions); const masterBarCount = Math.max(1, this.getMasterBarCount(revisions)); // Construct MasterBars representing time signatures, section tags, repeats, and tempo automations this.buildMasterBars({ score, masterTrack, masterBarCount, warnings }); let nextChannel = 0; // Iterate and build individual tracks for (const entry of revisions) { const instrumentId = entry.trackMeta.instrumentId ?? entry.revision.instrumentId; const isPercussion = instrumentId === 1024 || !!entry.trackMeta.isDrums; let channel: number; if (isPercussion) { channel = 9; // Percussion is locked to Channel 9 (MIDI 10) } else { if (nextChannel === 9) nextChannel++; // Skip channel 9 to prevent drum sound bleed on standard instruments channel = nextChannel; nextChannel++; } this.buildTrack({ score, entry, masterBarCount, warnings, channel }); } const settings = new alphaTab.Settings(); // Finish compiles the score model and runs checks on bars, voices, and indices score.finish(settings); return { score, settings, warnings }; } /** * Compiles and appends master bars to the global score. * Coordinates global properties like repeats, alternate endings, time signatures, and tempos. */ private buildMasterBars({ score, masterTrack, masterBarCount, warnings }: { score: alphaTab.model.Score; masterTrack: SongsterrRevisionTrackPayload | null; masterBarCount: number; warnings: ConversionWarning[]; }): void { let timeSignatureNumerator = 4; let timeSignatureDenominator = 4; for (let index = 0; index < masterBarCount; index++) { const measure = masterTrack?.measures?.[index]; const signature = this.getValidSignature(measure?.signature); // Propagate time signature changes down the timeline if (signature) { [timeSignatureNumerator, timeSignatureDenominator] = signature; } const masterBar = new alphaTab.model.MasterBar(); masterBar.timeSignatureNumerator = timeSignatureNumerator; masterBar.timeSignatureDenominator = timeSignatureDenominator; // Extract section markers ("Intro", "Verse", "Chorus", etc.) if (measure?.marker) { const section = new alphaTab.model.Section(); const markerText = this.extractMarkerText(measure.marker); section.marker = markerText; section.text = markerText; masterBar.section = section; } // Handle Repeat bracket structures if (measure?.repeatStart) { masterBar.isRepeatStart = true; } if (typeof measure?.repeatCount === 'number' && measure.repeatCount > 0) { masterBar.repeatCount = measure.repeatCount; } // Handle alternate endings (e.g., 1st ending, 2nd ending) if ( typeof measure?.alternateEnding === 'number' && measure.alternateEnding > 0 ) { masterBar.alternateEndings = measure.alternateEnding; } score.addMasterBar(masterBar); } // Process global tempo changes const tempoPoints = this.findTempoPoints(masterTrack); this.applyTempoAutomations(score, tempoPoints, warnings); } /** * Constructs an individual track (instrument voice), populates it with bars, * scans and formats voice dimensions, and attaches it to the score. */ private buildTrack({ score, entry, masterBarCount, warnings, channel }: { score: alphaTab.model.Score; entry: SongsterrRevisionTrackInput; masterBarCount: number; warnings: ConversionWarning[]; channel: number; }): void { const { trackMeta, revision } = entry; const playbackMapping = mapSongsterrInstrumentToPlayback( trackMeta.instrumentId ?? revision.instrumentId ); const track = new alphaTab.model.Track(); track.name = trackMeta.title || trackMeta.name || revision.name || 'Track'; track.shortName = track.name.slice(0, 20); track.playbackInfo.program = playbackMapping.program; track.playbackInfo.primaryChannel = channel; track.playbackInfo.secondaryChannel = channel; const staff = new alphaTab.model.Staff(); const tuning = revision.tuning || trackMeta.tuning; if (Array.isArray(tuning) && tuning.length > 0) { staff.stringTuning = new alphaTab.model.Tuning('Custom', tuning, false); } const isPercussion = playbackMapping.isPercussion || !!trackMeta.isDrums; staff.isPercussion = isPercussion; const numStrings = Array.isArray(tuning) ? tuning.length : 6; // CRITICAL BUG PREVENTION: Pre-scan to determine the maximum voice count across all measures. // alphaTab expects every single bar in a staff to have the same number of voices. // If a measure has 2 voices (e.g., fingerstyle melody + bassline) but a neighbouring measure only // has 1, the compiler will crash inside Voice._chain when calling score.finish(). // We pad shorter measures with empty rest voices to match the maximum. let maxVoiceCount = 1; for (let i = 0; i < masterBarCount; i++) { const m = revision.measures?.[i]; maxVoiceCount = Math.max(maxVoiceCount, m?.voices?.length || 0); } for (let measureIndex = 0; measureIndex < masterBarCount; measureIndex++) { const bar = new alphaTab.model.Bar(); const measure = revision.measures?.[measureIndex]; const voiceCount = measure?.voices?.length || 0; if (voiceCount === 0) { // Empty measure: pad with a single rest voice populated with silent beats const voice = new alphaTab.model.Voice(); this.fillWithRestBeats(voice, score.masterBars[measureIndex]); bar.addVoice(voice); } else { // Process and append all voices for (let voiceIndex = 0; voiceIndex < voiceCount; voiceIndex++) { const voice = new alphaTab.model.Voice(); const sourceVoice = measure!.voices![voiceIndex]; this.fillVoice({ voice, sourceVoice, masterBar: score.masterBars[measureIndex], warnings, locationPrefix: `track:${trackMeta.partId}|measure:${measureIndex}|voice:${voiceIndex}`, isPercussion, numStrings }); bar.addVoice(voice); } } // Pad remaining empty slots with silent rest voices to match maxVoiceCount for (let v = bar.voices.length; v < maxVoiceCount; v++) { const restVoice = new alphaTab.model.Voice(); this.fillWithRestBeats(restVoice, score.masterBars[measureIndex]); bar.addVoice(restVoice); } staff.addBar(bar); } track.addStaff(staff); score.addTrack(track); } /** * Fills an alphaTab Voice with translated beats parsed from a Songsterr Voice payload. */ private fillVoice({ voice, sourceVoice, masterBar, warnings, locationPrefix, isPercussion, numStrings }: { voice: alphaTab.model.Voice; sourceVoice: SongsterrRevisionVoicePayload | undefined; masterBar: alphaTab.model.MasterBar; warnings: ConversionWarning[]; locationPrefix: string; isPercussion: boolean; numStrings: number; }): void { const beats = sourceVoice?.beats || []; // Fallback if the voice is entirely silent if (beats.length === 0 || sourceVoice?.rest) { this.fillWithRestBeats(voice, masterBar); return; } for (let beatIndex = 0; beatIndex < beats.length; beatIndex++) { const beatData = beats[beatIndex]; const beat = this.mapBeat( beatData, warnings, `${locationPrefix}|beat:${beatIndex}`, isPercussion, numStrings ); voice.addBeat(beat); } if (voice.beats.length === 0) { this.fillWithRestBeats(voice, masterBar); } } /** * Maps detailed musical beat information (tuplets, lyrics/texts, pick strokes, velocities, dynamics) to alphaTab models. */ private mapBeat( beatData: SongsterrRevisionBeatPayload, warnings: ConversionWarning[], location: string, isPercussion: boolean, numStrings: number ): alphaTab.model.Beat { const beat = new alphaTab.model.Beat(); // Mark rest beats if (beatData.rest) { beat.isEmpty = true; } // Convert duration structure const mappedDuration = mapSongsterrDuration(beatData.duration); beat.duration = mappedDuration.duration; beat.dots = beatData.dots ?? mappedDuration.dots; const rawText = beatData.text; const textStr = typeof rawText === 'string' ? rawText : rawText?.text; beat.text = (textStr as string | undefined) || null; if (mappedDuration.isApproximate && !beatData.tuplet) { this.pushWarning(warnings, { code: 'duration_approximated', message: `Approximated unsupported duration ${JSON.stringify( beatData.duration )}`, location }); } // Process tuplets (e.g., triplets, quintuplets, etc.) if (typeof beatData.tuplet === 'number' && beatData.tuplet > 1) { const [num, den] = getTupletRatio(beatData.tuplet); beat.tupletNumerator = num; beat.tupletDenominator = den; // When mapping a tuplet, the underlying duration should match the base time value // of the notes, as the tuplet fraction itself modifies the duration. if (typeof beatData.type === 'number' && beatData.type > 0) { const baseDuration = mapSongsterrDuration([1, beatData.type]); beat.duration = baseDuration.duration; beat.dots = beatData.dots ?? 0; } } // Apply MIDI velocity values to dynamics if (typeof beatData.velocity === 'string') { const mappedDynamic = velocityToDynamicMap[beatData.velocity.toLowerCase()]; if (typeof mappedDynamic === 'number') { beat.dynamics = mappedDynamic; } else { this.pushWarning(warnings, { code: 'velocity_unknown', message: `Unsupported beat velocity "${beatData.velocity}"`, location }); } } // Map pick stroke directions if (typeof beatData.pickStroke === 'string') { const ps = beatData.pickStroke.toLowerCase(); if (ps === 'down') { beat.pickStroke = alphaTab.model.PickStroke.Down; } else if (ps === 'up') { beat.pickStroke = alphaTab.model.PickStroke.Up; } } // Map beat-level vibratos if (beatData.wideVibrato || beatData.vibratoWithTremoloBar) { beat.vibrato = alphaTab.model.VibratoType.Wide; } else if (beatData.vibrato) { beat.vibrato = alphaTab.model.VibratoType.Slight; } // Iterate notes present on this beat const notes = beatData.notes || []; for (let noteIndex = 0; noteIndex < notes.length; noteIndex++) { const noteData = notes[noteIndex]; if (noteData.rest) { continue; } const note = this.mapNote( noteData, beatData, warnings, `${location}|note:${noteIndex}`, isPercussion, numStrings ); beat.addNote(note); } return beat; } /** * Maps a single note and its underlying technical performance properties * (ties, dead/muted notes, ghost notes, legato hammer-ons/pull-offs, slides, harmonics, bends) to alphaTab models. */ private mapNote( noteData: SongsterrRevisionNotePayload, beatData: SongsterrRevisionBeatPayload, warnings: ConversionWarning[], location: string, isPercussion: boolean, numStrings: number ): alphaTab.model.Note { const note = new alphaTab.model.Note(); // STRING MAPPING: // Songsterr: 0 = highest pitch string. // alphaTab: 1 = lowest pitch string. // Thus we invert the string index: string = totalStrings - songsterrString note.string = isPercussion ? -1 : numStrings - (noteData.string ?? 0); note.fret = noteData.fret ?? 0; // Set percussion articulation reference if this is a drum track if (isPercussion) { note.percussionArticulation = getPercussionArticulationIndex( noteData.fret ?? 0 ); } // Tie notes together if (noteData.tie) { note.isTieDestination = true; } // Dead / muted notes if (noteData.dead) { note.isDead = true; } // Ghost (bracketed/soft) notes if (noteData.ghost) { note.isGhost = true; } // Hammer-on or pull-off (legato transition) if (noteData.hp) { note.isHammerPullOrigin = true; } // Staccato articulation if (noteData.staccato) { note.isStaccato = true; } // Accentuated articulation if (noteData.accentuated) { note.accentuated = alphaTab.model.AccentuationType.Normal; } // Palm muting (inherited from the parent beat level) if (beatData.palmMute) { note.isPalmMute = true; } // Note-level pitch vibrato (takes priority over beat level) if (noteData.wideVibrato) { note.vibrato = alphaTab.model.VibratoType.Wide; } else if (noteData.vibrato) { note.vibrato = alphaTab.model.VibratoType.Slight; } // Slide effects (legato, shift, slide in, slide out) if (typeof noteData.slide === 'string') { this.mapSlide(note, noteData.slide, warnings, location); } // Harmonics (natural, artificial, pinch, tap, feedback) if (typeof noteData.harmonic === 'string') { this.mapHarmonic(note, noteData, warnings, location); } // Pitch bends if (noteData.bend && noteData.bend.points && noteData.bend.points.length > 0) { this.mapBend(note, noteData.bend); } return note; } /** * Maps Songsterr slide terms to alphaTab's input and output slide types. */ private mapSlide( note: alphaTab.model.Note, slide: string, warnings: ConversionWarning[], location: string ): void { const normalizedSlide = slide.toLowerCase(); if (normalizedSlide === 'shift') { note.slideOutType = alphaTab.model.SlideOutType.Shift; return; } if (normalizedSlide === 'legato') { note.slideOutType = alphaTab.model.SlideOutType.Legato; return; } if (normalizedSlide === 'into_from_below' || normalizedSlide === 'below') { note.slideInType = alphaTab.model.SlideInType.IntoFromBelow; return; } if (normalizedSlide === 'into_from_above') { note.slideInType = alphaTab.model.SlideInType.IntoFromAbove; return; } if (normalizedSlide === 'out_up') { note.slideOutType = alphaTab.model.SlideOutType.OutUp; return; } if (normalizedSlide === 'out_down' || normalizedSlide === 'downwards') { note.slideOutType = alphaTab.model.SlideOutType.OutDown; return; } this.pushWarning(warnings, { code: 'slide_unsupported', message: `Unsupported slide effect "${slide}"`, location }); } /** * Maps Songsterr harmonic terms to alphaTab's harmonic types and sets the harmonic center fret. */ private mapHarmonic( note: alphaTab.model.Note, noteData: SongsterrRevisionNotePayload, warnings: ConversionWarning[], location: string ): void { const harmonicStr = noteData.harmonic!.toLowerCase(); const mappedType = harmonicTypeMap[harmonicStr]; if (typeof mappedType === 'number') { note.harmonicType = mappedType; if (typeof noteData.harmonicFret === 'number') { note.harmonicValue = noteData.harmonicFret; } } else { this.pushWarning(warnings, { code: 'harmonic_unsupported', message: `Unsupported harmonic type "${noteData.harmonic}"`, location }); } } /** * Maps Songsterr bend vectors to alphaTab BendPoints. * * FORMULA DETAILS: * - Songsterr coordinates: position ranges from 0 to 60. * - Guitar Pro and Songsterr scale: 100 pitch units = 1 full tone (2 semitones). 50 units = 1 semitone. * - alphaTab BendPoint.value expects quarter-steps (where 25 GP units = 1 unit, MaxValue = 12, matching 3 full tones). * - Conversion: alphaTab_value = Songsterr_tone / 25 */ private mapBend( note: alphaTab.model.Note, bend: { tone: number; points: { position: number; tone: number }[] } ): void { note.bendType = alphaTab.model.BendType.Custom; for (const point of bend.points) { const offset = Math.round(point.position); const value = Math.round(point.tone / 25); note.addBendPoint(new alphaTab.model.BendPoint(offset, value)); } } /** * Fills an empty voice with a string of silent rest beats matching the time signature dimensions of the MasterBar. */ private fillWithRestBeats( voice: alphaTab.model.Voice, masterBar: alphaTab.model.MasterBar ): void { const denominator = masterBar.timeSignatureDenominator || 4; const numerator = masterBar.timeSignatureNumerator || 4; const mappedDuration = mapSongsterrDuration([1, denominator]); for (let i = 0; i < numerator; i++) { const restBeat = new alphaTab.model.Beat(); restBeat.isEmpty = true; restBeat.duration = mappedDuration.duration; restBeat.dots = mappedDuration.dots; voice.addBeat(restBeat); } } /** * Scans and returns the highest measure count found across all track revisions. */ private getMasterBarCount(revisions: SongsterrRevisionTrackInput[]): number { return revisions.reduce((max, entry) => { return Math.max(max, entry.revision?.measures?.length || 0); }, 0); } /** * Identifies the track revision containing the largest number of measures to use as the master sync track. */ private pickMasterTrack( revisions: SongsterrRevisionTrackInput[] ): SongsterrRevisionTrackPayload | null { if (revisions.length === 0) { return null; } return revisions.reduce((longest, current) => { const currentLength = current.revision?.measures?.length || 0; const longestLength = longest.revision?.measures?.length || 0; return currentLength > longestLength ? current : longest; }).revision; } /** * Helper verifying if a time signature array is complete and valid. */ private getValidSignature( signature: [number, number] | undefined ): [number, number] | null { if (!Array.isArray(signature) || signature.length !== 2) { return null; } const [numerator, denominator] = signature; if (!numerator || !denominator) { return null; } return [numerator, denominator]; } /** * Helper extracting clean text from complex marker payloads. */ private extractMarkerText(marker: string | { text: string; width?: number }): string { if (typeof marker === 'string') { return marker; } if (marker && typeof marker === 'object' && typeof marker.text === 'string') { return marker.text; } return ''; } /** * Extracts tempo change points defined in the track revision. */ private findTempoPoints( masterTrack: SongsterrRevisionTrackPayload | null ): SongsterrRevisionAutomationTempoPoint[] { const tempo = masterTrack?.automations?.tempo; return Array.isArray(tempo) ? tempo : []; } /** * Applies tempo automation objects to the corresponding MasterBars in the score. */ private applyTempoAutomations( score: alphaTab.model.Score, points: SongsterrRevisionAutomationTempoPoint[], warnings: ConversionWarning[] ): void { for (const point of points) { const barIndex = point.measure; const masterBar = score.masterBars[barIndex]; if (!masterBar) { this.pushWarning(warnings, { code: 'tempo_measure_out_of_range', message: `Tempo automation references missing measure ${barIndex}`, location: `measure:${barIndex}` }); continue; } // alphaTab reference indices: 0=whole, 1=half(×0.5), 2=quarter(×1.0), 3=dotted-quarter(×1.5), 4=half(×2.0), 5=dotted-half(×3.0) // Since Songsterr's BPM is always based on quarter notes, we use reference 2. const tempoReference = 2; const ratioPosition = point.position > 0 ? Math.max(0, Math.min(1, point.position / (point.type || 4))) : 0; const tempoAutomation = alphaTab.model.Automation.buildTempoAutomation( false, ratioPosition, point.bpm, tempoReference, true ); masterBar.tempoAutomations.push(tempoAutomation); } } /** * Appends a conversion warning if the threshold limit has not been reached. */ private pushWarning( warnings: ConversionWarning[], warning: ConversionWarning ): void { if (warnings.length < MAX_WARNINGS) { warnings.push(warning); } } }