From dc16677ff70c6c4d2aa686bb09a4747ce11477a5 Mon Sep 17 00:00:00 2001 From: Thomas Gessler Date: Sun, 14 Jul 2024 11:57:24 +0200 Subject: [PATCH 1/6] ss_extract: Option to extract duets to single file This creates a single duet-style txt file instead of separate files for each singer. In addition, the program now finds duets that have a single track outside of the TRACK tags. In that case, the singer for any given note is determined by the last seen Singer attribute in a SENTENCE tag. --- ss_extract.cc | 162 ++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 129 insertions(+), 33 deletions(-) diff --git a/ss_extract.cc b/ss_extract.cc index a347621..c290723 100644 --- a/ss_extract.cc +++ b/ss_extract.cc @@ -26,14 +26,23 @@ struct Song { fs::path path, music, vocals, video, background, cover; unsigned samplerate; double tempo; - bool pal; - Song(): samplerate(), tempo() {} + bool isDuet, pal; + Song(): samplerate(), tempo(), isDuet() {} }; #include "ss_binary.hh" +enum Singer { + SINGER1, + SINGER2, + NUM_SINGERS +}; + std::string dvdPath; std::ofstream txtfile; +std::string singerName[NUM_SINGERS]; +std::stringstream singerNotes[NUM_SINGERS]; +bool singerActive[NUM_SINGERS]; int ts = 0; int sleepts = -1; bool g_video = true; @@ -41,9 +50,11 @@ bool g_audio = true; bool g_mkvcompress = true; bool g_oggcompress = true; bool g_createtxt = true; +bool g_duet = true; void parseNote(xmlpp::Node* node) { xmlpp::Element& elem = dynamic_cast(*node); + std::stringstream notes; char type = ':'; std::string lyric = elem.get_attribute("Lyric")->get_value(); // Some extra formatting to make lyrics look better (hyphen removal & whitespace) @@ -58,15 +69,41 @@ void parseNote(xmlpp::Node* node) { if (elem.get_attribute("FreeStyle")) type = 'F'; if (elem.get_attribute("Bonus")) type = '*'; if (note) { - if (sleepts > 0) txtfile << "- " << sleepts << '\n'; + if (sleepts > 0) notes << "- " << sleepts << '\n'; sleepts = 0; - txtfile << type << ' ' << ts << ' ' << duration << ' ' << note << ' ' << lyric << '\n'; + notes << type << ' ' << ts << ' ' << duration << ' ' << note << ' ' << lyric << '\n'; } ts += duration; + + bool written = false; + for (int i = 0; i < NUM_SINGERS; ++i) { + if (singerActive[i]) { + singerNotes[i] << notes.str(); + written = true; + } + } + if (!written) + throw std::runtime_error("No singer for note"); } -void parseSentence(xmlpp::Node* node) { +void parseSentence(xmlpp::Node* node, bool withSinger) { xmlpp::Element& elem = dynamic_cast(*node); + if (withSinger) { + xmlpp::Attribute* singerAttr = elem.get_attribute("Singer"); + if (singerAttr) { + std::string singerStr = singerAttr->get_value(); + for (int i = 0; i < NUM_SINGERS; ++i) + singerActive[i] = false; + if (singerStr == "Solo 1") { + singerActive[SINGER1] = true; + } else if (singerStr == "Solo 2") { + singerActive[SINGER2] = true; + } else if (singerStr == "Group") { + singerActive[SINGER1] = true; + singerActive[SINGER2] = true; + } else throw std::runtime_error("Invalid Singer"); + } + } // FIXME: Get rid of this or use SSDom's find xmlpp::Node::PrefixNsMap nsmap; nsmap["ss"] = "http://www.singstargame.com"; @@ -76,6 +113,14 @@ void parseSentence(xmlpp::Node* node) { std::for_each(n.begin(), n.end(), parseNote); } +void parseSentenceWithSinger(xmlpp::Node* node) { + parseSentence(node, true); +} + +void parseSentenceWithoutSinger(xmlpp::Node* node) { + parseSentence(node, false); +} + struct Match { std::string left, right; Match(std::string l, std::string r): left(l), right(r) {} @@ -85,20 +130,11 @@ struct Match { } }; -void saveTxtFile(xmlpp::const_NodeSet &sentence, const fs::path &path, const Song &song, const std::string singer = "") { +void initTxtFile(const fs::path &path, const Song &song, const std::string suffix = "") { fs::path file_path; - - if( singer.empty() ) { - file_path = path / "notes.txt"; - } else { - file_path = path; - file_path /= safename(std::string("notes") + " (" + singer + ")" + ".txt"); - } + file_path = path / (std::string("notes") + suffix + ".txt"); txtfile.open(file_path.string().c_str()); - if( singer.empty() ) - txtfile << "#TITLE:" << song.title << std::endl; - else - txtfile << "#TITLE:" << song.title << " (" << singer << ")" << std::endl; + txtfile << "#TITLE:" << song.title << suffix << std::endl; txtfile << "#ARTIST:" << song.artist << std::endl; if (!song.genre.empty()) txtfile << "#GENRE:" << song.genre << std::endl; if (!song.year.empty()) txtfile << "#YEAR:" << song.year << std::endl; @@ -110,9 +146,9 @@ void saveTxtFile(xmlpp::const_NodeSet &sentence, const fs::path &path, const Son if (!song.cover.empty()) txtfile << "#COVER:" << filename(song.cover) << std::endl; //txtfile << "#BACKGROUND:background.jpg" << std::endl; txtfile << "#BPM:" << song.tempo << std::endl; - ts = 0; - sleepts = -1; - std::for_each(sentence.begin(), sentence.end(), parseSentence); +} + +void finalizeTxtFile() { txtfile << 'E' << std::endl; txtfile.close(); } @@ -154,6 +190,7 @@ struct Process { if (res == "Semiquaver") {} else if (res == "Demisemiquaver") song.tempo *= 2.0; else throw std::runtime_error("Unknown tempo resolution: " + res); + song.isDuet = e.get_attribute("Duet") && e.get_attribute("Duet")->get_value() == "Yes"; } fs::create_directories(path); remove = path; @@ -226,20 +263,76 @@ struct Process { if (g_createtxt) { std::cerr << ">>> Extracting lyrics to notes.txt" << std::endl; xmlpp::const_NodeSet sentences; - if(dom.find("/ss:MELODY/ss:SENTENCE", sentences)) { - // Sentences not inside tracks (normal songs) - std::cerr << " >>> Solo track" << std::endl; - saveTxtFile(sentences, path, song); - } else { + + if (song.isDuet) { xmlpp::const_NodeSet tracks; - if (!dom.find("/ss:MELODY/ss:TRACK", tracks)) throw std::runtime_error("Unable to find any sentences in melody XML"); - for (auto it = tracks.begin(); it != tracks.end(); ++it ) { - xmlpp::Element& elem = dynamic_cast(**it); - std::string singer = elem.get_attribute("Artist")->get_value(); - std::cerr << " >>> Track from " << singer << std::endl; - dom.find(elem, "ss:SENTENCE", sentences); - saveTxtFile(sentences, path, song, singer); + if (!dom.find("/ss:MELODY/ss:TRACK", tracks)) throw std::runtime_error("Unable to find any tracks in melody XML"); + + if (tracks.size() != NUM_SINGERS) + throw std::runtime_error("Invalid number of tracks"); + + xmlpp::Element *trackElem[NUM_SINGERS]; + + auto it = tracks.begin(); + for (int i = 0; i < NUM_SINGERS; ++i) { + trackElem[i] = dynamic_cast(*it++); + xmlpp::Attribute *artistAttr = trackElem[i]->get_attribute("Artist"); + if (!artistAttr) + throw std::runtime_error("Track without Artist"); + singerName[i] = artistAttr->get_value(); + singerActive[i] = false; } + + if(dom.find("/ss:MELODY/ss:SENTENCE", sentences)) { + std::cerr << " >>> Single-track duet" << std::endl; + + ts = 0; + sleepts = -1; + std::for_each(sentences.begin(), sentences.end(), parseSentenceWithSinger); + } else { + std::cerr << " >>> Double-track duet" << std::endl; + + for (int i = 0; i < NUM_SINGERS; ++i) { + if (!dom.find(*trackElem[i], "ss:SENTENCE", sentences)) + throw std::runtime_error("Unable to find any sentectes inside track in melody XML"); + ts = 0; + sleepts = -1; + singerActive[i] = true; + std::for_each(sentences.begin(), sentences.end(), parseSentenceWithoutSinger); + singerActive[i] = false; + } + } + if (g_duet) { + initTxtFile(path, song); + for (int i = 0; i < NUM_SINGERS; ++i) { + txtfile << "#P" << i+1 << ": " << singerName[i] << "\n"; + } + for (int i = 0; i < NUM_SINGERS; ++i) { + txtfile << "P" << i + 1 << "\n"; + txtfile << singerNotes[i].rdbuf(); + } + finalizeTxtFile(); + } else { + for (int i = 0; i < NUM_SINGERS; ++i) { + initTxtFile(path, song, " (" + singerName[i] + ")"); + txtfile << singerNotes[i].rdbuf(); + finalizeTxtFile(); + } + } + } else { + std::cerr << " >>> Solo track" << std::endl; + + if(!dom.find("/ss:MELODY/ss:SENTENCE", sentences)) throw std::runtime_error("Unable to find any sentences in melody XML"); + + ts = 0; + sleepts = -1; + for (int i = 0; i < NUM_SINGERS; ++i) + singerActive[i] = false; + singerActive[SINGER1] = true; + std::for_each(sentences.begin(), sentences.end(), parseSentenceWithoutSinger); + initTxtFile(path, song); + txtfile << singerNotes[SINGER1].rdbuf(); + finalizeTxtFile(); } } } catch (std::exception& e) { @@ -359,6 +452,7 @@ int main( int argc, char **argv) { ("video", po::value(&video)->default_value("mkv"), "specify video format (none, mkv, mpeg2)") ("audio", po::value(&audio)->default_value("ogg"), "specify audio format (none, ogg, wav)") ("txt,t", "also convert XML to notes.txt (for UltraStar compatibility)") + ("duet,d", "create single duet-mode txt file for duets") ; // Process the first flagless option as dvd, the second as song po::positional_options_description pos; @@ -400,8 +494,10 @@ int main( int argc, char **argv) { throw std::runtime_error("Invalid audio flag. Value must be {none, ogg, wav}"); } std::cerr << ">>> Using audio flag: \"" << audio << "\"" << std::endl; - g_createtxt = vm.count("txt") > 0; + g_createtxt = vm.count("txt") > 0 || vm.count("duet") > 0; + g_duet = vm.count("duet") > 0; std::cerr << ">>> Convert XML to notes.txt: " << (g_createtxt?"yes":"no") << std::endl; + std::cerr << ">>> Create single duet-mode txt file for duets: " << (g_duet?"yes":"no") << std::endl; } catch (std::exception& e) { std::cout << cmdline << std::endl; std::cout << "ERROR: " << e.what() << std::endl; From ab5add026e85cceaaa545b24e8ab9089dc56837d Mon Sep 17 00:00:00 2001 From: Thomas Gessler Date: Sun, 14 Jul 2024 22:39:56 +0200 Subject: [PATCH 2/6] ss_extract: Detect rap notes In the XML format, notes can have any combination of the Bonus, Rap, and FreeStyle attributes. It seems that all Rap notes are also FreeStyle. Since the txt format cannot combine free-style with another type, FreeStyle notes will only be marked F if they are not Bonus or Rap. --- ss_extract.cc | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/ss_extract.cc b/ss_extract.cc index c290723..e6f1829 100644 --- a/ss_extract.cc +++ b/ss_extract.cc @@ -66,8 +66,13 @@ void parseNote(xmlpp::Node* node) { } unsigned note = boost::lexical_cast(elem.get_attribute("MidiNote")->get_value().c_str()); unsigned duration = boost::lexical_cast(elem.get_attribute("Duration")->get_value().c_str()); - if (elem.get_attribute("FreeStyle")) type = 'F'; - if (elem.get_attribute("Bonus")) type = '*'; + bool rap = elem.get_attribute("Rap"); + bool golden = elem.get_attribute("Bonus"); + bool freestyle = elem.get_attribute("FreeStyle"); + if (!rap && golden) type = '*'; + else if (rap && !golden) type = 'R'; + else if (rap && golden) type = 'G'; + else if (freestyle) type = 'F'; if (note) { if (sleepts > 0) notes << "- " << sleepts << '\n'; sleepts = 0; From ef7ef1d14fbcfa5b4ec101e1762246e5d35e2bc5 Mon Sep 17 00:00:00 2001 From: Thomas Gessler Date: Mon, 15 Jul 2024 23:31:23 +0200 Subject: [PATCH 3/6] ss_extract: For tracks without Artist, use Name There is at least one song that is marked as duet and has two tracks (with notes outside the tracks), but the tracks have no Artist attribute: SingStar Party[DE]: Destiny's Child - Survivor In such a case, the Name attribute will be used as a fallback. --- ss_extract.cc | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ss_extract.cc b/ss_extract.cc index e6f1829..4e541ae 100644 --- a/ss_extract.cc +++ b/ss_extract.cc @@ -282,8 +282,11 @@ struct Process { for (int i = 0; i < NUM_SINGERS; ++i) { trackElem[i] = dynamic_cast(*it++); xmlpp::Attribute *artistAttr = trackElem[i]->get_attribute("Artist"); - if (!artistAttr) - throw std::runtime_error("Track without Artist"); + if (!artistAttr) { + artistAttr = trackElem[i]->get_attribute("Name"); + if (!artistAttr) + throw std::runtime_error("Track without Artist"); + } singerName[i] = artistAttr->get_value(); singerActive[i] = false; } From 88899df7e7ceac579436907f7505c972ddd9c3fe Mon Sep 17 00:00:00 2001 From: Thomas Gessler Date: Fri, 24 Jan 2025 23:17:21 +0100 Subject: [PATCH 4/6] ss_extract: Option to corrent pitch offset The singstar XML files contain pitch information in the form of MIDI notes. The UltraStar format uses signed integers, where 0 corresponds to MIDI note 60. This leads to an error of five octaves with the previous ss_extract behavior. --- ss_extract.cc | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ss_extract.cc b/ss_extract.cc index 4e541ae..3ae89aa 100644 --- a/ss_extract.cc +++ b/ss_extract.cc @@ -45,6 +45,7 @@ std::stringstream singerNotes[NUM_SINGERS]; bool singerActive[NUM_SINGERS]; int ts = 0; int sleepts = -1; +int pitch_offset = 0; bool g_video = true; bool g_audio = true; bool g_mkvcompress = true; @@ -76,7 +77,7 @@ void parseNote(xmlpp::Node* node) { if (note) { if (sleepts > 0) notes << "- " << sleepts << '\n'; sleepts = 0; - notes << type << ' ' << ts << ' ' << duration << ' ' << note << ' ' << lyric << '\n'; + notes << type << ' ' << ts << ' ' << duration << ' ' << note - pitch_offset << ' ' << lyric << '\n'; } ts += duration; @@ -461,6 +462,7 @@ int main( int argc, char **argv) { ("audio", po::value(&audio)->default_value("ogg"), "specify audio format (none, ogg, wav)") ("txt,t", "also convert XML to notes.txt (for UltraStar compatibility)") ("duet,d", "create single duet-mode txt file for duets") + ("offset,o", "apply correct pitch offset") ; // Process the first flagless option as dvd, the second as song po::positional_options_description pos; @@ -504,8 +506,10 @@ int main( int argc, char **argv) { std::cerr << ">>> Using audio flag: \"" << audio << "\"" << std::endl; g_createtxt = vm.count("txt") > 0 || vm.count("duet") > 0; g_duet = vm.count("duet") > 0; + pitch_offset = (vm.count("offset") > 0 ? 60 : 0); std::cerr << ">>> Convert XML to notes.txt: " << (g_createtxt?"yes":"no") << std::endl; std::cerr << ">>> Create single duet-mode txt file for duets: " << (g_duet?"yes":"no") << std::endl; + std::cerr << ">>> Apply correct pitch offset: " << (pitch_offset?"yes":"no") << std::endl; } catch (std::exception& e) { std::cout << cmdline << std::endl; std::cout << "ERROR: " << e.what() << std::endl; From b3d8daf1673a5e3af0b9becd14a7f974a81fb973 Mon Sep 17 00:00:00 2001 From: Thomas Gessler Date: Tue, 4 Feb 2025 23:12:41 +0100 Subject: [PATCH 5/6] Extract medley start and end beats The medley start and end time in seconds are extracted from the songs_*.xml files and then converted to beats. Medley start and end are also marked at the corresponding notes in the melody files, but these might be ambiguous in the case of duets. The "normal" medleys are always chosen instead of the "micro" medleys. --- ss_extract.cc | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/ss_extract.cc b/ss_extract.cc index 3ae89aa..0a14507 100644 --- a/ss_extract.cc +++ b/ss_extract.cc @@ -27,7 +27,8 @@ struct Song { unsigned samplerate; double tempo; bool isDuet, pal; - Song(): samplerate(), tempo(), isDuet() {} + double medleyStart, medleyEnd; + Song(): samplerate(), tempo(), isDuet(), medleyStart(), medleyEnd() {} }; #include "ss_binary.hh" @@ -152,6 +153,12 @@ void initTxtFile(const fs::path &path, const Song &song, const std::string suffi if (!song.cover.empty()) txtfile << "#COVER:" << filename(song.cover) << std::endl; //txtfile << "#BACKGROUND:background.jpg" << std::endl; txtfile << "#BPM:" << song.tempo << std::endl; + if (song.medleyEnd > 0) { + int start = std::round(4 * (song.tempo / 60) * song.medleyStart); + txtfile << "#MEDLEYSTARTBEAT:" << start << std::endl; + int end = std::round(4 * (song.tempo / 60) * song.medleyEnd); + txtfile << "#MEDLEYENDBEAT:" << end << std::endl; + } } void finalizeTxtFile() { @@ -441,6 +448,16 @@ struct FindSongs { if (dom.find(elem, "ss:VIDEO/@FRAME_RATE", fr)) fps = boost::lexical_cast(dynamic_cast(*fr[0]).get_value().c_str()); if (fps == 25.0) s.pal = true; + const xmlpp::Node::NodeList medleys = elem.get_children("MEDLEYS"); + if (medleys.size() > 0) { + for (auto const &mt : medleys.front()->get_children("TYPE")) { + xmlpp::Element& elem = dynamic_cast(*mt); + if (elem.get_first_child_text()->get_content() == "Normal") { + s.medleyStart = std::stod(elem.get_attribute("Start")->get_value()); + s.medleyEnd = std::stod(elem.get_attribute("End")->get_value()); + } + } + } // Store song info to songs container songs[elem.get_attribute("ID")->get_value()] = s; } From 12c4c336376eb7ace0254596f7980a2695a34e89 Mon Sep 17 00:00:00 2001 From: Thomas Gessler Date: Tue, 4 Feb 2025 23:29:55 +0100 Subject: [PATCH 6/6] Add MP3 audio and MP4 video support --- ss_extract.cc | 53 +++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/ss_extract.cc b/ss_extract.cc index 0a14507..f645a80 100644 --- a/ss_extract.cc +++ b/ss_extract.cc @@ -50,7 +50,9 @@ int pitch_offset = 0; bool g_video = true; bool g_audio = true; bool g_mkvcompress = true; +bool g_mp4compress = true; bool g_oggcompress = true; +bool g_mp3compress = true; bool g_createtxt = true; bool g_duet = true; @@ -245,6 +247,26 @@ struct Process { } } } + if (g_mp3compress) { + if( !song.music.empty() ) { + std::cerr << ">>> Compressing audio into music.mp3" << std::endl; + std::string cmd = "lame \"" + song.music.string() + "\""; + std::cerr << cmd << std::endl; + if (std::system(cmd.c_str()) == 0) { // FIXME: std::system return value is not portable + fs::remove(song.music); + song.music = path / ("music.mp3"); + } + } + if( !song.vocals.empty() ) { + std::cerr << ">>> Compressing audio into vocals.mp3" << std::endl; + std::string cmd = "lame \"" + song.vocals.string() + "\""; + std::cerr << cmd << std::endl; + if (std::system(cmd.c_str()) == 0) { // FIXME: std::system return value is not portable + fs::remove(song.vocals); + song.vocals = path / ("vocals.mp3"); + } + } + } if (g_video) { std::cerr << ">>> Extracting video" << std::endl; try { @@ -271,6 +293,15 @@ struct Process { song.video = path / "video.m4v"; } } + if (g_mp4compress) { + std::cerr << ">>> Compressing video into video.mp4" << std::endl; + std::string cmd = "ffmpeg -i \"" + (path / "video.mpg").string() + "\" -vcodec libx264 -profile main -crf 25 -threads 0 -metadata album=\"" + song.edition + "\" -metadata author=\"" + song.artist + "\" -metadata comment=\"" + song.genre + "\" -metadata title=\"" + song.title + "\" \"" + (path / "video.mp4\"").string(); + std::cerr << cmd << std::endl; + if (std::system(cmd.c_str()) == 0) { // FIXME: std::system return value is not portable + fs::remove(path / "video.mpg"); + song.video = path / "video.mp4"; + } + } } if (g_createtxt) { @@ -475,8 +506,8 @@ int main( int argc, char **argv) { ("dvd", po::value(&dvdPath), "path to Singstar DVD root") ("list,l", "list tracks only") ("song", po::value(&song), "only extract the given track (ID or partial name)") - ("video", po::value(&video)->default_value("mkv"), "specify video format (none, mkv, mpeg2)") - ("audio", po::value(&audio)->default_value("ogg"), "specify audio format (none, ogg, wav)") + ("video", po::value(&video)->default_value("mkv"), "specify video format (none, mkv, mp4, mpeg2)") + ("audio", po::value(&audio)->default_value("ogg"), "specify audio format (none, ogg, mp3, wav)") ("txt,t", "also convert XML to notes.txt (for UltraStar compatibility)") ("duet,d", "create single duet-mode txt file for duets") ("offset,o", "apply correct pitch offset") @@ -497,28 +528,42 @@ int main( int argc, char **argv) { if (video == "none") { g_video = false; g_mkvcompress = false; + g_mp4compress = false; } else if (video == "mkv") { g_video = true; g_mkvcompress = true; + g_mp4compress = false; + } else if (video == "mp4") { + g_video = true; + g_mkvcompress = false; + g_mp4compress = true; } else if (video == "mpeg2") { g_video = true; g_mkvcompress = false; + g_mp4compress = false; } else { - throw std::runtime_error("Invalid video flag. Value must be {none, mkv, mpeg2}"); + throw std::runtime_error("Invalid video flag. Value must be {none, mkv, mp4, mpeg2}"); } std::cerr << ">>> Using video flag: \"" << video << "\"" << std::endl; // Process audio flag if (audio == "none") { g_audio = false; g_oggcompress = false; + g_mp3compress = false; } else if (audio == "ogg") { g_audio = true; g_oggcompress = true; + g_mp3compress = false; + } else if (audio == "mp3") { + g_audio = true; + g_oggcompress = false; + g_mp3compress = true; } else if (audio == "wav") { g_audio = true; g_oggcompress = false; + g_mp3compress = false; } else { - throw std::runtime_error("Invalid audio flag. Value must be {none, ogg, wav}"); + throw std::runtime_error("Invalid audio flag. Value must be {none, ogg, mp3, wav}"); } std::cerr << ">>> Using audio flag: \"" << audio << "\"" << std::endl; g_createtxt = vm.count("txt") > 0 || vm.count("duet") > 0;