881 lines
28 KiB
TypeScript
881 lines
28 KiB
TypeScript
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);
|
||
}
|
||
}
|
||
}
|