add initial version of tab-downloader

This commit is contained in:
2026-05-24 04:47:53 +00:00
Unverified
commit d56d4714db
13 changed files with 3628 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
import { test } from 'node:test';
import * as assert from 'node:assert';
import * as alphaTab from '@coderline/alphatab';
import { mapSongsterrDuration } from './duration-mapper.ts';
import { mapSongsterrInstrumentToPlayback } from './instrument-map.ts';
test('mapSongsterrDuration should map simple and complex durations', () => {
// Test a simple quarter note (1/4)
const quarter = mapSongsterrDuration([1, 4]);
assert.strictEqual(quarter.duration, alphaTab.model.Duration.Quarter);
assert.strictEqual(quarter.dots, 0);
assert.strictEqual(quarter.isApproximate, false);
// Test a dotted quarter note (3/8)
const dottedQuarter = mapSongsterrDuration([3, 8]);
assert.strictEqual(dottedQuarter.duration, alphaTab.model.Duration.Quarter);
assert.strictEqual(dottedQuarter.dots, 1);
assert.strictEqual(dottedQuarter.isApproximate, false);
// Test an eighth note (1/8)
const eighth = mapSongsterrDuration([1, 8]);
assert.strictEqual(eighth.duration, alphaTab.model.Duration.Eighth);
assert.strictEqual(eighth.dots, 0);
// Test a double-dotted quarter note (7/16)
// 7/16 = 0.4375 which is a Quarter Note (0.25) + Half-Quarter (0.125) + Quarter-Quarter (0.0625)
const doubleDottedQuarter = mapSongsterrDuration([7, 16]);
assert.strictEqual(doubleDottedQuarter.duration, alphaTab.model.Duration.Quarter);
assert.strictEqual(doubleDottedQuarter.dots, 2);
assert.strictEqual(doubleDottedQuarter.isApproximate, false);
});
test('mapSongsterrInstrumentToPlayback should resolve instruments', () => {
// Test standard Songsterr drum identifier
const drums = mapSongsterrInstrumentToPlayback(1024);
assert.strictEqual(drums.isPercussion, true);
assert.strictEqual(drums.program, 0);
assert.strictEqual(drums.primaryChannel, 9);
// Test standard Acoustic Guitar (Program 24)
const guitar = mapSongsterrInstrumentToPlayback(24);
assert.strictEqual(guitar.isPercussion, false);
assert.strictEqual(guitar.program, 24);
});

View File

@@ -0,0 +1,91 @@
import * as alphaTab from '@coderline/alphatab';
/**
* Result structure representing a successfully mapped Songsterr duration in alphaTab terms.
*/
export interface DurationMappingResult {
duration: alphaTab.model.Duration; // Closest matching alphaTab standard duration enum value
dots: number; // Number of dot annotations (0, 1, or 2)
isApproximate: boolean; // Flag indicating if the duration had to be approximated due to custom fractions
}
// Available base durations supported natively by alphaTab and standard GP files
const baseDurations: alphaTab.model.Duration[] = [
alphaTab.model.Duration.Whole,
alphaTab.model.Duration.Half,
alphaTab.model.Duration.Quarter,
alphaTab.model.Duration.Eighth,
alphaTab.model.Duration.Sixteenth,
alphaTab.model.Duration.ThirtySecond,
alphaTab.model.Duration.SixtyFourth
];
/**
* Maps a fractional duration tuple [numerator, denominator] from Songsterr's API payload
* to a standardized alphaTab.model.Duration plus any dot values (dotted or double-dotted notes).
*
* Since Songsterr can utilize arbitrary fractional times, this searches through a
* matrix of valid base durations combined with 0 to 2 dots, selecting the combination
* that yields the minimum numeric delta.
*
* @param duration An array containing [numerator, denominator] representing a musical time slice.
* @returns A DurationMappingResult containing mapped duration, dots, and a precision flag.
*/
export function mapSongsterrDuration(
duration: [number, number] | undefined
): DurationMappingResult {
// If no duration is provided, default to a standard quarter note and mark it as approximate.
if (!duration) {
return {
duration: alphaTab.model.Duration.Quarter,
dots: 0,
isApproximate: true
};
}
const [numerator, denominator] = duration;
if (!numerator || !denominator) {
return {
duration: alphaTab.model.Duration.Quarter,
dots: 0,
isApproximate: true
};
}
// Calculate the numeric target duration value (e.g., 1/4 = 0.25, 3/8 = 0.375)
const targetValue = numerator / denominator;
let bestDuration = alphaTab.model.Duration.Quarter;
let bestDots = 0;
let bestDelta = Number.POSITIVE_INFINITY;
// Search space: check each base duration against 0, 1, or 2 dots.
for (const candidateDuration of baseDurations) {
// Convert duration enum back to its numeric value (e.g., Duration.Quarter has underlying enum value of 4 -> 1/4)
const baseValue = 1 / Number(candidateDuration);
for (const dots of [0, 1, 2]) {
// Dotted notes add half of the preceding value:
// 1 dot = base + base/2
// 2 dots = base + base/2 + base/4
const dottedValue =
baseValue +
(dots >= 1 ? baseValue / 2 : 0) +
(dots >= 2 ? baseValue / 4 : 0);
const delta = Math.abs(dottedValue - targetValue);
// Track the closest match
if (delta < bestDelta) {
bestDelta = delta;
bestDuration = candidateDuration;
bestDots = dots;
}
}
}
return {
duration: bestDuration,
dots: bestDots,
// Consider it exact if the delta is within a minuscule threshold
isApproximate: bestDelta > 0.000001
};
}

