Skip to content

Commit 28942a5

Browse files
committed
Merge packaging: cross-platform executable builds
2 parents e647d83 + 579a429 commit 28942a5

12 files changed

Lines changed: 532 additions & 139 deletions

.gitignore

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,9 @@ __pycache__/
77

88
# Generated GP files (keep blank.gp template)
99
*.gp
10-
!blank.gp
10+
!assets/blank.gp
11+
12+
# PyInstaller
13+
build/
14+
dist/
15+
*.spec

README.md

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,24 @@ Generate Guitar Pro tabs from Songsterr and sync them with YouTube audio. Produc
33

44
![Guitar Pro with synced backing track](assets/screenshot.png)
55

6+
## Download
7+
8+
Pre-built executables are available:
9+
10+
**[Download the latest release](https://github.com/teaqu/guitar-pro-youtube-sync/releases/latest)**
11+
12+
| Platform | File |
13+
|----------|------|
14+
| Windows | `guitar-pro-sync-windows-x86_64.exe` |
15+
| macOS (Apple Silicon) | `guitar-pro-sync-macos-arm64` |
16+
| Linux | `guitar-pro-sync-linux-x86_64` |
17+
18+
Just download, run, and follow the prompts. Everything (Python, yt-dlp, ffmpeg) is bundled inside the executable.
19+
20+
> **macOS:** You may see "Apple could not verify this app." Right-click the file, select **Open**, then click **Open** again to bypass Gatekeeper.
21+
>
22+
> **Windows:** Windows Defender or SmartScreen may flag the download. This is a common false positive with PyInstaller-built executables. Click **More info****Run anyway**.
23+
624
## What It Does
725

826
Songsterr has timing data that maps each measure of a song's tab to a specific timestamp in a YouTube video. This tool:
@@ -13,35 +31,35 @@ Songsterr has timing data that maps each measure of a song's tab to a specific t
1331

1432
The result is a `.gp` file you can open in Guitar Pro with a synced backing track.
1533

16-
## Prerequisites
34+
## Development Setup
35+
36+
If you want to run from source instead of the pre-built executable:
37+
38+
### Prerequisites
1739

1840
- **Python 3.10+**
19-
- **[yt-dlp](https://github.com/yt-dlp/yt-dlp)** -- for downloading YouTube audio
20-
- **[ffmpeg](https://ffmpeg.org/)** -- for audio conversion (used by yt-dlp)
41+
- **[ffmpeg](https://ffmpeg.org/)** -- for audio conversion
2142

22-
## Installation
43+
### Installation
2344

2445
```bash
2546
git clone https://github.com/teaqu/guitar-pro-youtube-sync.git
2647
cd guitar-pro-youtube-sync
2748
python -m venv .venv
2849
source .venv/bin/activate
29-
pip install requests
50+
pip install -r requirements.txt
3051
```
3152

32-
Make sure `yt-dlp` and `ffmpeg` are installed and available on your PATH:
53+
Make sure `ffmpeg` is installed and available on your PATH:
3354

3455
```bash
3556
# macOS
36-
brew install yt-dlp ffmpeg
57+
brew install ffmpeg
3758

3859
# Linux
39-
pip install yt-dlp
4060
sudo apt install ffmpeg
4161

42-
# Windows
43-
pip install yt-dlp
44-
# Download ffmpeg from https://ffmpeg.org/download.html
62+
# Windows - download from https://ffmpeg.org/download.html
4563
```
4664

4765
## Usage
@@ -95,11 +113,11 @@ Supported browsers: `chrome`, `firefox`, `safari`, `edge`, `brave`, `opera`, `vi
95113

96114
### Generate a GP file only (no sync)
97115

98-
If you don't want the audio you can also use use `gen-gp.py` directly to generate a Guitar Pro file from Songsterr without audio syncing:
116+
If you don't want the audio you can also use use `gen_gp.py` directly to generate a Guitar Pro file from Songsterr without audio syncing:
99117

100118
```bash
101-
python gen-gp.py --song 23063
102-
python gen-gp.py --song 23063 -o output.gp
119+
python gen_gp.py --song 23063
120+
python gen_gp.py --song 23063 -o output.gp
103121
```
104122

105123
### Example output
@@ -145,7 +163,7 @@ Done!
145163

146164
## How It Works
147165

148-
### GP file generation (`gen-gp.py`)
166+
### GP file generation (`gen_gp.py`)
149167

150168
1. Fetches song metadata and all track data from Songsterr
151169
2. Converts Songsterr's JSON format into Guitar Pro's GPIF XML format (notes, beats, bars, rhythms, etc.)
@@ -162,7 +180,7 @@ Done!
162180

163181
## Testing
164182

165-
The project includes test suites for `sync.py` and `gen-gp.py`:
183+
The project includes test suites for `sync.py` and `gen_gp.py`:
166184

167185
```bash
168186
# Install test dependencies first

gen-gp.py renamed to gen_gp.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66
and generates a new score.gpif with the tab data.
77
88
Usage:
9-
python gen-gp.py --song 61178 [-o output.gp]
10-
python gen-gp.py --song https://www.songsterr.com/a/wsa/ozzy-osbourne-crazy-train-tab-s61178
11-
python gen-gp.py input.json [-o output.gp]
9+
python gen_gp.py --song 61178 [-o output.gp]
10+
python gen_gp.py --song https://www.songsterr.com/a/wsa/ozzy-osbourne-crazy-train-tab-s61178
11+
python gen_gp.py input.json [-o output.gp]
1212
"""
1313

1414
import argparse
@@ -20,6 +20,8 @@
2020

2121
import requests
2222

23+
from utils import resource_path
24+
2325
DURATION_MAP = {
2426
1: "Whole", 2: "Half", 4: "Quarter", 8: "Eighth",
2527
16: "16th", 32: "32nd", 64: "64th",
@@ -59,8 +61,8 @@
5961
}
6062

6163
SONGSTERR_CDN = "https://dqsljvtekg760.cloudfront.net"
62-
BLANK_GP = Path(__file__).parent / "assets" / "blank.gp"
63-
DRUM_KIT_XML = Path(__file__).parent / "assets" / "drum_kit.xml"
64+
BLANK_GP = resource_path("assets/blank.gp")
65+
DRUM_KIT_XML = resource_path("assets/drum_kit.xml")
6466

6567

6668
def escape_xml(text: str) -> str:
@@ -864,7 +866,7 @@ def generate_gp(tracks: list[dict], output_path: Path, meta: dict | None = None,
864866
dst.writestr(item, gpif.encode("utf-8"))
865867
else:
866868
dst.writestr(item, src.read(item.filename))
867-
tmp_path.rename(output_path)
869+
tmp_path.replace(output_path)
868870

869871
# Print summary
870872
num_measures = len(tracks[0].get("measures", []))
@@ -900,10 +902,10 @@ def main():
900902
formatter_class=argparse.RawDescriptionHelpFormatter,
901903
epilog="""
902904
Examples:
903-
python gen-gp.py --song 61178
904-
python gen-gp.py --song https://www.songsterr.com/a/wsa/ozzy-osbourne-crazy-train-tab-s61178
905-
python gen-gp.py --song 61178 -o crazy_train.gp
906-
python gen-gp.py input.json -o output.gp
905+
python gen_gp.py --song 61178
906+
python gen_gp.py --song https://www.songsterr.com/a/wsa/ozzy-osbourne-crazy-train-tab-s61178
907+
python gen_gp.py --song 61178 -o crazy_train.gp
908+
python gen_gp.py input.json -o output.gp
907909
""",
908910
)
909911
parser.add_argument("input", nargs="?", help="JSON file path or '-' for stdin (single track mode)")

main.py

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Guitar Pro YouTube Sync - Interactive CLI
4+
5+
Generates Guitar Pro (.gp) files from Songsterr tabs,
6+
optionally synced with YouTube audio.
7+
"""
8+
9+
import sys
10+
from pathlib import Path
11+
12+
import gen_gp
13+
from sync import (
14+
fetch_song_meta,
15+
fetch_video_points,
16+
select_video_entry,
17+
download_youtube_audio,
18+
sync_gp_file,
19+
print_summary,
20+
)
21+
from utils import load_config, save_config
22+
23+
24+
BROWSERS = ["chrome", "firefox", "edge", "brave", "safari", "opera"]
25+
26+
27+
def prompt_yes_no(question: str, default: bool = True) -> bool:
28+
"""Prompt user for yes/no answer."""
29+
suffix = "[Y/n]" if default else "[y/N]"
30+
while True:
31+
answer = input(f"{question} {suffix}: ").strip().lower()
32+
if answer == "":
33+
return default
34+
if answer in ("y", "yes"):
35+
return True
36+
if answer in ("n", "no"):
37+
return False
38+
print(" Please enter 'y' or 'n'")
39+
40+
41+
def prompt_browser_choice() -> str | None:
42+
"""Prompt user to select a browser for cookie extraction."""
43+
print("\n YouTube may require authentication for this video.")
44+
print(" Select a browser to use cookies from (must be logged into YouTube):")
45+
for i, browser in enumerate(BROWSERS, 1):
46+
print(f" {i}. {browser}")
47+
skip_num = len(BROWSERS) + 1
48+
print(f" {skip_num}. Skip audio")
49+
50+
while True:
51+
try:
52+
choice = input(f"\n Choice [{skip_num}]: ").strip()
53+
if choice == "":
54+
return None
55+
idx = int(choice)
56+
if 1 <= idx <= len(BROWSERS):
57+
return BROWSERS[idx - 1]
58+
if idx == skip_num:
59+
return None
60+
print(f" Please enter a number between 1 and {skip_num}")
61+
except ValueError:
62+
print(" Please enter a number")
63+
64+
65+
def try_download_audio(video_id: str, audio_path: Path, trim_start: float, config: dict) -> bool:
66+
"""Attempt to download audio, with automatic retry using saved browser and manual prompt.
67+
68+
Returns True if audio was downloaded successfully.
69+
"""
70+
# First attempt: no cookies
71+
try:
72+
download_youtube_audio(video_id, audio_path, trim_start=trim_start)
73+
return True
74+
except Exception as e:
75+
print(f"\n Audio download failed: {e}")
76+
77+
# Second attempt: auto-retry with saved browser
78+
saved_browser = config.get("cookie_browser")
79+
if saved_browser:
80+
print(f"\n Retrying with saved browser ({saved_browser})...")
81+
try:
82+
download_youtube_audio(video_id, audio_path, trim_start=trim_start, cookies_browser=saved_browser)
83+
return True
84+
except Exception as e:
85+
print(f" Still failed: {e}")
86+
87+
# Third attempt: ask user to pick a browser
88+
browser = prompt_browser_choice()
89+
if not browser:
90+
print(" Skipping audio.")
91+
return False
92+
93+
print(f"\n Retrying with {browser} cookies...")
94+
try:
95+
download_youtube_audio(video_id, audio_path, trim_start=trim_start, cookies_browser=browser)
96+
# Save successful browser for next time
97+
config["cookie_browser"] = browser
98+
save_config(config)
99+
print(f" (Saved {browser} as default browser for next time)")
100+
return True
101+
except Exception as e:
102+
print(f"\n Download failed again: {e}")
103+
print(" Skipping audio.")
104+
return False
105+
106+
107+
def process_song(config: dict) -> None:
108+
"""Process a single song (generate GP + optional audio sync)."""
109+
# Get song input
110+
while True:
111+
user_input = input("\nEnter Songsterr URL or song ID (or 'q' to quit): ").strip()
112+
if user_input.lower() in ("q", "quit", "exit"):
113+
raise SystemExit(0)
114+
if not user_input:
115+
continue
116+
try:
117+
song_id = gen_gp.parse_song_id(user_input)
118+
break
119+
except ValueError as e:
120+
print(f"\n Error: {e}")
121+
print(" Examples: https://www.songsterr.com/a/wsa/metallica-master-of-puppets-tab-s84 or 84")
122+
123+
# Fetch metadata
124+
print("\nFetching song info...")
125+
try:
126+
meta = fetch_song_meta(song_id)
127+
except Exception as e:
128+
print(f"\n Error fetching song data: {e}")
129+
return
130+
131+
artist = meta.get("artist", "Unknown")
132+
title = meta.get("title", "Unknown")
133+
num_tracks = len(meta.get("tracks", []))
134+
print(f" Found: {artist} - {title} ({num_tracks} tracks)")
135+
136+
include_audio = prompt_yes_no("\nInclude YouTube audio?", default=True)
137+
138+
safe_name = "".join(c if c.isalnum() or c in " -_" else "" for c in f"{artist} - {title}").strip()
139+
total_steps = 3 if include_audio else 1
140+
141+
# Step 1: Generate GP file
142+
print(f"\n[1/{total_steps}] Generating Guitar Pro file...")
143+
try:
144+
gp_meta, tracks = gen_gp.fetch_all_tracks(song_id)
145+
gp_file = Path(f"{safe_name or 'output'}.gp").resolve()
146+
gen_gp.generate_gp(tracks, gp_file, gp_meta)
147+
except Exception as e:
148+
print(f"\n Error generating GP file: {e}")
149+
return
150+
151+
if not include_audio:
152+
print(f"\nDone! File saved to: {gp_file}")
153+
return
154+
155+
# Step 2: Download audio
156+
print(f"\n[2/{total_steps}] Downloading YouTube audio...")
157+
revision_id = meta["revisionId"]
158+
try:
159+
entries = fetch_video_points(song_id, revision_id)
160+
entry = select_video_entry(entries)
161+
except Exception as e:
162+
print(f"\n Error fetching video data: {e}")
163+
print(" Continuing without audio...")
164+
print(f"\nDone! File saved to: {gp_file}")
165+
return
166+
167+
points = entry["points"]
168+
video_id = entry["videoId"]
169+
trim_start = points[0] if points else 0.0
170+
audio_path = gp_file.parent / ".tmp_audio.mp3"
171+
172+
audio_ok = try_download_audio(video_id, audio_path, trim_start, config)
173+
174+
# Step 3: Sync
175+
print(f"\n[3/{total_steps}] Syncing audio with tab...")
176+
synced_path = gp_file.parent / f"{gp_file.stem}_synced{gp_file.suffix}"
177+
mp3_path = audio_path if audio_ok else None
178+
bpms = sync_gp_file(gp_file, points, synced_path, mp3_path=mp3_path)
179+
180+
if audio_path.exists():
181+
audio_path.unlink()
182+
183+
print_summary(bpms, points)
184+
print(f"\nDone! File saved to: {synced_path}")
185+
186+
187+
def main():
188+
print("=== Guitar Pro YouTube Sync ===")
189+
190+
config = load_config()
191+
192+
while True:
193+
try:
194+
process_song(config)
195+
print("\n" + "-" * 40)
196+
except SystemExit:
197+
print("\nGoodbye!")
198+
break
199+
except KeyboardInterrupt:
200+
print("\n\nGoodbye!")
201+
break
202+
except Exception as e:
203+
print(f"\nUnexpected error: {e}")
204+
print("You can try another song.\n")
205+
206+
207+
if __name__ == "__main__":
208+
main()

requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
requests
2+
yt-dlp

0 commit comments

Comments
 (0)