add initial version of tab-downloader
This commit is contained in:
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Compiled Go binary
|
||||||
|
tab-downloader
|
||||||
|
|
||||||
|
# Default downloads folders
|
||||||
|
downloads/
|
||||||
|
|
||||||
|
# OS specific files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.antigravitycli/
|
||||||
|
|
||||||
|
# Log files
|
||||||
|
*.log
|
||||||
147
README.md
Normal file
147
README.md
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# Guitar Tab Downloader
|
||||||
|
|
||||||
|
A simple, fast, and powerful CLI tool to search, view, and download guitar tabs (Guitar Pro formats like `.gp`, `.gp3`, `.gp4`, `.gp5`, `.gpx`, etc.) from both **Songsterr.com** and **GProTab.net**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Dual-Source Support**: Integrates with both **Songsterr.com** (default) and **GProTab.net**.
|
||||||
|
- **Songsterr Pagination & Unlimited Downloads**: The 50-tab limit on Songsterr is fully lifted! The tool implements automated offset-pagination loops to retrieve and download *all* available tabs for any artist.
|
||||||
|
- **Interactive Multi-Tab Selection**: When batch-downloading an artist's tabs, a powerful index range selection engine prompts you (e.g. `1,3,5-10,12`), allowing you to download only specific tabs. Pressing **Enter** downloads all tabs as the default behavior.
|
||||||
|
- **Automated Fallbacks**: When using Songsterr, if a search or download fails, the tool automatically falls back to GProTab.net to find your tabs.
|
||||||
|
- **Pretty Table Formatting**: Displays search results in gorgeous, modern Unicode box-drawing tables with ANSI colors:
|
||||||
|
- Header row styled in **Bold Cyan**.
|
||||||
|
- Row indexes highlighted in **Bright Green** for quick interactive choices.
|
||||||
|
- Song/Artist titles in **Bright White** and URLs in muted **Gray**.
|
||||||
|
- **Interactive TUI Mode**: Launch the tool without flags for an interactive, colorized menu to search, browse, and download tabs on-the-fly.
|
||||||
|
- **Automated CLI Mode**: Run headlessly with full command-line arguments and flags to automate downloads and search.
|
||||||
|
- **Polite Batch Downloading**: Includes a configurable delay (default 1 second) between requests to respect rate limits.
|
||||||
|
- **Smart Filename Extraction**: Saves files using authentic filenames pulled directly from download headers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation & Compilation
|
||||||
|
|
||||||
|
Ensure you have [Go](https://go.dev/) (version 1.20 or higher) and [Node.js](https://nodejs.org/) installed (Node.js is used by the Songsterr download helper script).
|
||||||
|
|
||||||
|
1. Navigate to the project directory:
|
||||||
|
```bash
|
||||||
|
cd /srv/dev/tab-downloader
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Build the binary:
|
||||||
|
```bash
|
||||||
|
go build -o tab-downloader main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
The project includes lightweight, zero-dependency unit tests for both the Go orchestrator and the TypeScript converter utilities.
|
||||||
|
|
||||||
|
### 1. Go Unit Tests
|
||||||
|
|
||||||
|
Run the Go test suite to verify GProTab path normalization, Songsterr name slugifying, and our multi-index selection range parser:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. TypeScript Unit Tests
|
||||||
|
|
||||||
|
Run the Node.js native test suite to verify Songsterr musical duration matching and General MIDI program mappings:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --experimental-strip-types songsterr/duration-mapper.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### 1. Interactive TUI Mode
|
||||||
|
|
||||||
|
Run the compiled binary with no arguments to launch the colorized interactive menu:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./tab-downloader
|
||||||
|
```
|
||||||
|
|
||||||
|
Example prompt:
|
||||||
|
|
||||||
|
```text
|
||||||
|
=== Guitar Tab Downloader Menu (Site: Songsterr) ===
|
||||||
|
1. Search for Artists
|
||||||
|
2. Search for Songs
|
||||||
|
3. Download Tabs for an Artist (allows selection/all)
|
||||||
|
4. Download a Specific Song Tab
|
||||||
|
5. Switch Site Source (Current: Songsterr)
|
||||||
|
6. Exit
|
||||||
|
==================================================
|
||||||
|
Enter choice (1-6):
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Automated CLI Mode (Flags)
|
||||||
|
|
||||||
|
For automated pipelines or direct searches, run the command with flags.
|
||||||
|
|
||||||
|
#### Search for Artists
|
||||||
|
|
||||||
|
# Search using Songsterr (default)
|
||||||
|
```bash
|
||||||
|
./tab-downloader -search-artist "The Eagles"
|
||||||
|
```
|
||||||
|
|
||||||
|
# Search on GProTab.net specifically
|
||||||
|
```bash
|
||||||
|
./tab-downloader -search-artist "The Eagles" -site gprotab
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Search for Songs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./tab-downloader -search-song "Hotel California"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Download a Specific Song
|
||||||
|
|
||||||
|
# Downloads the song (automatically falling back to GProTab.net if Songsterr fails)
|
||||||
|
```bash
|
||||||
|
./tab-downloader -download-song "Hotel California" -output ./my_tabs
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Download All Tabs for an Artist
|
||||||
|
|
||||||
|
Downloads tabs for an artist into a subdirectory named after the artist, with an interactive prompt allowing you to filter specific selections or download all by default:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./tab-downloader -download-artist "The Eagles" -output ./my_tabs
|
||||||
|
```
|
||||||
|
|
||||||
|
Terminal Prompt Example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Enter selection (e.g., 1-5,7,10-12) or press Enter to download all [Default: all]: 1,3,5-7,10
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Details & CLI Flags
|
||||||
|
|
||||||
|
The complete list of supported command-line flags:
|
||||||
|
|
||||||
|
| Flag | Type | Default | Description |
|
||||||
|
| ------------------ | -------- | ----------- | --------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `-site` | `string` | `songsterr` | Select source website: `'songsterr'` or `'gprotab'`. |
|
||||||
|
| `-search-artist` | `string` | `""` | Search for artists matching the query and print a beautiful Unicode table. |
|
||||||
|
| `-search-song` | `string` | `""` | Search for songs matching the query and print a beautiful Unicode table. |
|
||||||
|
| `-download-artist` | `string` | `""` | Download tabs for an artist (prompts with interactive selection). Accepts artist name query, slug, or full URL. |
|
||||||
|
| `-download-song` | `string` | `""` | Download a single song tab. Accepts song name query, slug, or full URL. |
|
||||||
|
| `-output` | `string` | `downloads` | Destination directory for downloaded files. |
|
||||||
|
| `-delay` | `int` | `1000` | Delay in milliseconds between downloads in batch/artist mode. |
|
||||||
|
|
||||||
|
---
|
||||||
300
main_test.go
Normal file
300
main_test.go
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestNormalizePath validates the GProTab path normalization logic
|
||||||
|
func TestNormalizePath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Full https GProTab URL",
|
||||||
|
input: "https://gprotab.net/en/tabs/metallica/one",
|
||||||
|
expected: "/en/tabs/metallica/one",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Full http GProTab URL",
|
||||||
|
input: "http://gprotab.net/en/tabs/slayer/raining-blood",
|
||||||
|
expected: "/en/tabs/slayer/raining-blood",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Clean relative path",
|
||||||
|
input: "/en/tabs/pantera/walk",
|
||||||
|
expected: "/en/tabs/pantera/walk",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Partial slash slug",
|
||||||
|
input: "megadeth/holy-wars",
|
||||||
|
expected: "/en/tabs/megadeth/holy-wars",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Simple text input",
|
||||||
|
input: "metallica",
|
||||||
|
expected: "metallica",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := normalizePath(tt.input)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("normalizePath(%q) = %q; expected %q", tt.input, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCleanSongsterrName validates slug generation formatting
|
||||||
|
func TestCleanSongsterrName(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Simple lowercase conversion",
|
||||||
|
input: "Metallica",
|
||||||
|
expected: "metallica",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Spaces to hyphens",
|
||||||
|
input: "The Eagles",
|
||||||
|
expected: "the-eagles",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Special characters removal",
|
||||||
|
input: "Guns N' Roses & Friends!",
|
||||||
|
expected: "guns-n-roses-friends",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Collapsing multiple hyphens",
|
||||||
|
input: "rock---and---roll",
|
||||||
|
expected: "rock-and-roll",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Trailing and leading hyphens removal",
|
||||||
|
input: "---led zeppelin---",
|
||||||
|
expected: "led-zeppelin",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := cleanSongsterrName(tt.input)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("cleanSongsterrName(%q) = %q; expected %q", tt.input, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSanitizeDirName validates OS-safe folder name formatting
|
||||||
|
func TestSanitizeDirName(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Preserve capitalization and spaces",
|
||||||
|
input: "As I Lay Dying",
|
||||||
|
expected: "As I Lay Dying",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Filter standard Windows slash characters",
|
||||||
|
input: "AC/DC",
|
||||||
|
expected: "ACDC",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Remove illegal OS characters but keep capitalization and symbols",
|
||||||
|
input: "Guns N' Roses: Live & Loud *Special?",
|
||||||
|
expected: "Guns N' Roses Live & Loud Special",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Collapse adjacent spaces",
|
||||||
|
input: " Slayer Metal ",
|
||||||
|
expected: "Slayer Metal",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := sanitizeDirName(tt.input)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("sanitizeDirName(%q) = %q; expected %q", tt.input, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIsTargetArtist validates the robust sub-artist matching logic
|
||||||
|
func TestIsTargetArtist(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
songArtist string
|
||||||
|
targetArtist string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Direct matching exact",
|
||||||
|
songArtist: "August Burns Red",
|
||||||
|
targetArtist: "August Burns Red",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Direct matching case insensitive",
|
||||||
|
songArtist: "august burns red",
|
||||||
|
targetArtist: "August Burns Red",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Squished name matching",
|
||||||
|
songArtist: "augustburnsred",
|
||||||
|
targetArtist: "August Burns Red",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Trailing punctuation/comma matching",
|
||||||
|
songArtist: "August Burns Red,",
|
||||||
|
targetArtist: "August Burns Red",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Collaboration split by comma matching",
|
||||||
|
songArtist: "August Burns Red, Jeremy McKinnon",
|
||||||
|
targetArtist: "August Burns Red",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Collaboration split by ft matching",
|
||||||
|
songArtist: "August Burns Red ft. Jeremy McKinnon",
|
||||||
|
targetArtist: "August Burns Red",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Collaborator second in split",
|
||||||
|
songArtist: "Jeremy McKinnon & August Burns Red",
|
||||||
|
targetArtist: "August Burns Red",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Not matching similar name parody",
|
||||||
|
songArtist: "August Burns All The Tabs",
|
||||||
|
targetArtist: "August Burns Red",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Not matching misspelling",
|
||||||
|
songArtist: "August Burn Red",
|
||||||
|
targetArtist: "August Burns Red",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := isTargetArtist(tt.songArtist, tt.targetArtist)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("isTargetArtist(%q, %q) = %v; expected %v", tt.songArtist, tt.targetArtist, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseSelection validates parsing selections
|
||||||
|
func TestParseSelection(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
maxVal int
|
||||||
|
expected []int
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Single number",
|
||||||
|
input: "3",
|
||||||
|
maxVal: 10,
|
||||||
|
expected: []int{2},
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Comma separated",
|
||||||
|
input: "1,3,5",
|
||||||
|
maxVal: 10,
|
||||||
|
expected: []int{0, 2, 4},
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Range",
|
||||||
|
input: "2-5",
|
||||||
|
maxVal: 10,
|
||||||
|
expected: []int{1, 2, 3, 4},
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Mixed comma and range",
|
||||||
|
input: "1,3-5,8",
|
||||||
|
maxVal: 10,
|
||||||
|
expected: []int{0, 2, 3, 4, 7},
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "De-duplicate and sort",
|
||||||
|
input: "5,1-3,2",
|
||||||
|
maxVal: 10,
|
||||||
|
expected: []int{0, 1, 2, 4},
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Out of bounds single",
|
||||||
|
input: "11",
|
||||||
|
maxVal: 10,
|
||||||
|
expected: nil,
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Out of bounds range",
|
||||||
|
input: "8-12",
|
||||||
|
maxVal: 10,
|
||||||
|
expected: nil,
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Empty input",
|
||||||
|
input: "",
|
||||||
|
maxVal: 10,
|
||||||
|
expected: nil,
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
res, err := parseSelection(tt.input, tt.maxVal)
|
||||||
|
if tt.expectError {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("expected error for input %q, but got none", tt.input)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(res) != len(tt.expected) {
|
||||||
|
t.Errorf("expected length %d, got %d", len(tt.expected), len(res))
|
||||||
|
} else {
|
||||||
|
for i := range res {
|
||||||
|
if res[i] != tt.expected[i] {
|
||||||
|
t.Errorf("at index %d: expected %d, got %d", i, tt.expected[i], res[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
31
package-lock.json
generated
Normal file
31
package-lock.json
generated
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "tab-downloader",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"dependencies": {
|
||||||
|
"@coderline/alphatab": "^1.8.2",
|
||||||
|
"@xmldom/xmldom": "^0.9.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@coderline/alphatab": {
|
||||||
|
"version": "1.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@coderline/alphatab/-/alphatab-1.8.2.tgz",
|
||||||
|
"integrity": "sha512-Ab11m2IwCdOG+Wbzbv+v4pvJgJnNqw+UjIgKyaTu9TBTWfE7GlOuuElpswkQ2mLB6E4HcOX1FSBopMZdP41B5g==",
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@xmldom/xmldom": {
|
||||||
|
"version": "0.9.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz",
|
||||||
|
"integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
package.json
Normal file
7
package.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"@coderline/alphatab": "^1.8.2",
|
||||||
|
"@xmldom/xmldom": "^0.9.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
44
songsterr/duration-mapper.test.ts
Normal file
44
songsterr/duration-mapper.test.ts
Normal 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);
|
||||||
|
});
|
||||||
91
songsterr/duration-mapper.ts
Normal file
91
songsterr/duration-mapper.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
41
songsterr/instrument-map.ts
Normal file
41
songsterr/instrument-map.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
880
songsterr/songsterr-to-alphatab.converter.ts
Normal file
880
songsterr/songsterr-to-alphatab.converter.ts
Normal 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
163
songsterr/types.ts
Normal 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')
|
||||||
|
}
|
||||||
181
songsterr_dl.ts
Normal file
181
songsterr_dl.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { SongsterrToAlphaTabConverter } from './songsterr/songsterr-to-alphatab.converter.ts';
|
||||||
|
import type { SongsterrStateMetaCurrent, SongsterrRevisionTrackPayload } from './songsterr/types.ts';
|
||||||
|
|
||||||
|
// Polite standard User-Agent header to prevent aggressive scraper blocks
|
||||||
|
const userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the HTML content of a URL using the global fetch API.
|
||||||
|
*
|
||||||
|
* @param url Target endpoint to fetch.
|
||||||
|
* @returns HTML string content.
|
||||||
|
*/
|
||||||
|
async function fetchHTML(url: string): Promise<string> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': userAgent
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to fetch ${url} (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
return res.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches and parses JSON from a URL using the global fetch API.
|
||||||
|
*
|
||||||
|
* @param url Target JSON API endpoint.
|
||||||
|
* @returns Parsed JSON object.
|
||||||
|
*/
|
||||||
|
async function fetchJSON(url: string): Promise<any> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': userAgent
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to fetch JSON ${url} (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitizes a filename string by replacing illegal OS characters with underscores.
|
||||||
|
*
|
||||||
|
* @param name File name to clean.
|
||||||
|
* @returns Cleaned file name.
|
||||||
|
*/
|
||||||
|
function sanitizeFilename(name: string): string {
|
||||||
|
return name.replace(/[\\/:*?"<>|]/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main execution entry point for the Songsterr download and conversion helper.
|
||||||
|
*/
|
||||||
|
async function main() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
let songUrl = '';
|
||||||
|
let outputDir = '.';
|
||||||
|
|
||||||
|
// Parse custom command-line arguments: --url <songUrl> and --output <outputDir>
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
if (args[i] === '--url' && i + 1 < args.length) {
|
||||||
|
songUrl = args[i + 1];
|
||||||
|
i++;
|
||||||
|
} else if (args[i] === '--output' && i + 1 < args.length) {
|
||||||
|
outputDir = args[i + 1];
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!songUrl) {
|
||||||
|
console.error('Error: Missing --url parameter');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`[*] Fetching Songsterr page: ${songUrl}`);
|
||||||
|
const html = await fetchHTML(songUrl);
|
||||||
|
|
||||||
|
// Locate the embedded React hydration state JSON script tag
|
||||||
|
const stateMatch = html.match(/<script id="state"[^>]*>([^<]+)<\/script>/);
|
||||||
|
if (!stateMatch) {
|
||||||
|
throw new Error('Unable to find Songsterr state payload in page HTML');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode and parse the JSON string containing comprehensive metadata & tracking hashes
|
||||||
|
const stateJSON = decodeURIComponent(stateMatch[1]);
|
||||||
|
const parsedState = JSON.parse(stateJSON);
|
||||||
|
const current = parsedState?.meta?.current;
|
||||||
|
|
||||||
|
if (!current?.songId || !current?.revisionId || !current?.image) {
|
||||||
|
throw new Error('Songsterr state payload missing required revision fields');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Structure metadata representing active song and its separate tracking divisions
|
||||||
|
const stateMeta: SongsterrStateMetaCurrent = {
|
||||||
|
songId: current.songId,
|
||||||
|
revisionId: current.revisionId,
|
||||||
|
image: current.image,
|
||||||
|
title: current.title || 'Song',
|
||||||
|
artist: current.artist || 'Unknown Artist',
|
||||||
|
tracks: Array.isArray(current.tracks) ? current.tracks : []
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`[*] Found song: "${stateMeta.title}" by ${stateMeta.artist}`);
|
||||||
|
console.log(`[*] Tracks count: ${stateMeta.tracks.length}`);
|
||||||
|
|
||||||
|
// CloudFront CDNs containing Songsterr's actual track-level musical note data files
|
||||||
|
const CDNs = [
|
||||||
|
'https://dqsljvtekg760.cloudfront.net',
|
||||||
|
'https://d3d3l6a6rcgkaf.cloudfront.net'
|
||||||
|
];
|
||||||
|
|
||||||
|
const revisions: any[] = [];
|
||||||
|
|
||||||
|
// Fetch individual JSON note revision payloads for each track defined in the metadata
|
||||||
|
for (const track of stateMeta.tracks) {
|
||||||
|
if (typeof track.partId !== 'number') continue;
|
||||||
|
|
||||||
|
console.log(`[*] Fetching note payload for track: "${track.name || track.title || 'Track'}" (Part ${track.partId})...`);
|
||||||
|
let fetched = false;
|
||||||
|
let revisionPayload: SongsterrRevisionTrackPayload | null = null;
|
||||||
|
|
||||||
|
// Iterate through available CDNs in case of network or mirror failures
|
||||||
|
for (const cdn of CDNs) {
|
||||||
|
// Build direct URL targeting the JSON payload of this specific track part
|
||||||
|
const cdnUrl = `${cdn}/${stateMeta.songId}/${stateMeta.revisionId}/${stateMeta.image}/${track.partId}.json`;
|
||||||
|
try {
|
||||||
|
revisionPayload = await fetchJSON(cdnUrl);
|
||||||
|
fetched = true;
|
||||||
|
break; // Stop CDN attempts if fetch succeeds
|
||||||
|
} catch (e) {
|
||||||
|
// Silent catch: log failure locally and try fallback CDN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fetched || !revisionPayload) {
|
||||||
|
console.warn(`[!] Warning: Failed to fetch note payload for track part ${track.partId}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
revisions.push({
|
||||||
|
trackMeta: track,
|
||||||
|
revision: revisionPayload
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (revisions.length === 0) {
|
||||||
|
throw new Error('Failed to fetch note payloads for any of the tracks');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initiate alphaTab score generation and export as standard Guitar Pro 7 binary
|
||||||
|
console.log('[*] Converting notes to Guitar Pro 7 format...');
|
||||||
|
const converter = new SongsterrToAlphaTabConverter();
|
||||||
|
const result = converter.toGp7({
|
||||||
|
meta: stateMeta,
|
||||||
|
revisions: revisions
|
||||||
|
});
|
||||||
|
|
||||||
|
const artistSafe = sanitizeFilename(stateMeta.artist);
|
||||||
|
const titleSafe = sanitizeFilename(stateMeta.title);
|
||||||
|
const fileName = `${artistSafe} - ${titleSafe}.gp`;
|
||||||
|
|
||||||
|
if (!fs.existsSync(outputDir)) {
|
||||||
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const outPath = path.join(outputDir, fileName);
|
||||||
|
fs.writeFileSync(outPath, result.data);
|
||||||
|
|
||||||
|
console.log(`[+] Success! Saved tab file to: ${outPath}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(`[-] Error: ${e.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
Reference in New Issue
Block a user