View File

@@ -0,0 +1,41 @@
/**
* Represents the MIDI playback configuration for an instrument track.
*/
export interface InstrumentMapping {
program: number; // General MIDI (GM) program number (0-127)
isPercussion: boolean; // Flag identifying if this is a percussion/drum track
primaryChannel?: number; // Primary MIDI channel for audio playback
secondaryChannel?: number; // Secondary MIDI channel for secondary articulations
}
/**
* Maps a Songsterr instrument ID to a playback mapping configuration compatible with General MIDI.
*
* @param instrumentId The raw instrument ID provided by the Songsterr metadata payload.
* @returns An InstrumentMapping structure indicating MIDI program and channel attributes.
*/
export function mapSongsterrInstrumentToPlayback(
instrumentId: number | undefined
): InstrumentMapping {
// In Songsterr's schema, instrument ID 1024 is reserved for Drums/Percussion.
if (instrumentId === 1024) {
return {
program: 0, // General MIDI standard uses program 0 on Channel 9 for percussion
isPercussion: true,
primaryChannel: 9, // Channel 9 (0-indexed, corresponding to physical channel 10) is the standard percussion channel
secondaryChannel: 9
};
}
// Fallback program defaults to Acoustic Guitar (24) if undefined, or clamps raw IDs between standard GM limits (0-127).
const normalizedProgram =
typeof instrumentId === 'number'
? Math.min(Math.max(instrumentId, 0), 127)
: 24;
return {
program: normalizedProgram,
isPercussion: false
};
}

View File

@@ -0,0 +1,880 @@
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);
}
}
}

163
songsterr/types.ts Normal file
View File

