Files
tab-downloader/songsterr/songsterr-to-alphatab.converter.ts

881 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, alphaTab.model.DynamicValue> = {
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<string, alphaTab.model.HarmonicType> = {
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<number, number> {
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<number, number>();
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<number, number> | 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);
}
}
}