Spaces:
Running on Zero
Initial commit + Milestone 7: MJ-derived synth from real Thriller chorus
Browse filesMilestone 7 is the first synth where lyrics, melody and timing are all
extracted from MJ's actual Thriller chorus (vocals[120-136s] of the full
track) rather than ear-edited from SoulX's en_target example. Prior
versions (v1-v6) drifted into B minor because cumulative by-ear pitch
edits from a foreign starting key settled a whole step below MJ's C#
minor. This commit fixes the source of truth.
Pipeline (run_preproc_with_whisper.py):
vocals[120-136s] -> Demucs (already separated)
-> whisper-large w/ initial_prompt -> lyrics + word timing
-> ROSVOT -> note transcription
-> SoulX preproc -> metadata.json (data/mj_chorus_metadata.json)
-> SoulX inference -> examples/milestone7_mj_notes/generated.wav
Also includes the working "broken" recipe scripts (swap_word.py,
split_word.py, scripts/sing.sh) that established the SingerTranslator
pattern in earlier sessions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- .gitattributes +1 -0
- .gitignore +5 -0
- README.md +160 -0
- data/mj_chorus_metadata.json +16 -0
- examples/milestone7_mj_notes/generated.wav +3 -0
- run_preproc_with_whisper.py +83 -0
- scripts/sing.sh +44 -0
- split_word.py +105 -0
- swap_word.py +98 -0
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
*.wav filter=lfs diff=lfs merge=lfs -text
|
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
.DS_Store
|
| 4 |
+
examples/*.wav
|
| 5 |
+
examples/*/generated.wav
|
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SingerTranslator
|
| 2 |
+
|
| 3 |
+
Translate a *score* (lyrics + melody + voice prompt) into a *sung performance*.
|
| 4 |
+
|
| 5 |
+
Music generators invent the song. **SingerTranslator renders one you specify.**
|
| 6 |
+
You pick the lyrics, you pick the melody (MIDI/F0), you pick the voice; the
|
| 7 |
+
model produces the singing audio.
|
| 8 |
+
|
| 9 |
+
This is the user-controlled-composition workflow on top of
|
| 10 |
+
[SoulX-Singer](https://github.com/Soul-AILab/SoulX-Singer) running locally.
|
| 11 |
+
It includes the helpers and recipes we found necessary to make English work.
|
| 12 |
+
|
| 13 |
+
## Why this exists
|
| 14 |
+
|
| 15 |
+
ACE-Step v1.5 cover-gen, Voicify (Demucs+RVC+ACE-Step), and SoulX SVC mode
|
| 16 |
+
all failed to deliver "custom lyrics on a custom melody in a chosen voice".
|
| 17 |
+
The first three either had no F0 channel or locked you into the target's
|
| 18 |
+
lyrics. SoulX **SVS** mode does have F0/MIDI input — but only when used
|
| 19 |
+
locally with hand-built metadata. That's what this repo wraps.
|
| 20 |
+
|
| 21 |
+
Validated 2026-05-05: produced clean English singing of the lyric "Who says
|
| 22 |
+
you're not broken" on a chosen melody with hard-K plosive. First time the
|
| 23 |
+
pipeline clicked end-to-end.
|
| 24 |
+
|
| 25 |
+
## Layout
|
| 26 |
+
|
| 27 |
+
```
|
| 28 |
+
.
|
| 29 |
+
├── README.md — this
|
| 30 |
+
├── swap_word.py — replace word X with word Y in metadata
|
| 31 |
+
│ (auto regenerates phoneme via g2p_en)
|
| 32 |
+
│ supports --phoneme override + --duration_boost
|
| 33 |
+
├── split_word.py — replace one word slot with N consecutive slots
|
| 34 |
+
│ (used to test mid-word splits; PROVED WORSE)
|
| 35 |
+
├── scripts/
|
| 36 |
+
│ └── sing.sh — wrapper around SoulX-Singer SVS inference
|
| 37 |
+
└── examples/ — outputs we want to keep around
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
## Prerequisites
|
| 41 |
+
|
| 42 |
+
You need SoulX-Singer locally with weights downloaded. See
|
| 43 |
+
`project_english_singing_synthesis.md` in the auto-memory for the full install
|
| 44 |
+
path. Briefly:
|
| 45 |
+
|
| 46 |
+
```bash
|
| 47 |
+
cd ~/claude-code
|
| 48 |
+
git clone https://github.com/Soul-AILab/SoulX-Singer.git
|
| 49 |
+
cd SoulX-Singer
|
| 50 |
+
/Users/milhouse/.pyenv/versions/3.10.16/bin/python3.10 -m venv venv
|
| 51 |
+
venv/bin/pip install -r requirements.txt
|
| 52 |
+
mkdir pretrained_models && venv/bin/hf download Soul-AILab/SoulX-Singer \
|
| 53 |
+
--local-dir pretrained_models/SoulX-Singer
|
| 54 |
+
venv/bin/python -c "import nltk; nltk.download('averaged_perceptron_tagger_eng'); nltk.download('cmudict')"
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
CPU-only on Mac (MPS broken in the vocoder, see memory notes). ~5x realtime.
|
| 58 |
+
|
| 59 |
+
## Workflow
|
| 60 |
+
|
| 61 |
+
```
|
| 62 |
+
[ source metadata.json ]
|
| 63 |
+
|
|
| 64 |
+
| swap_word.py / split_word.py (edit lyrics, phonemes, durations)
|
| 65 |
+
|
|
| 66 |
+
v
|
| 67 |
+
[ edited metadata.json ]
|
| 68 |
+
|
|
| 69 |
+
| scripts/sing.sh (run SoulX SVS inference)
|
| 70 |
+
|
|
| 71 |
+
v
|
| 72 |
+
[ generated.wav ] a sung performance
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
## Quick start: word swap example
|
| 76 |
+
|
| 77 |
+
Take SoulX's shipped `en_target.json` (the "Who says you're not pretty" song),
|
| 78 |
+
swap "pretty" → "broken" with the hard-K recipe, and synthesize:
|
| 79 |
+
|
| 80 |
+
```bash
|
| 81 |
+
SOULX=~/claude-code/SoulX-Singer
|
| 82 |
+
PY=$SOULX/venv/bin/python
|
| 83 |
+
|
| 84 |
+
# 1. Edit the metadata (apply the triple-K plosive recipe + duration boost)
|
| 85 |
+
$PY swap_word.py \
|
| 86 |
+
--in $SOULX/example/audio/en_target.json \
|
| 87 |
+
--out /tmp/en_broken.json \
|
| 88 |
+
--old pretty --new broken \
|
| 89 |
+
--phoneme 'en_B-R-OW1-K-K-K-AH0-N' \
|
| 90 |
+
--duration_boost 0.20
|
| 91 |
+
|
| 92 |
+
# 2. Synthesize
|
| 93 |
+
scripts/sing.sh \
|
| 94 |
+
$SOULX/example/audio/en_prompt.mp3 \
|
| 95 |
+
$SOULX/example/audio/en_prompt.json \
|
| 96 |
+
/tmp/en_broken.json \
|
| 97 |
+
/tmp/sung_broken
|
| 98 |
+
|
| 99 |
+
# 3. Listen
|
| 100 |
+
afplay /tmp/sung_broken/generated.wav
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
## Recipes
|
| 104 |
+
|
| 105 |
+
### English plosives are weak — use the triple-phone trick
|
| 106 |
+
|
| 107 |
+
The model has only 70 English phonemes vs ~2700 Chinese. English K/P/T
|
| 108 |
+
articulation is poor by default. Workaround: triple the plosive in the
|
| 109 |
+
phoneme override, with a moderate duration boost.
|
| 110 |
+
|
| 111 |
+
| Word | Phoneme override |
|
| 112 |
+
|---|---|
|
| 113 |
+
| broken | `en_B-R-OW1-K-K-K-AH0-N` |
|
| 114 |
+
| pretty | `en_P-P-P-R-IH1-T-T-T-IY0` (untested but follows the pattern) |
|
| 115 |
+
| broken (verified) | tested 2026-05-05, produces hard K |
|
| 116 |
+
|
| 117 |
+
Validated trade-off (2026-05-05):
|
| 118 |
+
- Less than 3 K-phones: K is dropped or sounds soft
|
| 119 |
+
- More than 3 K-phones (4K, 5K): per-phone time falls below ~50ms, K
|
| 120 |
+
collapses to a vowel transition
|
| 121 |
+
- Boost much beyond +0.20s: K → G voicing leak (closure gets filled with
|
| 122 |
+
vocal-fold vibration from neighboring vowels — Chinese-prior unaspirated
|
| 123 |
+
stops dominate)
|
| 124 |
+
|
| 125 |
+
The sweet spot is **3 K-phones at ~56ms each** in a slot ~0.45s long.
|
| 126 |
+
|
| 127 |
+
### Sonorants are fine
|
| 128 |
+
|
| 129 |
+
Words like "lovely", "morning", "shining" come out clean without any tricks.
|
| 130 |
+
Lyric-engineer toward sonorants when you can.
|
| 131 |
+
|
| 132 |
+
### Slot-splitting is worse
|
| 133 |
+
|
| 134 |
+
`split_word.py` tested mid-word splitting like "broken" → "brok" + "ken"
|
| 135 |
+
(2 slots with K at the slot boundary). The hypothesis was that `<EOW>`/`<BOW>`
|
| 136 |
+
markers would force harder articulation. **It didn't work** — each piece
|
| 137 |
+
got too little time, model isn't trained on mid-word slot splits. Kept the
|
| 138 |
+
script around for future experimentation but the single-slot triple-phone
|
| 139 |
+
recipe is what's been validated.
|
| 140 |
+
|
| 141 |
+
## Status (2026-05-05)
|
| 142 |
+
|
| 143 |
+
- ✅ Local install verified
|
| 144 |
+
- ✅ Lyric swap + phoneme override + duration boost working (`swap_word.py`)
|
| 145 |
+
- ✅ "broken benchmark" achieved with 3K + boost20 recipe
|
| 146 |
+
- ⏳ User's voice prompt (Shana clip) — needs prompt_metadata generation
|
| 147 |
+
via SoulX preprocess pipeline (extra model downloads)
|
| 148 |
+
- ⏳ User's actual Thriller MIDI integration — currently using
|
| 149 |
+
en_target's melody as the surrogate
|
| 150 |
+
- ⏳ Generalize plosive recipe to P, T (untested)
|
| 151 |
+
|
| 152 |
+
## What it isn't
|
| 153 |
+
|
| 154 |
+
- **Not a music generator** — doesn't invent songs from prompts
|
| 155 |
+
- **Not a karaoke maker** — doesn't separate voices from existing recordings
|
| 156 |
+
- **Not a voice cloner alone** — that's what RVC and SoulX SVC do; this
|
| 157 |
+
controls more (lyrics + melody + voice, not just voice)
|
| 158 |
+
|
| 159 |
+
It's the rendering step of a composition pipeline. You bring the score,
|
| 160 |
+
SingerTranslator brings the singer.
|
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"index": "vocal_0_16000",
|
| 4 |
+
"language": "English",
|
| 5 |
+
"time": [
|
| 6 |
+
0,
|
| 7 |
+
16000
|
| 8 |
+
],
|
| 9 |
+
"duration": "0.14 0.40 0.24 1.66 0.50 0.68 0.38 0.26 0.36 0.20 0.46 0.26 0.30 0.20 0.28 0.30 0.64 0.28 0.66 0.45 0.21 1.86 0.38 0.64 0.44 0.40 0.40 0.18 0.34 0.36 0.40 0.64 0.78 0.31",
|
| 10 |
+
"text": "this is is thriller thriller thriller night and no one's gonna save you from the beast of outstrike this is is thriller thriller thriller night you're fighting for your life inside a killer this",
|
| 11 |
+
"phoneme": "en_DH-IH1-S en_IH1-Z en_IH1-Z en_TH-R-IH1-L-ER0 en_TH-R-IH1-L-ER0 en_TH-R-IH1-L-ER0 en_N-AY1-T en_AH0-N-D en_N-OW1 en_W-AH1-N-Z en_G-AA1-N-AH0 en_S-EY1-V en_Y-UW1 en_F-R-AH1-M en_DH-AH0 en_B-IY1-S-T en_AH1-V en_AW0-T-S-T-R-IH1-K en_DH-IH1-S en_IH1-Z en_IH1-Z en_TH-R-IH1-L-ER0 en_TH-R-IH1-L-ER0 en_TH-R-IH1-L-ER0 en_N-AY1-T en_Y-UH1-R en_F-AY1-T-IH0-NG en_F-AO1-R en_Y-AO1-R en_L-AY1-F en_IH0-N-S-AY1-D en_AH0 en_K-IH1-L-ER0 en_DH-IH1-S",
|
| 12 |
+
"note_pitch": "0 73 71 71 70 67 0 68 68 67 65 64 0 64 67 68 66 66 71 73 71 71 70 68 68 68 66 66 64 61 65 64 67 0",
|
| 13 |
+
"note_type": "2 2 3 2 2 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 2 2 3 2 2 2 2 2 2 2 2 2 3",
|
| 14 |
+
"f0": "413.3 428.3 429.5 389.7 354.7 0.0 0.0 0.0 0.0 0.0 508.9 493.8 469.1 475.2 504.0 526.3 538.8 545.8 549.9 551.9 551.8 551.2 552.4 555.6 557.0 552.2 537.0 518.7 507.3 495.7 490.6 488.9 488.3 487.5 488.4 491.1 495.3 497.9 497.8 495.9 494.7 495.3 497.4 498.5 498.0 496.6 495.8 497.4 500.3 502.9 502.7 499.1 497.0 497.0 500.6 503.9 504.2 500.6 497.0 496.7 497.5 497.9 496.3 492.9 489.6 488.8 489.6 487.8 481.1 472.6 0.0 426.5 407.0 393.6 378.6 354.9 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 335.8 337.7 0.0 0.0 0.0 0.0 452.0 454.0 435.6 425.7 438.5 461.8 476.8 485.3 489.5 495.8 500.6 504.1 500.5 491.4 481.4 477.2 475.4 473.1 469.6 465.4 462.2 461.3 464.6 471.7 476.8 480.0 474.5 465.4 464.7 466.1 467.6 469.6 469.4 466.0 463.1 452.3 401.4 373.8 365.9 380.5 392.0 386.8 382.6 390.6 404.1 408.6 402.1 396.3 392.6 393.3 390.3 377.5 370.8 372.1 387.5 410.4 415.1 414.3 411.8 412.8 414.4 411.0 387.9 367.5 357.7 358.2 369.7 374.3 372.8 360.1 318.6 319.7 330.3 0.0 0.0 0.0 0.0 0.0 0.0 0.0 392.1 392.2 392.9 390.1 387.7 0.0 0.0 0.0 0.0 0.0 343.3 417.7 423.8 424.2 419.3 410.2 405.4 406.5 416.3 423.0 425.9 425.7 429.7 424.2 421.8 422.2 417.6 413.9 412.3 411.4 418.8 425.0 425.3 416.9 388.1 372.2 364.3 367.8 372.0 369.8 358.4 344.4 311.2 0.0 0.0 0.0 0.0 395.3 396.0 393.4 382.7 371.9 369.7 367.4 366.4 370.3 374.0 356.9 341.6 313.8 299.6 0.0 0.0 0.0 0.0 0.0 323.4 349.1 344.7 322.6 314.7 318.3 332.4 339.3 334.9 317.7 313.6 322.0 329.3 332.2 329.0 317.2 0.0 0.0 311.3 298.9 298.2 295.3 282.7 274.3 0.0 0.0 0.0 0.0 0.0 0.0 0.0 354.5 348.4 321.4 308.3 316.8 328.9 327.9 325.3 324.8 321.3 322.5 334.7 355.3 357.8 355.7 340.3 328.2 319.2 364.5 383.1 387.4 379.2 374.9 385.6 404.3 417.5 418.7 414.7 408.9 410.3 414.1 417.4 416.8 406.8 375.0 0.0 0.0 0.0 0.0 399.3 391.7 374.5 367.4 363.5 371.2 375.5 374.9 366.3 0.0 0.0 0.0 390.9 391.3 393.5 366.8 365.5 368.4 372.3 382.3 377.9 369.1 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 387.7 399.7 396.4 382.6 361.1 360.5 370.4 382.7 380.6 365.6 354.5 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 447.9 453.2 451.7 440.2 428.4 415.7 396.2 397.4 403.3 410.1 416.4 427.5 470.2 506.4 499.8 504.5 504.8 498.2 491.6 487.5 488.3 492.3 492.2 479.2 452.8 413.8 410.7 414.7 415.8 419.9 422.8 409.3 388.9 388.9 0.0 0.0 0.0 0.0 495.0 509.7 506.6 484.9 487.7 510.4 537.0 555.8 562.7 563.9 563.0 559.4 553.1 552.4 557.8 563.1 557.1 532.0 524.0 515.5 501.1 496.4 498.4 498.3 495.2 494.6 497.2 500.4 502.3 502.4 500.1 497.1 496.7 498.3 501.1 500.7 497.3 496.6 499.6 504.1 504.0 500.4 498.0 496.1 495.1 497.0 499.4 499.1 495.9 491.7 491.1 493.5 495.0 494.2 492.0 489.3 490.4 493.5 490.4 479.8 0.0 0.0 0.0 0.0 357.6 350.2 331.5 283.4 270.1 233.5 222.5 218.5 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 428.6 446.8 441.6 437.7 452.3 473.7 488.2 492.3 494.3 496.2 493.7 484.7 465.4 440.7 434.2 445.6 458.6 460.4 456.0 452.8 460.2 476.0 482.2 482.1 476.1 460.8 443.1 418.7 421.9 439.8 454.5 464.6 469.2 469.1 465.4 462.1 457.7 437.3 376.2 377.4 415.8 427.3 423.2 414.4 411.4 417.9 425.3 423.3 417.1 403.6 397.7 391.6 371.9 361.7 364.5 382.6 407.7 415.3 416.3 416.4 417.8 420.6 413.5 388.9 368.2 363.8 374.3 380.5 373.4 357.6 336.1 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 439.6 430.4 416.6 417.8 415.7 402.3 396.8 0.0 0.0 0.0 0.0 0.0 0.0 445.1 452.5 448.6 436.9 426.8 423.3 421.0 421.2 422.2 422.0 421.3 418.0 402.8 374.8 364.0 363.4 364.5 364.2 362.5 365.3 369.3 363.4 358.3 0.0 0.0 0.0 412.9 407.2 399.1 384.1 376.5 371.5 369.7 370.4 371.5 370.9 365.8 353.7 330.0 327.7 330.8 333.4 329.4 314.5 318.7 333.7 339.0 331.4 323.0 314.6 313.2 323.6 341.7 344.9 335.5 324.7 325.0 337.5 352.1 351.9 337.9 319.1 307.3 0.0 291.4 290.9 286.4 276.3 268.2 273.5 275.8 269.5 259.9 259.7 0.0 0.0 0.0 361.5 360.6 351.1 319.5 320.5 338.3 354.1 349.4 331.9 321.0 329.7 347.8 358.4 371.1 374.0 371.4 370.0 370.2 370.3 371.4 368.4 265.7 258.4 254.3 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 329.1 330.5 337.1 339.8 333.1 324.3 322.6 319.7 321.2 328.5 331.9 328.1 301.8 262.0 258.3 266.1 277.7 278.7 275.5 274.2 276.4 281.0 280.8 271.6 247.8 237.8 0.0 0.0 0.0 274.2 275.4 275.5 276.3 273.6 318.4 309.7 0.0 0.0 397.2 404.8 405.4 392.2 381.1 382.8 382.2 381.7 384.4 388.6 385.9 372.9 343.8 318.9 320.8 329.8 337.5 333.3 321.2 319.5 324.4 327.7 319.4 287.2 257.9 0.0 0.0 0.0"
|
| 15 |
+
}
|
| 16 |
+
]
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d9766fcaab0f6fbb4e5f80c16717f4ecc9c42b797ef1006e6bef47f9a725d085
|
| 3 |
+
size 768044
|
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Run SoulX-Singer preprocess pipeline with whisper as the English ASR
|
| 2 |
+
(bypassing the NeMo dep that's broken on Mac/torch-2.2)."""
|
| 3 |
+
import argparse
|
| 4 |
+
import os
|
| 5 |
+
import re
|
| 6 |
+
import sys
|
| 7 |
+
|
| 8 |
+
sys.path.insert(0, '/Users/milhouse/claude-code/SoulX-Singer')
|
| 9 |
+
|
| 10 |
+
# IMPORTANT: monkey-patch the English ASR class BEFORE pipeline imports it
|
| 11 |
+
import preprocess.tools.lyric_transcription as lt
|
| 12 |
+
import numpy as np
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _clean_word(word: str) -> str:
|
| 16 |
+
return re.sub(r"[\?\.,:]", "", word).strip()
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
INITIAL_PROMPT = os.environ.get('WHISPER_INITIAL_PROMPT', '')
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class WhisperASREn:
|
| 23 |
+
"""Drop-in replacement for SoulX's _ASREnModel using OpenAI whisper."""
|
| 24 |
+
def __init__(self, model_path: str, device: str):
|
| 25 |
+
import whisper
|
| 26 |
+
size = os.environ.get('WHISPER_MODEL', 'large')
|
| 27 |
+
print(f'[whisper] loading model={size}')
|
| 28 |
+
self.model = whisper.load_model(size)
|
| 29 |
+
self.device = device
|
| 30 |
+
|
| 31 |
+
def process(self, wav_fn: str):
|
| 32 |
+
kwargs = dict(language='en', word_timestamps=True)
|
| 33 |
+
if INITIAL_PROMPT:
|
| 34 |
+
kwargs['initial_prompt'] = INITIAL_PROMPT
|
| 35 |
+
print(f'[whisper] using initial_prompt of {len(INITIAL_PROMPT)} chars')
|
| 36 |
+
result = self.model.transcribe(wav_fn, **kwargs)
|
| 37 |
+
print(f'[whisper] text: {result.get("text","").strip()}')
|
| 38 |
+
|
| 39 |
+
raw_words = []
|
| 40 |
+
raw_timestamps = []
|
| 41 |
+
for seg in result.get('segments', []):
|
| 42 |
+
for w in seg.get('words', []):
|
| 43 |
+
word = _clean_word(str(w.get('word', '')))
|
| 44 |
+
if not word:
|
| 45 |
+
continue
|
| 46 |
+
s = float(w.get('start', 0.0))
|
| 47 |
+
e = float(w.get('end', 0.0))
|
| 48 |
+
raw_words.append(word)
|
| 49 |
+
raw_timestamps.append([s, e])
|
| 50 |
+
|
| 51 |
+
words, durs = lt._build_words_with_gaps(raw_words, raw_timestamps, wav_fn)
|
| 52 |
+
|
| 53 |
+
f0_path = os.path.splitext(wav_fn)[0] + "_f0.npy"
|
| 54 |
+
if os.path.exists(f0_path):
|
| 55 |
+
words, durs = lt._word_dur_post_process(
|
| 56 |
+
words, durs, np.load(f0_path)
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
return words, durs
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# Patch the class reference in the module
|
| 63 |
+
lt._ASREnModel = WhisperASREn
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
# Now run the pipeline normally
|
| 67 |
+
from preprocess.pipeline import main as pipeline_main
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
if __name__ == '__main__':
|
| 71 |
+
parser = argparse.ArgumentParser()
|
| 72 |
+
parser.add_argument('--audio_path', required=True)
|
| 73 |
+
parser.add_argument('--save_dir', required=True)
|
| 74 |
+
parser.add_argument('--language', default='English')
|
| 75 |
+
parser.add_argument('--device', default='cpu')
|
| 76 |
+
parser.add_argument('--vocal_sep', default='False')
|
| 77 |
+
parser.add_argument('--max_merge_duration', type=int, default=60000)
|
| 78 |
+
parser.add_argument('--midi_transcribe', default='True')
|
| 79 |
+
args = parser.parse_args()
|
| 80 |
+
# convert string bools to bools as the pipeline expects
|
| 81 |
+
args.vocal_sep = args.vocal_sep.lower() in ('true', '1', 'yes')
|
| 82 |
+
args.midi_transcribe = args.midi_transcribe.lower() in ('true', '1', 'yes')
|
| 83 |
+
pipeline_main(args)
|
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Wrapper around SoulX-Singer SVS inference. Picks reasonable defaults for
|
| 3 |
+
# the SingerTranslator workflow on Mac (CPU, no fp16, auto pitch shift).
|
| 4 |
+
#
|
| 5 |
+
# Usage:
|
| 6 |
+
# scripts/sing.sh <prompt_wav> <prompt_metadata> <target_metadata> <output_dir>
|
| 7 |
+
#
|
| 8 |
+
# Example:
|
| 9 |
+
# scripts/sing.sh \
|
| 10 |
+
# /path/to/SoulX-Singer/example/audio/en_prompt.mp3 \
|
| 11 |
+
# /path/to/SoulX-Singer/example/audio/en_prompt.json \
|
| 12 |
+
# /tmp/my_target.json \
|
| 13 |
+
# /tmp/sung_output
|
| 14 |
+
set -euo pipefail
|
| 15 |
+
|
| 16 |
+
if [[ $# -lt 4 ]]; then
|
| 17 |
+
echo "Usage: $0 <prompt_wav> <prompt_metadata.json> <target_metadata.json> <output_dir>" >&2
|
| 18 |
+
exit 2
|
| 19 |
+
fi
|
| 20 |
+
|
| 21 |
+
PROMPT_WAV="$1"
|
| 22 |
+
PROMPT_META="$2"
|
| 23 |
+
TARGET_META="$3"
|
| 24 |
+
OUT_DIR="$4"
|
| 25 |
+
|
| 26 |
+
SOULX_ROOT="${SOULX_ROOT:-/Users/milhouse/claude-code/SoulX-Singer}"
|
| 27 |
+
PYBIN="$SOULX_ROOT/venv/bin/python"
|
| 28 |
+
|
| 29 |
+
cd "$SOULX_ROOT"
|
| 30 |
+
# IMPORTANT: --control score uses note_pitch + note_type from metadata.
|
| 31 |
+
# Default 'melody' uses frame-level f0 instead, ignoring note_pitch edits.
|
| 32 |
+
# For SingerTranslator surgery on melody, score is the right mode.
|
| 33 |
+
PYTHONPATH=. "$PYBIN" -m cli.inference \
|
| 34 |
+
--device cpu \
|
| 35 |
+
--control score \
|
| 36 |
+
--model_path pretrained_models/SoulX-Singer/model.pt \
|
| 37 |
+
--config soulxsinger/config/soulxsinger.yaml \
|
| 38 |
+
--prompt_wav_path "$PROMPT_WAV" \
|
| 39 |
+
--prompt_metadata_path "$PROMPT_META" \
|
| 40 |
+
--target_metadata_path "$TARGET_META" \
|
| 41 |
+
--phoneset_path soulxsinger/utils/phoneme/phone_set.json \
|
| 42 |
+
--save_dir "$OUT_DIR" \
|
| 43 |
+
--auto_shift \
|
| 44 |
+
--pitch_shift 0
|
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Replace each occurrence of a word with N consecutive slots, each
|
| 2 |
+
carrying its own phoneme, duration share, and a copy of the note_pitch.
|
| 3 |
+
|
| 4 |
+
Goal: force the model to articulate at word boundaries by putting a
|
| 5 |
+
plosive (K, P, T) at the end of slot A and the start of slot B.
|
| 6 |
+
|
| 7 |
+
Example: "broken" → [("brok", "en_B-R-OW1-K"), ("ken", "en_K-AH0-N")]
|
| 8 |
+
|
| 9 |
+
Usage:
|
| 10 |
+
python split_word.py --in IN.json --out OUT.json --old pretty \
|
| 11 |
+
--pieces 'brok:en_B-R-OW1-K' 'ken:en_K-AH0-N' \
|
| 12 |
+
[--total-duration 0.50]
|
| 13 |
+
"""
|
| 14 |
+
import argparse
|
| 15 |
+
import json
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def split_seg(seg: dict, old: str, pieces: list[tuple[str, str]],
|
| 19 |
+
total_duration: float | None = None) -> dict:
|
| 20 |
+
text = seg['text'].split()
|
| 21 |
+
phon = seg['phoneme'].split()
|
| 22 |
+
durs = [float(x) for x in seg['duration'].split()]
|
| 23 |
+
pitches = seg['note_pitch'].split()
|
| 24 |
+
types = seg['note_type'].split()
|
| 25 |
+
n = len(text)
|
| 26 |
+
assert len(phon) == n == len(durs) == len(pitches) == len(types)
|
| 27 |
+
|
| 28 |
+
new_text, new_phon, new_durs, new_pitches, new_types = [], [], [], [], []
|
| 29 |
+
n_split = 0
|
| 30 |
+
|
| 31 |
+
for i in range(n):
|
| 32 |
+
if text[i].lower() == old.lower():
|
| 33 |
+
# Determine the duration to redistribute. Either fixed (--total-duration)
|
| 34 |
+
# or original duration of this slot, possibly augmented by stealing from
|
| 35 |
+
# the *next* <SP> rest.
|
| 36 |
+
target_dur = total_duration or durs[i]
|
| 37 |
+
steal_idx = None
|
| 38 |
+
if total_duration is not None and total_duration > durs[i]:
|
| 39 |
+
want = total_duration - durs[i]
|
| 40 |
+
if i + 1 < n and text[i + 1] == '<SP>' and durs[i + 1] > want + 0.05:
|
| 41 |
+
steal_idx = i + 1
|
| 42 |
+
durs[i + 1] -= want
|
| 43 |
+
print(f" slot {i}: stole {want:.2f}s from <SP> rest at {i+1}")
|
| 44 |
+
else:
|
| 45 |
+
print(f" WARN slot {i}: cannot reach {total_duration:.2f}s (no slack)")
|
| 46 |
+
target_dur = durs[i]
|
| 47 |
+
per_piece = target_dur / len(pieces)
|
| 48 |
+
for j, (piece_text, piece_phon) in enumerate(pieces):
|
| 49 |
+
new_text.append(piece_text)
|
| 50 |
+
new_phon.append(piece_phon)
|
| 51 |
+
new_durs.append(per_piece)
|
| 52 |
+
new_pitches.append(pitches[i]) # same pitch for all pieces
|
| 53 |
+
# Articulation hints: 1=onset on first piece, 3=end on last, 2=mid sustain
|
| 54 |
+
if len(pieces) == 1:
|
| 55 |
+
new_types.append(types[i])
|
| 56 |
+
elif j == 0:
|
| 57 |
+
new_types.append('1')
|
| 58 |
+
elif j == len(pieces) - 1:
|
| 59 |
+
new_types.append('3')
|
| 60 |
+
else:
|
| 61 |
+
new_types.append('2')
|
| 62 |
+
n_split += 1
|
| 63 |
+
else:
|
| 64 |
+
new_text.append(text[i])
|
| 65 |
+
new_phon.append(phon[i])
|
| 66 |
+
new_durs.append(durs[i])
|
| 67 |
+
new_pitches.append(pitches[i])
|
| 68 |
+
new_types.append(types[i])
|
| 69 |
+
|
| 70 |
+
print(f" split {n_split} occurrence(s) of '{old}' into {len(pieces)} pieces")
|
| 71 |
+
print(f" new slot count: {len(new_text)} (was {n})")
|
| 72 |
+
|
| 73 |
+
out = dict(seg)
|
| 74 |
+
out['text'] = ' '.join(new_text)
|
| 75 |
+
out['phoneme'] = ' '.join(new_phon)
|
| 76 |
+
out['duration'] = ' '.join(f"{d:.2f}" for d in new_durs)
|
| 77 |
+
out['note_pitch'] = ' '.join(new_pitches)
|
| 78 |
+
out['note_type'] = ' '.join(new_types)
|
| 79 |
+
return out
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def main():
|
| 83 |
+
ap = argparse.ArgumentParser()
|
| 84 |
+
ap.add_argument('--in', dest='inp', required=True)
|
| 85 |
+
ap.add_argument('--out', dest='out', required=True)
|
| 86 |
+
ap.add_argument('--old', required=True)
|
| 87 |
+
ap.add_argument('--pieces', nargs='+', required=True,
|
| 88 |
+
help="Each piece as 'text:phoneme', e.g. 'brok:en_B-R-OW1-K'")
|
| 89 |
+
ap.add_argument('--total-duration', type=float, default=None,
|
| 90 |
+
help="Force total slot duration (steals from next <SP> rest)")
|
| 91 |
+
args = ap.parse_args()
|
| 92 |
+
|
| 93 |
+
pieces = []
|
| 94 |
+
for spec in args.pieces:
|
| 95 |
+
text, _, phon = spec.partition(':')
|
| 96 |
+
pieces.append((text, phon))
|
| 97 |
+
|
| 98 |
+
data = json.load(open(args.inp))
|
| 99 |
+
edited = [split_seg(s, args.old, pieces, args.total_duration) for s in data]
|
| 100 |
+
json.dump(edited, open(args.out, 'w'), ensure_ascii=False, indent=2)
|
| 101 |
+
print(f"\nWrote {args.out}")
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
if __name__ == '__main__':
|
| 105 |
+
main()
|
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Surgical lyric edit: take a SoulX-Singer metadata JSON, replace one
|
| 2 |
+
word everywhere with another, regenerate that word's phoneme group via
|
| 3 |
+
g2p_en, and save. All other fields (note_pitch, note_type, duration, f0,
|
| 4 |
+
time) are preserved exactly so we test ONLY the lyric/phoneme change.
|
| 5 |
+
|
| 6 |
+
Usage:
|
| 7 |
+
python swap_word.py --in <in.json> --out <out.json> \
|
| 8 |
+
--old pretty --new broken
|
| 9 |
+
"""
|
| 10 |
+
import argparse
|
| 11 |
+
import json
|
| 12 |
+
from g2p_en import G2p
|
| 13 |
+
|
| 14 |
+
g2p = G2p()
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def english_phoneme(word: str) -> str:
|
| 18 |
+
"""Convert one English word to SoulX-Singer's phoneme token format,
|
| 19 |
+
e.g. 'pretty' -> 'en_P-R-IH1-T-IY0'."""
|
| 20 |
+
phones = [p for p in g2p(word) if p not in (' ',)]
|
| 21 |
+
return 'en_' + '-'.join(phones)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def swap(seg: dict, old: str, new: str,
|
| 25 |
+
phoneme_override: str | None = None,
|
| 26 |
+
duration_boost: float = 0.0) -> dict:
|
| 27 |
+
text_tokens = seg['text'].split()
|
| 28 |
+
phon_tokens = seg['phoneme'].split()
|
| 29 |
+
if len(text_tokens) != len(phon_tokens):
|
| 30 |
+
raise ValueError(
|
| 31 |
+
f"text/phoneme token-count mismatch: "
|
| 32 |
+
f"{len(text_tokens)} vs {len(phon_tokens)}"
|
| 33 |
+
)
|
| 34 |
+
new_phon = phoneme_override or english_phoneme(new)
|
| 35 |
+
print(f" '{new}' -> {new_phon}{' (override)' if phoneme_override else ''}")
|
| 36 |
+
|
| 37 |
+
duration_tokens = seg.get('duration', '').split()
|
| 38 |
+
has_dur = len(duration_tokens) == len(text_tokens)
|
| 39 |
+
|
| 40 |
+
n_swapped = 0
|
| 41 |
+
swapped_indices = []
|
| 42 |
+
for i, t in enumerate(text_tokens):
|
| 43 |
+
if t.lower() == old.lower():
|
| 44 |
+
text_tokens[i] = new
|
| 45 |
+
phon_tokens[i] = new_phon
|
| 46 |
+
swapped_indices.append(i)
|
| 47 |
+
n_swapped += 1
|
| 48 |
+
print(f" swapped {n_swapped} occurrence(s) of '{old}' -> '{new}'")
|
| 49 |
+
|
| 50 |
+
if duration_boost and has_dur:
|
| 51 |
+
for i in swapped_indices:
|
| 52 |
+
old_d = float(duration_tokens[i])
|
| 53 |
+
# Steal from the next <SP> rest if available, else previous
|
| 54 |
+
steal_from = None
|
| 55 |
+
for j in (i + 1, i - 1):
|
| 56 |
+
if 0 <= j < len(text_tokens) and text_tokens[j] == '<SP>':
|
| 57 |
+
rest_d = float(duration_tokens[j])
|
| 58 |
+
if rest_d > duration_boost + 0.05:
|
| 59 |
+
steal_from = j
|
| 60 |
+
break
|
| 61 |
+
if steal_from is None:
|
| 62 |
+
print(f" WARN no neighboring <SP> with enough slack at index {i}; skipping boost")
|
| 63 |
+
continue
|
| 64 |
+
duration_tokens[i] = f"{old_d + duration_boost:.2f}"
|
| 65 |
+
duration_tokens[steal_from] = f"{float(duration_tokens[steal_from]) - duration_boost:.2f}"
|
| 66 |
+
print(f" index {i}: dur {old_d:.2f}s + {duration_boost:.2f}s "
|
| 67 |
+
f"(stole from <SP> at index {steal_from})")
|
| 68 |
+
|
| 69 |
+
out = dict(seg)
|
| 70 |
+
out['text'] = ' '.join(text_tokens)
|
| 71 |
+
out['phoneme'] = ' '.join(phon_tokens)
|
| 72 |
+
if duration_boost and has_dur:
|
| 73 |
+
out['duration'] = ' '.join(duration_tokens)
|
| 74 |
+
return out
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def main():
|
| 78 |
+
ap = argparse.ArgumentParser()
|
| 79 |
+
ap.add_argument('--in', dest='inp', required=True)
|
| 80 |
+
ap.add_argument('--out', dest='out', required=True)
|
| 81 |
+
ap.add_argument('--old', required=True)
|
| 82 |
+
ap.add_argument('--new', required=True)
|
| 83 |
+
ap.add_argument('--phoneme', default=None,
|
| 84 |
+
help="Override the auto-generated phoneme, e.g. 'en_B-R-OW1-K-K-AH0-N'")
|
| 85 |
+
ap.add_argument('--duration_boost', type=float, default=0.0,
|
| 86 |
+
help='Add N seconds to swapped-word slot, stealing from a neighboring <SP> rest')
|
| 87 |
+
args = ap.parse_args()
|
| 88 |
+
|
| 89 |
+
data = json.load(open(args.inp))
|
| 90 |
+
edited = [swap(s, args.old, args.new,
|
| 91 |
+
phoneme_override=args.phoneme,
|
| 92 |
+
duration_boost=args.duration_boost) for s in data]
|
| 93 |
+
json.dump(edited, open(args.out, 'w'), ensure_ascii=False, indent=2)
|
| 94 |
+
print(f"\nWrote {args.out}")
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
if __name__ == '__main__':
|
| 98 |
+
main()
|