@@ -0,0 +1,163 @@
/**
* Raw response format received when initiating a file download from Songsterr.
*/
export interface SongsterrDownloadResponse {
file: number[]; // Binary array representation of the downloaded file bytes
fileName: string; // The original filename served by the server
contentType: string; // The MIME type of the file
}
/**
* Basic metadata subset returned by Songsterr search endpoints.
*/
export interface SongsterrPartialMetadata {
title: string; // Title of the song
songId: number; // Global identifier for the song
artistId: number; // Global identifier for the artist
artist: string; // Artist name
byLinkUrl?: string; // Optional absolute link URL
}
/**
* Represents metadata of an individual instrument track in Songsterr's state payload.
*/
export interface SongsterrStateMetaCurrentTrack {
partId: number; // Part ID used to fetch the track-specific note payload JSON
instrumentId: number; // Instrument ID used for MIDI playback assignment
title?: string; // Custom track title (alternative)
name?: string; // Custom track name
tuning?: number[]; // Tuning offsets in semitones starting from highest pitch
isDrums?: boolean; // Flag indicating if this is a percussion track
}
/**
* The top-level metadata container for the current song state scraped from Songsterr's HTML script block.
*/
export interface SongsterrStateMetaCurrent {
songId: number; // Song ID
revisionId: number; // Active musical notation revision ID
image: string; // Storage hash/image suffix identifier on CloudFront CDNs
title: string; // Title of the song
artist: string; // Name of the artist
tracks: SongsterrStateMetaCurrentTrack[]; // Collection of tracks associated with this revision
}
/**
* A tempo variation marker inside the song's automation timeline.
*/
export interface SongsterrRevisionAutomationTempoPoint {
measure: number; // 0-indexed measure where the tempo change occurs
position: number; // Metric position within the measure
bpm: number; // Beats Per Minute (tempo target)
type: number; // Time division duration type index
}
/**
* Collection of performance automations (such as tempo changes) present in a revision.
*/
export interface SongsterrRevisionAutomations {
tempo?: SongsterrRevisionAutomationTempoPoint[];
}
/**
* Coordinate specifying a single point on a pitch bend diagram.
*/
export interface SongsterrRevisionBendPoint {
position: number; // Horizontal offset on bend diagram (0 to 60)
tone: number; // Pitch offset value (100 = 1 full tone, 50 = 1 semitone)
}
/**
* Pitch bend configuration payload for a note.
*/
export interface SongsterrRevisionBendPayload {
tone: number; // Max bend tone value
points: SongsterrRevisionBendPoint[]; // Set of coordinate points defining the bend curve
}
/**
* Playback modification, articulation, or fret details of an individual note.
*/
export interface SongsterrRevisionNotePayload {
fret?: number; // Fret number on the fretboard (0 = open string)
string?: number; // 0-indexed string index (0 is typically the highest pitch string)
tie?: boolean; // Identifies if this note is tied to the previous note
slide?: string; // String code representing slide type ('shift', 'legato', 'below', etc.)
rest?: boolean; // Identifies if the note is a rest
dead?: boolean; // Identifies dead (muted) notes
ghost?: boolean; // Identifies ghost/bracketed notes
hp?: boolean; // Legato hammer-on or pull-off flag
staccato?: boolean; // Articulation indicating a shortened note duration
accentuated?: boolean;// Flag indicating heavier picking velocity
vibrato?: boolean; // Subtle pitch vibrato
wideVibrato?: boolean;// Intense pitch vibrato
harmonic?: string; // Harmonic type ('natural', 'artificial', 'pinch', etc.)
harmonicFret?: number;// Fret location where the harmonic is initiated
bend?: SongsterrRevisionBendPayload; // Pitch bend details
}
/**
* Musical beat payload grouping multiple notes together played at the same instant.
*/
export interface SongsterrRevisionBeatPayload {
notes?: SongsterrRevisionNotePayload[]; // Collection of notes active in this beat
type?: number; // Base beat duration category
duration?: [number, number]; // Fractional duration tuple [numerator, denominator]
dots?: number; // Count of dots modifying the beat's length
text?: string | { text: string; width?: number }; // Custom lyric/annotation text on the staff
velocity?: string; // MIDI dynamics code ('p', 'mp', 'f', etc.)
rest?: boolean; // Flag identifying if this beat is a pure rest
palmMute?: boolean; // Flag identifying if palm-muting is active for this beat
vibrato?: boolean; // Beat-level vibrato
wideVibrato?: boolean;// Beat-level wide vibrato
vibratoWithTremoloBar?: string; // Tremolo bar vibrato depth code
pickStroke?: string; // Picking direction ('up', 'down')
tuplet?: number; // Tuplet factor (e.g., 3 for triplets, 5 for quintuplets)
tupletStart?: boolean;// Flag identifying the start of a tuplet grouping
tupletStop?: boolean; // Flag identifying the end of a tuplet grouping
}
/**
* Represents a vocal line/independent voice track containing sequential beats within a measure.
*/
export interface SongsterrRevisionVoicePayload {
beats?: SongsterrRevisionBeatPayload[]; // Beats sequenced inside this voice
rest?: boolean; // Flag identifying if the voice is entirely silent
}
/**
* Represents a single measure (bar) in a track containing multiple voices.
*/
export interface SongsterrRevisionMeasurePayload {
voices?: SongsterrRevisionVoicePayload[]; // Independent musical voices in this bar
signature?: [number, number]; // Time signature tuple [numerator, denominator] (e.g. [4, 4])
marker?: string | { text: string; width?: number }; // Section marker name (e.g. "Verse", "Chorus")
repeatStart?: boolean;// Identifies if a repeat section opens at this measure
repeatCount?: number; // Count of repeat iterations
alternateEnding?: number; // Alternate ending bracket index/value
rest?: boolean; // Identifies if the entire measure is silent
}
/**
* Complete musical score revision details for a single track.
*/
export interface SongsterrRevisionTrackPayload {
name?: string; // Track name
instrumentId?: number;// Instrument playback configuration ID
tuning?: number[]; // Note offsets for each string
strings?: number; // Number of strings on the instrument
measures: SongsterrRevisionMeasurePayload[]; // Complete sequence of measures (bars)
automations?: SongsterrRevisionAutomations; // Set of playback automations
songId?: number; // Reference song identifier
revisionId?: number; // Reference revision identifier
partId?: number; // Reference part identifier
}
/**
* Warning payload generated during the parsing/conversion process.
*/
export interface ConversionWarning {
code: string; // Unique warning code identifier (e.g., 'duration_approximated')
message: string; // Human-readable warning description
location?: string; // Code location indicating where the issue was hit (e.g., 'track:1|measure:12')
}