diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3cf0e17 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,90 @@ +# 0.12.0 +* Add support for Simai ex notes written as "ex" instead of just "x". [Reported] +* Force CRLF for Sdt and Ma2 export. +* Fixed bug when a note and bpm change happens at the same time causing gap to be incorrectly computed. [Report: Kyan-pasu] +* Handle division by zero in Simai chart duration parsing. [Reported] +* Refactored Simai chart export solving the following bugs: + * Simai divisors ("{}") sometimes don't appear at the beginning after the initial bpm declaration. [Report: Kyan-pasu] + * Incorrect computation in `get_measure_computation` function. [Report: Kyan-pasu] + * Better handling of float arithmetic. +* Fix bug that causes a slide note to ignore pseudo each modifier. [Reported] +* Fix error in prepending the first 0x10 bytes in chart and db encryption. +* Handle case when decrypted chart or database doesn't have gzip magic number. +* Fix bug in decryption when padding is 0. +* Added more tests and documentation. + +# 0.11.0 +* Rewritten lark grammar to utilize LALR parser for around 50% speed improvement. +* Tweaked how debug information is presented. + +# 0.10.1 +* Added debug information when parsing Simai charts and files. +* Minor changes to Simai class. + +# 0.10.0 +* Added support for simai pseudo each \`. Current implementation is to offset the succeeding note by 1/384 (384 is ma2's default resolution.) [Reported] +* Added support for simai notes with 0 positions (e.g. 3/0, 0>0[4:1]). Current implementation is to ignore such notes. [Reported] +* Added support for simai touch notes with regions A and D. Current implementation is to ignore such notes. [Reported] +* Added support for hold notes where the modifier is first (e.g. 3xh, Cfh). [Reported] +* Ma2 parsing no longer ignores MET events. +* Replaced the word zone with region in touch notes. I used to refer to DX's C, B, and E touch regions as zones. I have since changed them. + +# 0.9.0 +* Rewritten offset functions to accept inputs in terms of seconds and fractions of measures: "0.5s", "2s", or "1/64" +* Fixed offset not taking into account BPM changes [Report: Kyan-pasu] +* Fixed erroneous check in after_next_measure duration check in `get_rest` in simai module [Reported] +* Add suppport for integer values for simai `&first` fields [Reported] +* Fix simai touch notes parsing. [Reported] +* Removed deprecated MaiFinaleCrypt class. + +# 0.8.0 +* Rewritten Ma2 parsing to be more flexible for future versions of the format. +* Added support for Ma2 version 1.02.00 [Report: StonerSto]. +* Fixed `add_touch_hold` and `del_touch_hold` not using `THO` key for updating note statistics. +* Added ma2 parsing tests. +* Added \_\_main\_\_.py to allow `python -m maiconverter arguments here` + +# 0.7.1 +* Fixed ma2 parsing of ex star notes [Report: StonerSto]. + +# 0.7.0 +* Added error handling in parallel Simai chart parsing for Windows [Report: Kurimu Pantsu]. +* Added support for simai hold notes that don't have duration [Report: Kurimu Pantsu]. +* More tests are added for Simai file parsing. +* Added `parse_file_str` for Simai module to parse Simai files in string format rather than opening a file. +* Added `finale_encrypt` and `finale_decrypt` to reduce repetition in code and provide users a way to encrypt/decrypt strings by themselves. + +# 0.6.5 +* Added two more fields specific to Maipad plus: `demo_seek` and `demo_len` [Report: Kurimu Pantsu]. Current implementation is to ignore these fields. + +# 0.6.2 - 0.6.4 +* Various bug fixes in Simai file parsing and Simai chart exporting [Report: Kurimu Pantsu]. +* Command-line tool now uses the new finale crypt functions instead of the soon-to-be removed `MaiFinaleCrypt`. +* Updated neglected tests module with more tests coming. + +# 0.6.1 +* Added the old scripts back in the new `scripts` folder. + +# 0.6.0 +Added four functions in MaiCrypt package: `finale_db_encrypt`, `finale_db_decrypt`, `finale_chart_encrypt`, and `finale_chart_decrypt`. The chart functions are simply the old `MaiFinaleCrypt` class turned into functions. While the db functions are more suited for encrypting and decrypting Finale's database files. Included in the new db functions are easy handling of UTF-16. In accordance with this, the `MaiFinaleCrypt` class is now pending deprecation and will be removed in 0.9.0. + +`del_slide` methods in all three chart classes now require a third parameter, `end_position`, to prevent deleting multiple slides that start at the same button and measure. + +# 0.5.0 +Most of the changes were made to make making charts for the three formats easier and consistent across all the formats. + +Added del methods for deleting notes for all three chart classes. Breaking changes are made to Ma2 and SDT classes to unify the method signatures for all three classes. + +In SDT, the StarNote class and add_star method were removed and moved to the TapNote class and add_tap method. Both of them now accept an `is_star` parameter. For the TapNote class an `amount` attribute is added for star notes. A star note's `amount` is now automatically incremented when an `add_slide` method is called that has the same button position and measure. + +The `add_slide` method for SDTs no longer need a `slide_id` parameter and has since been removed. The method now automatically assigns a unique `slide_id` when creating a `SlideStart` and `SlideEnd` class. + +For Ma2 and SDT classes, the position of `pattern` and `duration` for the `add_slide` method was moved to make it more consistent with other add methods. To avoid code from being broken please use keyword arguments for your programs. + +For Simai parallel processing, chunksizes are now based on the number of fragments divided by the amount of available CPU. + +# 0.4.0 +Added parrellism in Simai fragments parsing for (small) speed improvements. Ongoing debloat of the SimaiChart class and fix PyLint and MyPy warnings and errors. + +# 0.3.3 +First semi-public release of MaiConverter-Private. Has encrypt/decrypt, S\*T, Ma2, and 3Simai support. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..9bd0aed --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,5 @@ +include CHANGELOG.md +include README.md +include LICENSE +include how_to_make_charts.md +recursive-include *.lark diff --git a/README.md b/README.md index cd35da7..84ae5c7 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,117 @@ # MaiConverter -Converts standard 3simai files to sdt files so you can play custom charts in your favorite touchlaundry machine. Python done quick and dirty. - -Rewrite now includes a proper lark parser for simai and should handle more 3simai quirks. Conversion logic has also been completely rewritten to be more accurate and maintainable. As such a new s\*t to simai converter has been added. +A Python program for parsing and converting Maimai charts. Made up of two parts: +* An importable package for parsing, creating, exporting, and converting SDT, Ma2, and 3Simai charts. And +* a commandline script for parsing and converting the 3 formats. If you're not familiar with these file formats, then you can read about sdt files [here](https://listed.to/@donmai/18173/the-four-chart-formats-of-maimai-classic) and simai files [here](https://w.atwiki.jp/simai/pages/25.html). You can read about my blog post about this [here](https://listed.to/@donmai/18284/newly-released-simai-to-sdt-converter) -If you're interested in anything maimai modding related, then go join [MaiMai TEA](https://discord.gg/82UR3e2akE) in Discord. - # Dependencies * [Pycryptodome](https://pypi.org/project/pycryptodome) * [Lark](https://pypi.org/project/lark-parser) -# Usage -## simai_to_sdt.py -Converts a simai file or a directory containing simai files to sdt. +# Commandline +The command-line script, installed as part of the package, can parse, convert, encrypt, or decrypt Maimai chart formats. The general form is: + +```maiconverter COMMAND /path/to/file/or/directory``` + +`COMMAND` can be the following, with descriptions later: +* encrypt +* decrypt +* ma2tosdt +* ma2tosimai +* sdttoma2 +* sdttosimai +* simaifiletoma2 +* simaifiletosdt +* simaitoma2 +* simaitosdt + +The second positional argument is the path to a chart file or directory. If given a directory, it will convert all relevant files found in the directory. + +The program will save all converted file in the "output" folder in the input file's parent directory or the input directory. It will make an "output" folder if there is no existing folder. + +## encrypt, decrypt + +These commands will either encrypt an S\*T chart file to their equivalent S\*B file or vice versa. +**Requires**: -k or --key parameter followed by a hexadecimal AES key. The program can encrypt or decrypt a **table** by adding a -db or --database toggle parameter. + +### Example +Convert an SDT to SDB: + +```maiconverter encrypt --key 0xFEDCBA9876543210 100_songname_02.sdt``` + +Convert an SCB to SCT: + +```maiconverter decrypt --key 0xFEDCBA9876543210 252_donmaime_05.scb``` + +Decrypts an encrypted table: + +```maiconverter decrypt --key 0xFEDCBA9876543210 mmtablename.bin``` + +## sdttoma2, sdttosimai + +These commands will either convert an S\*T file to ma2 or Simai, respectively. **Requires** -b or --bpm parameter followed by the song's BPM as either an int or float. +**Note**: For sdttosimai, it does not produce a complete Simai file. sdttosimai only generates a Simai chart. + +### Example +Convert a 200 bpm SRT file to Simai: + +```maiconverter sdttosimai --bpm 200 300_segapls.srt``` + +Convert a 130 bpm SDT file to Ma2: + +```maiconverter sdttoma2 --bpm 130 301_dontsue.sdt``` + +## ma2tosdt, ma2tosimai + +These commands will either convert a Ma2 file to SDT or Simai, respectively. +**Note**: For ma2tosimai, it does not produce a complete Simai file. ma2tosimai only generates a Simai chart. + +### Example +Convert a Ma2 file to SDT: + +```maiconverter ma2tosdt 000404_02.ma2``` + +Convert a Ma2 file to Simai: + +```maiconverter ma2tosimai 001401_04.ma2``` -**NOTE**: This is a proof of concept and does **not** accept a complete simai file. The input file should only contain the chart for one difficulty with no "&inote_=" or any simai fields. +## simaitoma2, simaitosdt -All output files are stored in 'output' folder in the same directory as the input. Unless output directory is specified by using the --output or -o parameter. Touch notes can be converted by adding the --convert-touch or -ct parameter. You can offset all the notes by adding the --delay or -d parameter. +These commands convert a text file containing only a Simai chart file to either a Ma2 or SDT, respectively. -```python simai_to_sdt.py /path/to/file/or/directory``` +## simaifiletoma2, simaifiletosdt -## sxt_to_simai.py -Converts an s\*t file or a directory containing s\*t files to simai. +These commands differ from the previous by parsing an entire maidata.txt. All charts are individually converted to a Ma2 or SDT, respectively. -**NOTE**: This is a proof of concept and does **not** produce a complete simai file. The output file only contains the chart for one difficulty with no "&inote_=" or any simai fields. +# Misc commandline arguments +## -o, --output +Specify an output directory, or it defaults to the input directory. -All output files are stored in 'output' folder in the same directory as the input. Unless output directory is specified by using the --output or -o parameter. You need to specify the bpm of the song by using the `--bpm` parameter. You can offset all the notes by adding the --delay or -d parameter. +## -d, --delay +If you want to apply an offset to every converted chart's notes, you can do so using this argument. It accepts both negative and positive offsets in terms of measures. -```python sxt_to_simai.py --bpm 120 /path/to/file/or/directory``` +## -ct, --convert_touch +If converting from Ma2 or Simai to SDT, you can add this toggle to (naively) convert touch notes to regular tap and hold notes. Useful when you want to manually convert touch notes to tap and hold notes. You just need to modify the note's button, no need to figure out the timing. -## encrypt_decrypt.py -Encrypts and decrypts finale files. S\*T files are converted to S\*B files and vice versa. Database files can be encrypted and decrypted by using the --database or -db parameter. +## -md, --max-divisor +Sets the max Simai divisor ("{}") that is allowed when exporting a Simai chart. Set it to a low number like 128, should you want a more readable output. Defaults to 1000. -All output files are stored in 'output' folder in the same directory as the input. Unless output directory is specified by using the --output or -o parameter +# Python package +If you installed the wheel file, you could import the program like a standard Python package. If you want to make a chart maker or GUI frontend for this converter, please use it. See `how_to_make_charts.md` for an introductory guide on using MaiConverter for chart making. There is also (incomplete) documentation for classes and functions in the package. See licensing below. -### Encrypting -```python encrypt_decrypt.py encrypt 'AES KEY HERE IN HEX' /path/to/file/or/directory``` +# TODOS +* Documentation +* Do all the `TODO`s scattered in the package +* Reduce jank -### Decrypting -```python encrypt_decrypt.py decrypt 'AES KEY HERE IN HEX' /path/to/file/or/directory``` +# Contact +If you have questions or bug reports, send me a DM or ping me at MaiTea Discord server. -# TODOs -Contributions are welcome and appreciated just make sure to format using Black. -* simai_to_sdt.py should accept an actual simai file with simai fields (e.g. '&title=', '&inote_=') -* Do `TODO`s scattered in the library especially in the lark file. +* Discord: donmai#1493 +* Twitter: @donmai_me +* GitHub: donmai-me +* Listed.to: @donmai # License -This is an open-sourced application licensed under the [MIT License](https://github.com/donmai-me/MaiConverter/blob/master/LICENSE) +This is an open-sourced application licensed under the MIT License diff --git a/how_to_make_charts.md b/how_to_make_charts.md new file mode 100644 index 0000000..33edd7e --- /dev/null +++ b/how_to_make_charts.md @@ -0,0 +1,92 @@ +# Introduction +Are you a depraved charter tired of making Simai charts by hand on your text editor? Or are you a new Maimai charter that feels intimidated by Simai's convoluted format? Do you want to focus on writing charts by adding notes with only the required information? Suppose you don't mind using the command line. In that case, you can use MaiConverter to make and export your Simai (and other chart formats) easily. + +> Note: This isn't a full documentation/walkthough of every feature in the SimaiChart class in MaiConverter. You are encouraged to read the source files to get a better understanding of MaiConverter (and so you can help me document the code.) + +# Import your existing chart +To import an existing chart, strip all Simai metadata until you're left with only the chart text. One chart difficulty at a time and no `&inote=`s. Save this as a separate file and import it to Simai. + +```python +>>> from maiconverter.simai import SimaiChart +>>> with open("simai_chart.txt", "r") as f: +... text = f.read() +... +>>> simai = SimaiChart.from_str(text) +``` + +Suppose there are no errors during parsing. Then you're left with a SimaiChart instance that has all your work imported. + +# Chart from scratch +If you want to start from scratch, then import SimaiChart and make an instance. + +```python +>>> from maiconverter.simai import SimaiChart +>>> simai = SimaiChart() +``` + +# Setting BPMs +If you're starting from scratch, you should first set your starting BPM. First, specify the measure the BPM takes effect, then the BPM. Let's define the starting BPM as 220: + +```python +>>> simai.set_bpm(1.0, 220) +``` + +Should you change your mind, you can use `set_bpm` again, and it will automatically overwrite the previously set BPM. + +```python +>>> simai.set_bpm(1.0, 300) +``` + +You can remove a BPM change by using `del_bpm`, providing only the measure of the BPM change. + +```python +# Change BPM to 200 at measure 13 +>>> simai.set_bpm(13, 200.0) +# Oops we meant measure 14 +>>> simai.del_bpm(13) +>>> simai.set_bpm(14, 200) +``` + +# Adding (and deleting) notes +**NOTE**: Although the Simai buttons and touch screen locations start at 1 and end at 8, MaiConverter begins at 0 and ends at 7. Why? Because I'm a programmer, and the arcade chart formats also start at 0. MaiConverter automatically adds 1 to every button when exporting. + +```python +# Add an ex tap note for first measure in button 0 and a hold note at measure 1.5 at button 5 for 0.25 measures +>>> simai.add_tap(1.0, 0, is_ex=True) +>>> simai.add_hold(1.5, 5, 0.25) +... +# Add a > slide at measure 50.5 from button 5 to 1 with a duration of 0.75 measures. +>>> simai.add_tap(50.5, 5, is_star=True) +>>> simai.add_slide(50.5, 5, 1, 0.75, ">") +# Add a touch tap note in measure 70, at E5 +>>> simai.add_touch_tap(70, 5, "E") +# Add a firework touch hold note in measure 70.5, at C with a duration of 1 measure. +>>> simai.add_touch_hold(70.5, 0, "C", 1, is_firework=True) +``` + +If you don't know the order of arguments of an `add` function, then invoke the function using keywords. Invoking an `add` function with keyworded arguments is recommended for frontend programs. + +```python +# Add an ex tap note for first measure in button 0 and a hold note at measure 1.5 at button 5 for 0.25 measures +>>> simai.add_tap(measure=1.0, position=0, is_ex=True) +>>> simai.add_hold(measure=1.5, position=5, duration=0.25) +... +# Add a > slide at measure 50.5 from button 5 to 1 with a duration of 0.75 measures. +>>> simai.add_tap(measure=50.5, position=5, is_star=True) +>>> simai.add_slide(measure=50.5, start_postion=5, end_position=1, duration=0.75, pattern=">") +# Add a touch tap note in measure 70, at E5 +>>> simai.add_touch_tap(measure=70, position=5, zone="E") +# Add a firework touch hold note in measure 70.5, at C with a duration of 1 measure. +>>> simai.add_touch_hold(measure=70.5, position=0, zone="C", duration=1, is_firework=True) +``` + +For every `add_tap` and `add_slide` functions, there are corresponding `del_tap` and `del_slde` functions. The only parameters that the del functions need are the measure and button where a note happened. For `del_touch_tap` and `del_touch_hold` functions, the measure, location, and touch zone are required. + +```python +# Delete tap note at measure 6.125 at button 0 +>>> simai.delete_tap(6.125, 0) +# Delete hold note starting at measure 12 at button 5 +>>> simai.delete_hold(12, 5) +# Delete slide note starting at measure 9, starting at button 2, ending at button 7 +>>> simai.delete_slide(9, 2, 7) +``` diff --git a/maiconverter/__init__.py b/maiconverter/__init__.py index 9119892..6e558de 100644 --- a/maiconverter/__init__.py +++ b/maiconverter/__init__.py @@ -1,27 +1,7 @@ -from .event import EventType -from .note import NoteType -from .crypt import MaiFinaleCrypt -from .maisdt import MaiSDT, sdt_note_to_str -from .simai import ( - SimaiChart, - SimaiHoldNote, - SimaiTapNote, - SimaiSlideNote, - SimaiTouchTapNote, - SimaiTouchHoldNote, - SimaiBPM, - simai_pattern_to_int, - simai_slide_to_pattern_str, - simai_pattern_from_int, - simai_get_rest, - simai_convert_to_fragment, - simai_parse_chart, - slide_is_cw, - slide_distance, -) -from .simai_lark_parser import SimaiTransformer +from importlib.metadata import version, PackageNotFoundError +from .maiconverter import main -from .maisdttosimai import sdt_to_simai -from .simaitomaisdt import simai_to_sdt - -__version__ = "2.1.1_Public" +try: + __version__ = version("maiconverter") +except PackageNotFoundError: + __version__ = "not installed" diff --git a/maiconverter/__main__.py b/maiconverter/__main__.py new file mode 100644 index 0000000..d25bf63 --- /dev/null +++ b/maiconverter/__main__.py @@ -0,0 +1,3 @@ +from .maiconverter import main + +main() diff --git a/maiconverter/converter/__init__.py b/maiconverter/converter/__init__.py new file mode 100644 index 0000000..4d9284f --- /dev/null +++ b/maiconverter/converter/__init__.py @@ -0,0 +1,6 @@ +from .maima2tomaisdt import ma2_to_sdt +from .maima2tosimai import ma2_to_simai +from .maisdttomaima2 import sdt_to_ma2 +from .maisdttosimai import sdt_to_simai +from .simaitomaima2 import simai_to_ma2 +from .simaitomaisdt import simai_to_sdt diff --git a/maiconverter/converter/maima2tomaisdt.py b/maiconverter/converter/maima2tomaisdt.py new file mode 100644 index 0000000..c75b3c3 --- /dev/null +++ b/maiconverter/converter/maima2tomaisdt.py @@ -0,0 +1,144 @@ +from typing import Union, Callable, Sequence +import copy + +from ..event import MaiNote, NoteType +from ..maisdt import ( + MaiSDT, + HoldNote as SDTHoldNote, + SlideStartNote as SDTSlideStartNote, +) +from ..maima2 import MaiMa2, TapNote, HoldNote, SlideNote, TouchTapNote, TouchHoldNote + +ma2_slide_dict = { + "SI_": 1, + "SCL": 2, + "SCR": 3, + "SUL": 4, + "SUR": 5, + "SSL": 6, + "SSR": 7, + "SV_": 8, + "SXL": 9, + "SXR": 10, + "SLL": 11, + "SLR": 12, + "SF_": 13, +} + + +def _default_touch_converter( + sdt: MaiSDT, touch_note: Union[TouchTapNote, TouchHoldNote] +) -> None: + if isinstance(touch_note, TouchTapNote) and touch_note.region == "C": + sdt.add_tap(measure=touch_note.measure, position=0) + elif isinstance(touch_note, TouchTapNote): + sdt.add_tap(measure=touch_note.measure, position=touch_note.position) + elif isinstance(touch_note, TouchHoldNote) and touch_note.region == "C": + sdt.add_hold( + measure=touch_note.measure, position=0, duration=touch_note.duration + ) + + +def ma2_to_sdt( + ma2: MaiMa2, + touch_converter: Callable[ + [MaiSDT, Union[TouchTapNote, TouchHoldNote]], None + ] = _default_touch_converter, + convert_touch: bool = False, +) -> MaiSDT: + sdt = MaiSDT() + convert_notes(sdt, ma2.notes, touch_converter, convert_touch) + sdt.notes.sort() + + # Compensate for bpm changes + event_list = [note for note in sdt.notes + ma2.bpms] + + event_list.sort(key=lambda x: x.measure) + previous_measure = 0.0 + equivalent_current_measure = 0.0 + equivalent_notes = [] + initial_bpm = ma2.get_bpm(0) + for event in event_list: + current_measure = event.measure + current_bpm = ma2.get_bpm(current_measure) + + # current_bpm_eps takes the bpm slightly before the current measure + # for compensating gaps. This is because if there is a bpm change at + # the exact same time as a note, equivalent_gap will be wrongly affected. + if current_measure > 1.0: + current_bpm_eps = ma2.get_bpm(current_measure - 0.0001) + else: + current_bpm_eps = current_bpm + + gap = current_measure - previous_measure + scale = initial_bpm / current_bpm + scale_inf = initial_bpm / current_bpm_eps + equivalent_gap = gap * scale_inf + equivalent_current_measure += equivalent_gap + + previous_measure = current_measure + if not isinstance(event, MaiNote): + continue + + note = copy.deepcopy(event) + note.measure = equivalent_current_measure + + if isinstance(note, SDTHoldNote): + note.duration = note.duration * scale + elif isinstance(note, SDTSlideStartNote): + note.duration = note.duration * scale + note.delay = note.delay * scale + + equivalent_notes.append(note) + + sdt.notes = equivalent_notes + return sdt + + +def convert_notes( + sdt: MaiSDT, + ma2_notes: Sequence[MaiNote], + touch_converter: Callable[[MaiSDT, Union[TouchTapNote, TouchHoldNote]], None], + convert_touch: bool, +) -> None: + skipped_notes = 0 + for ma2_note in ma2_notes: + note_type = ma2_note.note_type + if isinstance(ma2_note, TapNote): + is_break = note_type in [NoteType.break_tap, NoteType.break_tap] + is_star = note_type in [NoteType.star, NoteType.break_star] + sdt.add_tap( + measure=ma2_note.measure, + position=ma2_note.position, + is_break=is_break, + is_star=is_star, + ) + elif isinstance(ma2_note, HoldNote): + # Hold, and ex hold + sdt.add_hold( + measure=ma2_note.measure, + position=ma2_note.position, + duration=ma2_note.duration, + ) + elif isinstance(ma2_note, SlideNote): + # Complete slide + # SDT slide durations include the delay unlike in ma2 + sdt.add_slide( + measure=ma2_note.measure, + start_position=ma2_note.position, + end_position=ma2_note.end_position, + duration=ma2_note.duration + ma2_note.delay, + pattern=ma2_note.pattern, + delay=ma2_note.delay, + ) + elif isinstance(ma2_note, (TouchTapNote, TouchHoldNote)): + # Touch tap, and touch hold + if convert_touch: + touch_converter(sdt, ma2_note) + else: + skipped_notes += 1 + else: + print("Warning: Unknown note type {}".format(note_type)) + + if skipped_notes > 0: + print("Skipped {} touch note(s)".format(skipped_notes)) diff --git a/maiconverter/converter/maima2tosimai.py b/maiconverter/converter/maima2tosimai.py new file mode 100644 index 0000000..5ea29b8 --- /dev/null +++ b/maiconverter/converter/maima2tosimai.py @@ -0,0 +1,84 @@ +from typing import Sequence + +from ..simai import ( + SimaiChart, + pattern_from_int, +) +from ..maima2 import MaiMa2, TapNote, HoldNote, SlideNote, TouchTapNote, TouchHoldNote +from ..event import MaiNote, NoteType + + +def ma2_to_simai(ma2: MaiMa2) -> SimaiChart: + simai_chart = SimaiChart() + + for bpm in ma2.bpms: + if bpm.measure <= 1.0: + measure = 1.0 + else: + measure = bpm.measure + + simai_chart.set_bpm(measure, bpm.bpm) + + convert_notes(simai_chart, ma2.notes) + + return simai_chart + + +def convert_notes(simai_chart: SimaiChart, ma2_notes: Sequence[MaiNote]) -> None: + for ma2_note in ma2_notes: + note_type = ma2_note.note_type + if isinstance(ma2_note, TapNote): + is_break = note_type in [NoteType.break_tap, NoteType.break_star] + is_ex = note_type in [NoteType.ex_tap, NoteType.ex_star] + is_star = note_type in [ + NoteType.star, + NoteType.break_star, + NoteType.ex_star, + ] + simai_chart.add_tap( + measure=ma2_note.measure, + position=ma2_note.position, + is_break=is_break, + is_star=is_star, + is_ex=is_ex, + ) + elif isinstance(ma2_note, HoldNote): + is_ex = note_type == NoteType.ex_hold + simai_chart.add_hold( + measure=ma2_note.measure, + position=ma2_note.position, + duration=ma2_note.duration, + is_ex=is_ex, + ) + elif isinstance(ma2_note, SlideNote): + # Ma2 slide durations does not include the delay + # like in simai + pattern = pattern_from_int( + ma2_note.pattern, ma2_note.position, ma2_note.end_position + ) + simai_chart.add_slide( + measure=ma2_note.measure, + start_position=ma2_note.position, + end_position=ma2_note.end_position, + duration=ma2_note.duration, + pattern=pattern[0], + delay=ma2_note.delay, + reflect_position=pattern[1], + ) + elif isinstance(ma2_note, TouchTapNote): + simai_chart.add_touch_tap( + measure=ma2_note.measure, + position=ma2_note.position, + region=ma2_note.region, + is_firework=ma2_note.is_firework, + ) + elif isinstance(ma2_note, TouchHoldNote): + simai_chart.add_touch_hold( + measure=ma2_note.measure, + position=ma2_note.position, + region=ma2_note.region, + duration=ma2_note.duration, + is_firework=ma2_note.is_firework, + ) + else: + print("Warning: Unknown note type {}".format(note_type)) diff --git a/maiconverter/converter/maisdttomaima2.py b/maiconverter/converter/maisdttomaima2.py new file mode 100644 index 0000000..654c34b --- /dev/null +++ b/maiconverter/converter/maisdttomaima2.py @@ -0,0 +1,83 @@ +from dataclasses import dataclass +from typing import List, Union + +from ..maima2 import MaiMa2 +from ..maisdt import MaiSDT, TapNote, HoldNote, SlideStartNote, SlideEndNote +from ..event import NoteType + + +@dataclass +class StartSlide: + measure: float + position: int + duration: float + delay: float + slide_id: int + + +def sdt_to_ma2( + sdt: MaiSDT, + initial_bpm: float, + res: int = 384, + click_res: int = 384, + fes_mode: bool = False, +) -> MaiMa2: + ma2 = MaiMa2(resolution=res, click_res=click_res, fes_mode=fes_mode) + ma2.set_bpm(0.0, initial_bpm) + ma2.set_meter(0.0, 4, 4) + convert_notes(ma2, sdt.notes) + return ma2 + + +def convert_notes( + ma2: MaiMa2, + sdt_notes: List[Union[TapNote, HoldNote, SlideStartNote, SlideEndNote]], +) -> None: + start_slides = [] + for sdt_note in sdt_notes: + note_type = sdt_note.note_type + if isinstance(sdt_note, TapNote): + is_break = note_type in [NoteType.break_tap, NoteType.break_star] + is_star = note_type in [NoteType.star, NoteType.break_star] + ma2.add_tap( + measure=sdt_note.measure, + position=sdt_note.position, + is_break=is_break, + is_star=is_star, + ) + elif isinstance(sdt_note, HoldNote): + ma2.add_hold( + measure=sdt_note.measure, + position=sdt_note.position, + duration=sdt_note.duration, + ) + elif isinstance(sdt_note, SlideStartNote): + # ma2 slide durations does not include the delay unlike in sdt + start_slide = StartSlide( + measure=sdt_note.measure, + position=sdt_note.position, + duration=sdt_note.duration - sdt_note.delay, + delay=sdt_note.delay, + slide_id=sdt_note.slide_id, + ) + start_slides.append(start_slide) + elif isinstance(sdt_note, SlideEndNote): + starts = [ + slide for slide in start_slides if slide.slide_id == sdt_note.slide_id + ] + if len(starts) == 0: + raise Exception("No corresponding start slide") + if len(starts) > 1: + raise Exception("Multiple start slides with same slide id") + + start_slide = starts[0] + ma2.add_slide( + measure=start_slide.measure, + start_position=start_slide.position, + end_position=sdt_note.position, + duration=start_slide.duration, + pattern=sdt_note.pattern, + delay=start_slide.delay, + ) + else: + print("Warning: Unknown note type {}".format(note_type)) diff --git a/maiconverter/converter/maisdttosimai.py b/maiconverter/converter/maisdttosimai.py new file mode 100644 index 0000000..2a259dc --- /dev/null +++ b/maiconverter/converter/maisdttosimai.py @@ -0,0 +1,81 @@ +from dataclasses import dataclass +from typing import List, Union + +from ..simai import SimaiChart, pattern_from_int +from ..maisdt import MaiSDT, TapNote, HoldNote, SlideStartNote, SlideEndNote +from ..event import NoteType + + +@dataclass +class StartSlide: + measure: float + position: int + duration: float + delay: float + slide_id: int + + +def sdt_to_simai(sdt: MaiSDT, initial_bpm: float) -> SimaiChart: + simai_chart = SimaiChart() + simai_chart.set_bpm(1.0, initial_bpm) + convert_notes(simai_chart, sdt.notes) + return simai_chart + + +def convert_notes( + simai_chart: SimaiChart, + sdt_notes: List[Union[TapNote, HoldNote, SlideStartNote, SlideEndNote]], +) -> None: + start_slides = [] + for sdt_note in sdt_notes: + note_type = sdt_note.note_type + if isinstance(sdt_note, TapNote): + is_break = note_type in [NoteType.break_tap, NoteType.break_star] + is_star = note_type in [NoteType.star, NoteType.break_star] + simai_chart.add_tap( + measure=sdt_note.measure, + position=sdt_note.position, + is_break=is_break, + is_star=is_star, + ) + elif isinstance(sdt_note, HoldNote): + simai_chart.add_hold( + measure=sdt_note.measure, + position=sdt_note.position, + duration=sdt_note.duration, + ) + elif isinstance(sdt_note, SlideStartNote): + # simai slide durations does not include the delay + # unlike in sdt + start_slide = StartSlide( + measure=sdt_note.measure, + position=sdt_note.position, + duration=sdt_note.duration - sdt_note.delay, + delay=sdt_note.delay, + slide_id=sdt_note.slide_id, + ) + start_slides.append(start_slide) + elif isinstance(sdt_note, SlideEndNote): + starts = [ + slide for slide in start_slides if slide.slide_id == sdt_note.slide_id + ] + if len(starts) == 0: + raise Exception("No corresponding start slide") + if len(starts) > 1: + raise Exception("Multiple start slides with same slide id") + + start_slide = starts[0] + pattern = pattern_from_int( + sdt_note.pattern, start_slide.position, sdt_note.position + ) + simai_chart.add_slide( + measure=start_slide.measure, + start_position=start_slide.position, + end_position=sdt_note.position, + duration=start_slide.duration, + pattern=pattern[0], + delay=start_slide.delay, + reflect_position=pattern[1], + ) + else: + print("Warning: Unknown note type {}".format(note_type)) diff --git a/maiconverter/converter/simaitomaima2.py b/maiconverter/converter/simaitomaima2.py new file mode 100644 index 0000000..aa810a1 --- /dev/null +++ b/maiconverter/converter/simaitomaima2.py @@ -0,0 +1,88 @@ +from typing import List + +from ..maima2 import MaiMa2 +from ..simai import ( + SimaiChart, + pattern_to_int, + TapNote, + HoldNote, + SlideNote, + TouchHoldNote, + TouchTapNote, +) +from ..event import SimaiNote, NoteType + + +def simai_to_ma2( + simai: SimaiChart, res: int = 384, click_res: int = 384, fes_mode: bool = False +) -> MaiMa2: + ma2 = MaiMa2(resolution=res, click_res=click_res, fes_mode=fes_mode) + + for bpm in simai.bpms: + if bpm.measure <= 1.0: + measure = 0.0 + else: + measure = bpm.measure + + ma2.set_bpm(measure, bpm.bpm) + + ma2.set_meter(0.0, 4, 4) + convert_notes(ma2, simai.notes) + return ma2 + + +def convert_notes(ma2: MaiMa2, simai_notes: List[SimaiNote]) -> None: + for simai_note in simai_notes: + note_type = simai_note.note_type + if isinstance(simai_note, TapNote): + is_break = note_type in [NoteType.break_tap, NoteType.break_star] + is_ex = note_type in [NoteType.ex_tap, NoteType.ex_star] + is_star = note_type in [ + NoteType.star, + NoteType.break_star, + NoteType.ex_star, + ] + ma2.add_tap( + measure=simai_note.measure, + position=simai_note.position, + is_break=is_break, + is_star=is_star, + is_ex=is_ex, + ) + elif isinstance(simai_note, HoldNote): + is_ex = note_type == NoteType.ex_hold + ma2.add_hold( + measure=simai_note.measure, + position=simai_note.position, + duration=simai_note.duration, + is_ex=is_ex, + ) + elif isinstance(simai_note, SlideNote): + # Ma2 slide durations does not include the delay + # like in simai + pattern = pattern_to_int(simai_note) + ma2.add_slide( + measure=simai_note.measure, + start_position=simai_note.position, + end_position=simai_note.end_position, + duration=simai_note.duration, + pattern=pattern, + delay=simai_note.delay, + ) + elif isinstance(simai_note, TouchTapNote): + ma2.add_touch_tap( + measure=simai_note.measure, + position=simai_note.position, + region=simai_note.region, + is_firework=simai_note.is_firework, + ) + elif isinstance(simai_note, TouchHoldNote): + ma2.add_touch_hold( + measure=simai_note.measure, + position=simai_note.position, + region=simai_note.region, + duration=simai_note.duration, + is_firework=simai_note.is_firework, + ) + else: + print("Warning: Unknown note type {}".format(note_type)) diff --git a/maiconverter/converter/simaitomaisdt.py b/maiconverter/converter/simaitomaisdt.py new file mode 100644 index 0000000..f51bdc5 --- /dev/null +++ b/maiconverter/converter/simaitomaisdt.py @@ -0,0 +1,136 @@ +from typing import Union, Callable, List +import copy + +from ..event import SimaiNote, MaiNote, NoteType +from ..maisdt import ( + MaiSDT, + HoldNote as SDTHoldNote, + SlideStartNote as SDTSlideStartNote, +) +from ..simai import ( + SimaiChart, + pattern_to_int, + TapNote, + HoldNote, + SlideNote, + TouchHoldNote, + TouchTapNote, +) + + +def _default_touch_converter( + sdt: MaiSDT, touch_note: Union[TouchTapNote, TouchHoldNote] +) -> None: + if isinstance(touch_note, TouchTapNote) and touch_note.region == "C": + sdt.add_tap(measure=touch_note.measure, position=0) + elif isinstance(touch_note, TouchTapNote): + sdt.add_tap(measure=touch_note.measure, position=touch_note.position) + elif isinstance(touch_note, TouchHoldNote) and touch_note.region == "C": + sdt.add_hold( + measure=touch_note.measure, position=0, duration=touch_note.duration + ) + + +def simai_to_sdt( + simai: SimaiChart, + touch_converter: Callable[ + [MaiSDT, Union[TouchHoldNote, TouchTapNote]], None + ] = _default_touch_converter, + convert_touch: bool = False, +) -> MaiSDT: + sdt = MaiSDT() + convert_notes(sdt, simai.notes, touch_converter, convert_touch) + sdt.notes.sort() + + event_list = sdt.notes + simai.bpms + + event_list.sort(key=lambda event: event.measure) + previous_measure = 1.0 + equivalent_current_measure = 1.0 + equivalent_notes = [] + initial_bpm = simai.get_bpm(1.0) + + for event in event_list: + current_measure = event.measure + current_bpm = simai.get_bpm(current_measure) + + # current_bpm_eps takes the bpm slightly before the current measure + # for compensating gaps. This is because if there is a bpm change at + # the exact same time as a note, equivalent_gap will be wrongly affected. + if current_measure > 1.0: + current_bpm_eps = simai.get_bpm(current_measure - 0.0001) + else: + current_bpm_eps = current_bpm + + gap = current_measure - previous_measure + scale = initial_bpm / current_bpm + scale_inf = initial_bpm / current_bpm_eps + equivalent_gap = gap * scale_inf + equivalent_current_measure += equivalent_gap + + previous_measure = current_measure + if not isinstance(event, MaiNote): + continue + + note = copy.deepcopy(event) + note.measure = equivalent_current_measure + + if isinstance(note, SDTHoldNote): + note.duration = note.duration * scale + elif isinstance(note, SDTSlideStartNote): + note.duration = note.duration * scale + note.delay = note.delay * scale + + equivalent_notes.append(note) + + sdt.notes = equivalent_notes + return sdt + + +def convert_notes( + sdt: MaiSDT, + simai_notes: List[SimaiNote], + touch_converter: Callable[[MaiSDT, Union[TouchHoldNote, TouchTapNote]], None], + convert_touch: bool, +) -> None: + skipped_notes = 0 + for simai_note in simai_notes: + note_type = simai_note.note_type + if isinstance(simai_note, TapNote): + is_break = note_type in [NoteType.break_tap, NoteType.break_star] + is_star = note_type in [NoteType.star, NoteType.break_star] + sdt.add_tap( + measure=simai_note.measure, + position=simai_note.position, + is_break=is_break, + is_star=is_star, + ) + elif isinstance(simai_note, HoldNote): + sdt.add_hold( + measure=simai_note.measure, + position=simai_note.position, + duration=simai_note.duration, + ) + elif isinstance(simai_note, SlideNote): + # SDT slide duration include the delay + # unlike in simai + pattern = pattern_to_int(simai_note) + sdt.add_slide( + measure=simai_note.measure, + start_position=simai_note.position, + end_position=simai_note.end_position, + duration=simai_note.duration + simai_note.delay, + pattern=pattern, + delay=simai_note.delay, + ) + elif isinstance(simai_note, (TouchTapNote, TouchHoldNote)): + # Touch tap and touch hold + if convert_touch: + touch_converter(sdt, simai_note) + else: + skipped_notes += 1 + else: + print("Warning: Unknown note type {}".format(note_type)) + + if skipped_notes > 0: + print("Skipped {} touch note(s)".format(skipped_notes)) diff --git a/maiconverter/crypt.py b/maiconverter/crypt.py deleted file mode 100644 index 31709e9..0000000 --- a/maiconverter/crypt.py +++ /dev/null @@ -1,51 +0,0 @@ -import os -import gzip -from Crypto.Cipher import AES -from binascii import unhexlify - - -class MaiFinaleCrypt: - def __init__(self, key): - self.key = unhexlify(key.replace(" ", "")) - if len(self.key) != 0x10: - raise ValueError("Invalid key length.") - self.cipher = None - - def generate(self, iv): - self.cipher = AES.new(self.key, AES.MODE_CBC, iv) - - def convert_to_text(self, file_path): - with open(file_path, "rb") as encrypted_file: - iv = encrypted_file.read(0x10) - ciphertext = encrypted_file.read() - if len(ciphertext) % 0x10 != 0: - raise Exception("Ciphertext is not a multiple of 16.") - - self.generate(iv) - - gzipdata = self.cipher.decrypt(ciphertext) - padding = gzipdata[-1] - gzipdata = gzipdata[:-padding] - - data = gzip.decompress(gzipdata) - data = data[16:] - - return data - - def convert_to_bin(self, file_path): - with open(file_path, "rb") as text_file: - plaintext = text_file.read() - plaintext = unhexlify("4b67ca1eebc78fb9964f781019bc4903") + plaintext - gzipdata = gzip.compress(plaintext) - - padding = b"" - if len(gzipdata) % 16 != 0: - amount = 16 - (len(gzipdata) % 16) - padding = bytes([amount]) * amount - gzipdata += padding - - iv = os.urandom(16) - self.generate(iv) - ciphertext = self.cipher.encrypt(gzipdata) - - return iv + ciphertext diff --git a/maiconverter/event/__init__.py b/maiconverter/event/__init__.py new file mode 100644 index 0000000..41c220c --- /dev/null +++ b/maiconverter/event/__init__.py @@ -0,0 +1,2 @@ +from .event import * +from .note import * diff --git a/maiconverter/event.py b/maiconverter/event/event.py similarity index 100% rename from maiconverter/event.py rename to maiconverter/event/event.py diff --git a/maiconverter/note.py b/maiconverter/event/note.py similarity index 100% rename from maiconverter/note.py rename to maiconverter/event/note.py diff --git a/maiconverter/maiconverter.py b/maiconverter/maiconverter.py new file mode 100644 index 0000000..8012f77 --- /dev/null +++ b/maiconverter/maiconverter.py @@ -0,0 +1,361 @@ +import os +import argparse +import re +import traceback + +import maiconverter +from maiconverter.maicrypt import ( + finale_db_encrypt, + finale_db_decrypt, + finale_chart_encrypt, + finale_chart_decrypt, +) +from maiconverter.maima2 import MaiMa2 +from maiconverter.maisdt import MaiSDT +from maiconverter.simai import parse_file, SimaiChart +from maiconverter.converter import ( + ma2_to_sdt, + ma2_to_simai, + sdt_to_ma2, + sdt_to_simai, + simai_to_ma2, + simai_to_sdt, +) + +COMMANDS = [ + "encrypt", + "decrypt", + "ma2tosdt", + "ma2tosimai", + "sdttoma2", + "sdttosimai", + "simaifiletoma2", + "simaifiletosdt", + "simaitoma2", + "simaitosdt", +] + + +def file_path(string): + if os.path.exists(string): + return string.rstrip("/\\") + + raise FileNotFoundError(string) + + +# Only accepts directory paths +def dir_path(string): + if os.path.isdir(string): + return string.rstrip("/\\") + + raise NotADirectoryError(string) + + +def crypto(args, output): + if args.key is None: + raise RuntimeError("Key not supplied") + + if os.path.isdir(args.path): + files = os.listdir(args.path) + files = [ + os.path.join(args.path, file) + for file in files + if os.path.isfile(os.path.join(args.path, file)) + ] + + if args.command == "encrypt": + if args.database: + files = [ + file for file in files if not re.search(r"\.tbl", file) is None + ] + else: + # Only accept ".sdt" ".sct" ".szt" ".srt" files + files = [ + file for file in files if not re.search(r"\.s.t", file) is None + ] + else: # decrypt + if args.database: + files = [ + file for file in files if not re.search(r"\.bin", file) is None + ] + else: + # Only accept ".sdb" ".scb" ".szb" ".srb" files + files = [ + file for file in files if not re.search(r"\.s.b", file) is None + ] + else: + files = [args.path] + + for file in files: + if args.database: + handle_db(file, output, args.command, args.key) + else: + handle_file(file, output, args.command, args.key) + + +def chart_convert(args, output): + if args.command in ["ma2tosdt", "ma2tosimai"]: + file_regex = r"\.ma2" + elif args.command in ["sdttoma2", "sdttosimai"]: + if args.bpm is None: + raise RuntimeError("BPM required for SDT file") + + file_regex = r"\.s.t" + else: + file_regex = r"\.txt" + + if os.path.isdir(args.path): + files = os.listdir(args.path) + files = [ + os.path.join(args.path, file) + for file in files + if os.path.isfile(os.path.join(args.path, file)) + and re.search(file_regex, file) is not None + ] + else: + files = [args.path] + + for file in files: + name = os.path.splitext(os.path.basename(file))[0] + + try: + if args.command in ["ma2tosdt", "ma2tosimai"]: + handle_ma2(file, name, output, args) + elif args.command in ["sdttoma2", "sdttosimai"]: + handle_sdt(file, name, output, args) + elif args.command in ["simaifiletoma2", "simaifiletosdt"]: + handle_simai_file(file, name, output, args) + else: + handle_simai_chart(file, name, output, args) + + except Exception as e: + print(f"Error occured processing {file}. {e}") + traceback.print_exc() + + +def handle_ma2(file, name, output_path, args): + ma2 = MaiMa2.open(file) + if len(args.delay) != 0: + ma2.offset(args.delay) + + if args.command == "ma2tosdt": + sdt = ma2_to_sdt(ma2, convert_touch=args.convert_touch) + ext = ".sdt" + else: + simai = ma2_to_simai(ma2) + ext = ".txt" + + with open(os.path.join(output_path, name + ext), "w+", newline="\r\n") as out: + if args.command == "ma2tosimai": + out.write(simai.export(max_den=args.max_divisor)) + else: + out.write(sdt.export()) + + +def handle_sdt(file, name, output_path, args): + sdt = MaiSDT.open(file) + if len(args.delay) != 0: + sdt.offset(args.delay) + + if args.command == "sdttoma2": + ma2 = sdt_to_ma2(sdt, initial_bpm=args.bpm, res=args.resolution) + ext = ".ma2" + else: + simai = sdt_to_simai(sdt, initial_bpm=args.bpm) + ext = ".txt" + + with open(os.path.join(output_path, name + ext), "w+", newline="\r\n") as out: + if args.command == "sdttosimai": + out.write(simai.export(max_den=args.max_divisor)) + else: + out.write(ma2.export()) + + +def handle_simai_chart(file, name, output_path, args): + with open(file, "r") as f: + chart_text = f.read() + + simai = SimaiChart.from_str(chart_text, message=f"Parsing Simai chart at {file}...") + if len(args.delay) != 0: + simai.offset(args.delay) + + if args.command == "simaitosdt": + ext = ".sdt" + converted = simai_to_sdt(simai, convert_touch=args.convert_touch) + else: + ext = ".ma2" + converted = simai_to_ma2(simai, res=args.resolution) + + with open(os.path.join(output_path, name + ext), "w+", newline="\r\n") as out: + out.write(converted.export()) + + +def handle_simai_file(file, name, output_path, args): + title, charts = parse_file(file) + for i, chart in enumerate(charts): + diff, simai_chart = chart + if len(args.delay) != 0: + simai_chart.offset(args.delay) + + try: + if args.command == "simaifiletosdt": + ext = ".sdt" + converted = simai_to_sdt(simai_chart, convert_touch=args.convert_touch) + else: + ext = ".ma2" + converted = simai_to_ma2(simai_chart, res=args.resolution) + + name = title + f"_{diff}" + with open( + os.path.join(output_path, name + ext), "w+", newline="\r\n" + ) as out: + out.write(converted.export()) + except Exception as e: + print(f"Error processing {i + 1} chart of file. {e}") + traceback.print_exc() + + +def handle_file(input_path, output_dir, command, key): + file_name = os.path.splitext(os.path.basename(input_path))[0] + if command == "encrypt": + + file_ext = os.path.splitext(input_path)[1].replace("t", "b") + if os.path.exists(os.path.join(output_dir, file_name + file_ext)): + print("File {} exists! Skipping".format(file_name + file_ext)) + return + + cipher_text = finale_chart_encrypt(key, input_path) + with open(os.path.join(output_dir, file_name + file_ext), "xb") as f: + f.write(cipher_text) + else: + file_ext = os.path.splitext(input_path)[1].replace("b", "t") + if os.path.exists(os.path.join(output_dir, file_name + file_ext)): + print("File {} exists! Skipping".format(file_name + file_ext)) + return + + plain_text = finale_chart_decrypt(key, input_path) + with open(os.path.join(output_dir, file_name + file_ext), "x") as f: + f.write(plain_text) + + +def handle_db(input_path, output_dir, command, key): + if command == "encrypt": + file_name = os.path.splitext(os.path.basename(input_path))[0] + file_ext = ".bin" + if os.path.exists(os.path.join(output_dir, file_name + file_ext)): + print("File {} exists! Skipping".format(file_name + file_ext)) + return + + cipher_text = finale_db_encrypt(key, input_path) + with open(os.path.join(output_dir, file_name + file_ext), "xb") as f: + f.write(cipher_text) + else: + file_name = os.path.splitext(os.path.basename(input_path))[0] + file_ext = ".tbl" + if os.path.exists(os.path.join(output_dir, file_name + file_ext)): + print("File {} exists! Skipping".format(file_name + file_ext)) + return + + plain_text = finale_db_decrypt(key, input_path) + with open( + os.path.join(output_dir, file_name + file_ext), "x", newline="\r\n" + ) as f: + f.write(plain_text) + + +def main(): + parser = argparse.ArgumentParser( + description="Tool for converting Maimai chart formats", + allow_abbrev=False, + ) + parser.add_argument( + "command", + metavar="command", + type=str, + choices=COMMANDS, + help="Specify whether to encrypt or decrypt", + ) + parser.add_argument("path", metavar="input", type=file_path, help="") + parser.add_argument( + "-k", + "--key", + type=str, + help="16 byte AES key for encrypt/decrypt (whitespace allowed)", + ) + parser.add_argument( + "--database", + "-db", + action="store_const", + default=False, + const=True, + help="Database/s toggle for encrypt/decrypt", + ) + parser.add_argument( + "-b", + "--bpm", + metavar="Song BPM", + type=float, + help="Song BPM for sdttoma2/sdttosimai", + ) + parser.add_argument( + "-d", + "--delay", + metavar="Delay", + type=str, + default="", + help="Offset a chart by set measures (can be negative)", + ) + parser.add_argument( + "-ct", + "--convert_touch", + action="store_const", + default=False, + const=True, + help="Toggle to convert touch notes for chart conversion to SDT", + ) + parser.add_argument( + "-r", + "--resolution", + type=int, + default=384, + help="Output chart resolution for ma2 charts", + ) + parser.add_argument( + "-md", + "--max_divisor", + type=int, + default=1000, + help="Max divisor used in Simai export", + ) + parser.add_argument( + "-o", + "--output", + metavar="Output directory", + type=dir_path, + help="Path to output. Defaults to /path/to/input/output", + ) + + args = parser.parse_args() + print(f"MaiConverter {maiconverter.__version__} by donmai") + + if args.output is None and os.path.isdir(args.path): + output_dir = os.path.join(args.path, "output") + elif args.output is None and not os.path.isdir(args.path): + output_dir = os.path.join(os.path.dirname(args.path), "output") + else: + output_dir = args.output + + if not os.path.exists(output_dir): + os.makedirs(output_dir) + elif os.path.exists(output_dir) and not os.path.isdir(output_dir): + raise NotADirectoryError(output_dir) + + if args.command in ["encrypt", "decrypt"]: + crypto(args, output_dir) + else: + chart_convert(args, output_dir) + + print( + "Finished. Join MaiMai Tea Discord server for more info and tools about MaiMai modding!" + ) + print("https://discord.gg/WxEMM9dnwR") diff --git a/maiconverter/maicrypt/__init__.py b/maiconverter/maicrypt/__init__.py new file mode 100644 index 0000000..4d03188 --- /dev/null +++ b/maiconverter/maicrypt/__init__.py @@ -0,0 +1,8 @@ +from .maifinalecrypt import ( + finale_encrypt, + finale_decrypt, + finale_db_decrypt, + finale_db_encrypt, + finale_chart_decrypt, + finale_chart_encrypt, +) diff --git a/maiconverter/maicrypt/maifinalecrypt.py b/maiconverter/maicrypt/maifinalecrypt.py new file mode 100644 index 0000000..c50ed92 --- /dev/null +++ b/maiconverter/maicrypt/maifinalecrypt.py @@ -0,0 +1,133 @@ +import os +import gzip +from typing import Union +from binascii import unhexlify +from Crypto.Cipher import AES + + +def finale_db_decrypt( + key: str, + path: str, + encoding: str = "utf-16-le", + ignore_errors: bool = True, +) -> str: + if os.path.getsize(path) % 0x10 != 0: + raise ValueError("Ciphertext is not a multiple of 16") + + with open(path, "rb") as encrypted_file: + iv = encrypted_file.read(0x10) + ciphertext = encrypted_file.read() + + return finale_decrypt( + mode="db", + key=key, + iv=iv, + ciphertext=ciphertext, + encoding=encoding, + ignore_errors=ignore_errors, + ) + + +def finale_db_encrypt( + key: str, + path: str, + encoding: str = "utf-16-le", +) -> bytes: + with open(path, "r", encoding="utf-8") as f: + rawtext = f.read() + + return finale_encrypt(mode="db", key=key, plaintext=rawtext, encoding=encoding) + + +def finale_chart_decrypt(key: str, path: str, encoding: str = "utf-8") -> str: + if os.path.getsize(path) % 0x10 != 0: + raise ValueError("Ciphertext is not a multiple of 16") + + with open(path, "rb") as encrypted_file: + iv = encrypted_file.read(0x10) + ciphertext = encrypted_file.read() + + key_bin = int(key, 0).to_bytes(0x10, "big") + + return finale_decrypt( + mode="chart", + key=key_bin, + iv=iv, + ciphertext=ciphertext, + encoding=encoding, + ) + + +def finale_chart_encrypt(key: str, path: str, encoding: str = "utf-8") -> bytes: + key_bin = int(key, 0).to_bytes(0x10, "big") + + with open(path, "r", encoding="utf-8") as f: + rawtext = f.read() + + return finale_encrypt( + mode="chart", key=key_bin, plaintext=rawtext, encoding=encoding + ) + + +def finale_decrypt( + mode: str, + key: Union[str, bytes], + iv: bytes, + ciphertext: bytes, + encoding: str, + ignore_errors: bool = True, +) -> str: + if not isinstance(key, bytes): + key = int(key, 0).to_bytes(0x10, "big") + + cipher = AES.new(key, AES.MODE_CBC, iv) + gzipdata = cipher.decrypt(ciphertext) + + num_padding = gzipdata[-1] + if num_padding > 0: + gzipdata = gzipdata[:-num_padding] + if gzipdata[:2] != b"\x1f\x8b": + gzipdata = b"\x1f\x8b" + gzipdata + + if mode == "db": + # 0x10 bytes for random data and 2 bytes for the UTF-16 BOM + data = gzip.decompress(gzipdata)[0x12:] + elif mode == "chart": + data = gzip.decompress(gzipdata)[0x10:] + else: + raise ValueError(f"Unknown mode: {mode}") + + if ignore_errors: + return data.decode(encoding, errors="ignore") + + return data.decode(encoding) + + +def finale_encrypt( + mode: str, + key: Union[str, bytes], + plaintext: str, + encoding: str, +) -> bytes: + if not isinstance(key, bytes): + key = unhexlify(key.replace(" ", "")) + if len(key) != 0x10: + raise ValueError("Invalid key length") + + JUNK = unhexlify("4b67ca1eebc78fb9964f781019bc4903") + if mode == "db": + # FEFF is UTF-16 BOM + encoded = JUNK + b"\xfe\xff" + plaintext.encode(encoding=encoding) + else: + encoded = JUNK + plaintext.encode(encoding=encoding) + + gzipdata = gzip.compress(encoded) + if len(gzipdata) % 0x10 != 0: + amount = 0x10 - (len(gzipdata) % 0x10) + padding = amount.to_bytes(1, "big") * amount + gzipdata += padding + + iv = os.urandom(0x10) + cipher = AES.new(key, AES.MODE_CBC, iv) + + return iv + cipher.encrypt(gzipdata) diff --git a/maiconverter/maima2/__init__.py b/maiconverter/maima2/__init__.py new file mode 100644 index 0000000..87223ef --- /dev/null +++ b/maiconverter/maima2/__init__.py @@ -0,0 +1,3 @@ +from .ma2note import * +from .tools import parse_v1 +from .maima2 import MaiMa2 diff --git a/maiconverter/maima2/ma2note.py b/maiconverter/maima2/ma2note.py new file mode 100644 index 0000000..e0cabd4 --- /dev/null +++ b/maiconverter/maima2/ma2note.py @@ -0,0 +1,457 @@ +import math +from typing import Tuple, Union + +from ..event import MaiNote, NoteType, Event, EventType + +# Dictionary for a note type's representation in ma2 +# Does not cover slide notes, BPM, and meter events. +# Use slide_dict for slides instead. +note_dict = { + "TAP": 1, + "HLD": 2, + "BRK": 3, + "STR": 4, + "BST": 5, + "XTP": 6, + "XST": 7, + "XHO": 8, + "TTP": 9, + "THO": 10, + # "SLD": 11 +} + +# Dictionary for a slide note's representation in ma2 by pattern. +slide_dict = { + "SI_": 1, + "SCL": 2, + "SCR": 3, + "SUL": 4, + "SUR": 5, + "SSL": 6, + "SSR": 7, + "SV_": 8, + "SXL": 9, + "SXR": 10, + "SLL": 11, + "SLR": 12, + "SF_": 13, +} + + +class SlideNote(MaiNote): + def __init__( + self, + measure: float, + start_position: int, + end_position: int, + pattern: int, + duration: float, + delay: float = 0.25, + ) -> None: + """Produces a ma2 slide note. + + Note: + Please use MaiMa2 class' add_slide method for adding slides. + + Args: + measure: Time when the note starts, in terms of measures. + start_position: Starting button. + end_position: Ending button. + pattern: Numerical representation of the slide pattern. + duration: Time duration of when the slide starts moving and + when it ends (delay is not included.) in terms of + measures. + delay: Time duration of when the slide appears and when it + starts to move, in terms of measures. + resolution: Ma2 time resolution used. Optional, defaults to + 384. + Raises: + ValueError: When pattern or duration is not a positive integer, + and when delay, or end_position are negative. + """ + if pattern <= 0: + raise ValueError("Slide pattern is not positive " + str(pattern)) + if duration <= 0: + raise ValueError("Slide duration is not positive " + str(duration)) + if delay < 0: + raise ValueError("Slide delay is negative " + str(delay)) + if end_position < 0: + raise ValueError("Slide end position is negative " + str(end_position)) + + measure = round(10000.0 * measure) / 10000.0 + super().__init__(measure, start_position, NoteType.complete_slide) + self.end_position = end_position + self.pattern = pattern + self.delay = delay + self.duration = duration + + +class HoldNote(MaiNote): + def __init__( + self, + measure: float, + position: int, + duration: float, + is_ex: bool = False, + ) -> None: + """Produces a ma2 hold note. + + Note: + Please use MaiMa2 class' add_hold method for adding holds. + + Args: + measure: Time when the note starts, in terms of measures. + position: Button where the hold note happens. + duration: Total time duration of the hold note. + is_ex: Whether a hold note is an ex note. + resolution: Ma2 time resolution used. Optional, defaults to + 384. + + Raises: + ValueError: When duration is not positive. + """ + if duration < 0: + raise ValueError(f"Hold duration is negative: {duration}") + + measure = round(measure * 10000.0) / 10000.0 + duration = round(duration * 10000.0) / 10000.0 + + if is_ex: + super().__init__(measure, position, NoteType.ex_hold) + else: + super().__init__(measure, position, NoteType.hold) + + self.duration = duration + + +class TapNote(MaiNote): + def __init__( + self, + measure: float, + position: int, + is_star: bool = False, + is_break: bool = False, + is_ex: bool = False, + ) -> None: + """Produces a ma2 tap note. + + Note: + Please use MaiMa2 class' add_tap method for adding taps. + + Args: + measure: Time when the note starts, in terms of measures. + position: Button where the tap note happens. + is_star: Whether a tap note is a star note. + is_break: Whether a tap note is a break note. + is_ex: Whether a tap note is an ex note. + resolution: Ma2 time resolution used. Optional, defaults to + 384. + """ + measure = round(measure * 10000.0) / 10000.0 + + if is_ex and is_star: + super().__init__(measure, position, NoteType.ex_star) + elif is_ex and not is_star: + super().__init__(measure, position, NoteType.ex_tap) + elif is_star and is_break: + super().__init__(measure, position, NoteType.break_star) + elif is_star and not is_break: + super().__init__(measure, position, NoteType.star) + elif not is_star and is_break: + super().__init__(measure, position, NoteType.break_tap) + elif not is_star and not is_break: + super().__init__(measure, position, NoteType.tap) + + +class TouchTapNote(MaiNote): + def __init__( + self, + measure: float, + position: int, + region: str, + is_firework: bool = False, + size: str = "M1", + ) -> None: + """Produces a ma2 touch tap note. + + Note: + Please use MaiMa2 class' add_touch_tap method + for adding touch taps. + + Args: + measure: Time when the note starts, in terms of measures. + position: Position in the region where the note happens. + region: Touch region where the note happens. + is_firework: Whether a touch tap note will produce + fireworks. Optional bool, defaults to False. + size: Optional str. Specifies the size of touch note defaults to "M1" + + Examples: + A touch tap note at E0 at measure 2.25, produces no + fireworks. + + >>> touch_tap = MaiMa2TouchTapNote(2.25, 0, "E") + + A touch tap note at B5 at measure 5.00, produces fireworks. + + >>> touch_tap = MaiMa2TouchTapNote(5.00, 5, "B", True) + """ + if size not in ["M1", "L1"]: + raise ValueError(f"Invalid size given: {size}") + + measure = round(measure * 10000.0) / 10000.0 + + super().__init__(measure, position, NoteType.touch_tap) + self.is_firework = is_firework + self.region = region + self.size = size + + +class TouchHoldNote(MaiNote): + def __init__( + self, + measure: float, + position: int, + region: str, + duration: float, + is_firework: bool = False, + size: str = "M1", + ) -> None: + """Produces a ma2 touch hold note. + + Note: + Please use MaiMa2 class' add_touch_hold method + for adding touch holds. + + Args: + measure: Time when the note starts, in terms of measures. + position: Position in the region where the note happens. + region: Touch region where the note happens. + duration: Total time duration of the touch hold note. + is_firework: Whether a touch hold note will produce + fireworks. Optional bool, defaults to False. + size: Optional str. Specifies the size of touch note defaults to "M1" + + Examples: + A touch hold note at C0 at measure 1 with duration of + 1.5 measures, produces no fireworks. + + >>> touch_tap = MaiMa2TouchHoldNote(1, 0, "C", 1.5) + """ + if duration <= 0: + raise ValueError(f"Hold duration is not positive: {duration} ") + if size not in ["M1", "L1"]: + raise ValueError(f"Invalid size given: {size}") + + measure = round(measure * 10000.0) / 10000.0 + duration = round(duration * 10000.0) / 10000.0 + + super().__init__(measure, position, NoteType.touch_hold) + self.duration = duration + self.is_firework = is_firework + self.region = region + self.size = size + + +class BPM(Event): + def __init__(self, measure: float, bpm: float) -> None: + """Produces a ma2 BPM event. + + Note: + Please use MaiMa2 class' set_bpm method + for adding BPM events. + + Args: + measure: Time when the BPM change happens, + in terms of measures. + bpm: The tempo in beats per minute. + resolution: Ma2 time resolution used. Optional, defaults to + 384. + + Raises: + ValueError: When bpm is not positive. + + Examples: + A bpm of 220 at measure 3. + + >>> ma2_bpm = MaiMa2BPM(3, 220) + """ + if bpm <= 0: + raise ValueError(f"BPM is not positive: {bpm}") + + measure = round(measure * 10000.0) / 10000.0 + + super().__init__(measure, EventType.bpm) + self.bpm = bpm + + +class Meter(Event): + def __init__( + self, + measure: float, + meter_numerator: int, + meter_denominator: int, + ) -> None: + """Produces a ma2 meter signature event. + + Note: + Please use MaiMa2 class' set_meter method + for adding meter signature events. + + Args: + measure: Time when the meter change happens, + in terms of measures. + meter_numerator: The upper numeral in a meter/time + signature. + meter_denominator: The lower numeral in a meter/time + signature. + resolution: Ma2 time resolution used. Optional, defaults to + 384. + + Raises: + ValueError: When meter_numerator or + meter_denominator is not positive. + """ + if meter_numerator <= 0: + raise ValueError("Meter numerator is not positive: {meter_numerator}") + if meter_denominator <= 0: + raise ValueError(f"Meter denominator is not positive {meter_denominator}") + + measure = round(measure * 10000.0) / 10000.0 + + super().__init__(measure, EventType.meter) + self.numerator = meter_numerator + self.denominator = meter_denominator + + +def measure_to_ma2_time(measure: float, resolution: int = 384) -> Tuple[int, int]: + """Convert measure in decimal form to ma2's format. + + Ma2 uses integer timing for its measures. It does so by taking + the fractional part and multiplying it to the chart's + resolution, then rounding it up. For durations like holds + and slides, both the whole and fractional part are + multiplied by the charts resolution. + + Args: + measure: The time a note happened or duration. + + Returns: + A tuple (WHOLE_PART, FRACTIONAL_PART). + + Raises: + ValueError: When measure is negative. + + Examples: + Convert a note that happens in measure 2.5 in ma2 time + with resolution of 384. + + >>> print(measure_to_ma2_time(2.5)) + (2, 192) + + Convert a duration of 3.75 measures in ma2 time with + resolution of 500. + + >>> my_resolution = 500 + >>> (whole, dec) = measure_to_ma2_time(3.75, my_resolution) + >>> whole*my_resolution + dec + 1875.0 + """ + if measure < 0: + raise ValueError("Measure is negative. " + str(measure)) + + (decimal_part, whole_part) = math.modf(measure) + decimal_part = round(decimal_part * resolution) + + return (int(whole_part), decimal_part) + + +def event_to_str( + event: Union[TapNote, HoldNote, SlideNote, TouchTapNote, TouchHoldNote, BPM, Meter], + resolution: int = 384, +) -> str: + """Converts ma2 note and events into ma2-compatible lines. + + Args: + event: A bpm, meter, or note event. + + Returns: + A single line string with a line ending. + + Raises: + TypeError: If a note or event is unknown. + ValueError: If a slide note has unknown pattern. + """ + if not isinstance(event, Event): + raise TypeError("{} is not an Event type".format(event)) + + inv_note_dict = {v: k for k, v in note_dict.items()} + measure = measure_to_ma2_time(event.measure, resolution) + + if isinstance(event, BPM): # BPM + template = "BPM\t{}\t{}\t{:.3f}\n" + result = template.format(measure[0], measure[1], event.bpm) + elif isinstance(event, Meter): # Meter + template = "MET\t{}\t{}\t{}\t{}\n" + result = template.format( + measure[0], measure[1], event.numerator, event.denominator + ) + elif isinstance(event, TapNote): + template = "{}\t{}\t{}\t{}\n" + name = inv_note_dict[event.note_type.value] + result = template.format(name, measure[0], measure[1], event.position) + elif isinstance(event, HoldNote): + template = "{}\t{}\t{}\t{}\t{}\n" + name = inv_note_dict[event.note_type.value] + duration = round(event.duration * resolution) + result = template.format(name, measure[0], measure[1], event.position, duration) + elif isinstance(event, SlideNote): + template = "{}\t{}\t{}\t{}\t{}\t{}\t{}\n" + inv_slide_dict = {v: k for k, v in slide_dict.items()} + if event.pattern not in inv_slide_dict: + raise ValueError("Unknown slide pattern {}".format(event.pattern)) + + pattern = inv_slide_dict[event.pattern] + delay = round(event.delay * resolution) + duration = round(event.duration * resolution) + result = template.format( + pattern, + measure[0], + measure[1], + event.position, + delay, + duration, + event.end_position, + ) + elif isinstance(event, TouchTapNote): + template = "{}\t{}\t{}\t{}\t{}\t{}\t{}\n" + name = inv_note_dict[event.note_type.value] + fireworks = 1 if event.is_firework else 0 + result = template.format( + name, + measure[0], + measure[1], + event.position, + event.region, + fireworks, + event.size, + ) + elif isinstance(event, TouchHoldNote): + template = "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\n" + name = inv_note_dict[event.note_type.value] + duration = round(event.duration * resolution) + fireworks = 1 if event.is_firework else 0 + result = template.format( + name, + measure[0], + measure[1], + event.position, + duration, + event.region, + fireworks, + event.size, + ) + else: + raise TypeError("Unknown note type {}".format(event.note_type.value)) + + return result diff --git a/maiconverter/maima2/maima2.py b/maiconverter/maima2/maima2.py new file mode 100644 index 0000000..34998d5 --- /dev/null +++ b/maiconverter/maima2/maima2.py @@ -0,0 +1,867 @@ +from __future__ import annotations +from typing import Tuple, List, Union +import copy + +from .ma2note import ( + TapNote, + HoldNote, + SlideNote, + TouchTapNote, + TouchHoldNote, + BPM, + Meter, + event_to_str, +) +from .tools import parse_v1 +from ..event import NoteType +from ..time import measure_to_second, second_to_measure + +# Latest chart version +MA2_VERSION = "1.03.00" + + +class MaiMa2: + """A class that represents a ma2 chart. Contains notes, bpm, + and meter information. Does not include information such as + song name, chart difficulty, composer, chart maker, etc. + It only contains enough information to build a working ma2 + chart file. + + Attributes: + resolution (int): The internal resolution of the chart. + Used for time tracking purposes. See set_time method + for more information about ma2's time tracking. + click_res (int): Like resolution but for CLK events. + fes_mode (bool): Whether a chart is an utage. + bpms (list[BPM]): Contains bpm events of the chart. + meters (dict[float, Meter]): Contains meter events + of the chart + notes (list[MaiNote]): Contains notes of the chart. + compatible_code (str): Required for ma2's header. Copied + from official ma2 chart files. + version (str): Required for ma2's header.Copied from + official ma2 chart files. + notes_stat (dict[str, int]): Tracks total number of + notes used by note type. + """ + + def __init__( + self, + resolution: int = 384, + click_res: int = 384, + fes_mode: bool = False, + version: str = MA2_VERSION, + ): + """Produces a MaiMa2 object. + + Args: + resolution: Time tracking resolution. + click_res: Like resolution but for CLK events. + fes_mode: Whether a chart is an utage. + version: Chart version. + + Raises: + ValueError: When resolution or click_res is not positive. + + Examples: + Create a regular ma2 object with default resolution and + click_res. + + >>> ma2 = MaiMa2() + + Create an utage ma2 with resolution of 200. + + >>> ma2 = MaiMa2(resolution=200, fes_mode=True) + """ + # TODO: Remove compatible code and version attribute. + if resolution <= 0: + raise ValueError("Resolution is not positive " + str(resolution)) + if click_res <= 0: + raise ValueError("Click is not positive " + str(click_res)) + + self.resolution = resolution + self.click_res = click_res + self.fes_mode = fes_mode + self.bpms: List[BPM] = [] + self.meters: List[Meter] = [] + self.notes: List[ + Union[TapNote, HoldNote, SlideNote, TouchTapNote, TouchHoldNote] + ] = [] + self.version = ("0.00.00", version) + self.notes_stat = { + "TAP": 0, + "BRK": 0, + "XTP": 0, + "HLD": 0, + "XHO": 0, + "STR": 0, + "BST": 0, + "XST": 0, + "TTP": 0, + "THO": 0, + "SLD": 0, + } + + @classmethod + def open(cls, path: str) -> MaiMa2: + ma2 = cls() + with open(path, "r") as in_f: + for line in in_f: + if line in ["\n", "\r\n"]: + continue + + ma2.parse_line(line) + + return ma2 + + def parse_line(self, line: str) -> None: + # Ma2 notes are tab-separated so we make a list called values that contains all the info + values = line.rstrip().split("\t") + line_type = values[0] + if line_type == "VERSION": + self.version = (values[1], values[2]) + elif line_type == "FES_MODE": + self.fes_mode = values[1] == "1" + elif self.version[1] in ["1.02.00", "1.03.00"]: + parse_v1(self, values) + else: + raise ValueError(f"Unknown Ma2 version: {self.version}") + + def set_bpm(self, measure: float, bpm: float) -> None: + """Sets the bpm at given measure. + + Note: + If BPM event is already defined at given measure, + the method will overwrite it. + + Args: + measure: Time, in measures, where the bpm is defined. + bpm: The tempo in beat per minutes. + + Examples: + In a chart, the initial bpm is 180 then changes + to 250 in measure 12. + + >>> ma2 = MaiMa2() + >>> ma2.set_bpm(0, 180) + >>> ma2.set_bpm(12, 250) + """ + measure = round(measure * 10000.0) / 10000.0 + self.del_bpm(measure) + self.bpms.append(BPM(measure, bpm)) + + def get_bpm(self, measure: float) -> float: + """Gets the bpm at given measure. + + Args: + measure: Time, in measures. + + Returns: + Returns the bpm defined at given measure or None. + + Raises: + ValueError: When measure is negative, there are no BPMs + defined, or there are no starting BPM defined. + + Examples: + In a chart, the initial bpm is 180 then changes + to 250 in measure 12. + + >>> ma2.get_bpm(0) + 180.0 + >>> ma2.get_bpm(11.99) + 180.0 + >>> ma2.get_bpm(12) + 250.0 + """ + if len(self.bpms) == 0: + raise ValueError("No BPMs defined") + if measure < 0: + raise ValueError(f"Measure is negative {measure}") + + measure = round(measure * 10000.0) / 10000.0 + bpm_measures = [bpm.measure for bpm in self.bpms] + bpm_measures = list(set(bpm_measures)) + bpm_measures.sort() + + if 0.0 not in bpm_measures and 1.0 not in bpm_measures: + raise RuntimeError("No starting BPM defined") + + previous_measure = 0.0 + for bpm_measure in bpm_measures: + if bpm_measure <= measure: + previous_measure = bpm_measure + else: + break + + bpm_result = [bpm.bpm for bpm in self.bpms if bpm.measure == previous_measure] + return bpm_result[0] + + def del_bpm(self, measure: float): + """Deletes the bpm at given measure. + + Note: + If there are no BPM defined for that measure, nothing happens. + + Args: + measure: Time, in measures, where the bpm is defined. + + Examples: + Delete the BPM change defined at measure 24. + + >>> ma2.del_bpm(24) + """ + bpms = [x for x in self.bpms if x.measure == measure] + for x in bpms: + self.bpms.remove(x) + + def set_meter( + self, measure: float, meter_numerator: int, meter_denominator: int + ) -> None: + """Sets the meter signature at given measure. + + Note: + If meter signature event is already defined + at given measure, the method will overwrite it. + + Args: + measure: Time, in measures, where the bpm is defined. + meter_numerator: The upper numeral in a meter signature. + meter_denominator: The lower numeral in a meter signature. + + Examples: + In a chart, the initial meter is 4/4 then changes + to 6/8 in measure 5. + + >>> ma2 = MaiMa2() + >>> ma2.set_meter(0, 4, 4) + >>> ma2.set_meter(5, 6, 8) + """ + measure = round(measure * 10000.0) / 10000.0 + self.meters.append(Meter(measure, meter_numerator, meter_denominator)) + + def get_meter(self, measure) -> Tuple[int, int]: + """Gets the bpm at given measure. + + Args: + measure: Time, in measures. + + Returns: + Returns a tuple (numerator, denominator) defined at + given measure or None. + + Examples: + In a chart, the initial meter is 4/4 then changes + to 6/8 in measure 12. + + >>> ma2.get_meter(0) + (4, 4) + >>> ma2.get_meter(11.99) + (4, 4) + >>> ma2.get_meter(12) + (6, 8) + + In a blank chart. + + >>> print(ma2.get_meter(0)) + None + """ + if len(self.meters) == 0: + raise ValueError("No meters defined") + if measure < 0: + raise ValueError("Measure is negative number " + str(measure)) + + measure = round(measure * 10000.0) / 10000.0 + meter_measures = [meter.measure for meter in self.meters] + meter_measures = list(set(meter_measures)) + meter_measures.sort() + + if 0.0 not in meter_measures and 1.0 not in meter_measures: + raise ValueError("No starting meters defined") + + previous_measure = 0.0 + for meter_measure in meter_measures: + if meter_measure <= measure: + previous_measure = meter_measure + else: + break + + meter_result = [ + meter for meter in self.meters if meter.measure == previous_measure + ] + + return (meter_result[0].numerator, meter_result[0].denominator) + + def get_bpm_statistic(self) -> Tuple[float, float, float, float]: + """Reads all the BPM defined and provides statistics. + + Returns: + A tuple of floats representing BPMs: + (STARTING, MODE, HIGHEST, LOWEST) + + Raises: + Exception: If there are no BPM events defined. + """ + self.notes.sort() + last_measure = self.notes[-1].measure + measure_list = [bpm.measure for bpm in self.bpms] + if len(measure_list) == 0: + raise Exception("No BPMs defined.") + + measure_list.sort() + bpm_list = [bpm.bpm for bpm in self.bpms] + try: + starting_bpm = self.get_bpm(0.0) + except RuntimeError: + starting_bpm = self.get_bpm(1.0) + + mode_bpm = starting_bpm + highest_bpm = max(bpm_list) + lowest_bpm = min(bpm_list) + bpm_duration = {starting_bpm: 0.0} + + for i, _ in enumerate(measure_list): + bpm = bpm_list[i] + if i == len(measure_list) - 1: + duration = last_measure - measure_list[i] + else: + duration = measure_list[i + 1] - measure_list[i] + + if bpm in bpm_duration: + bpm_duration[bpm] += duration + else: + bpm_duration[bpm] = duration + + if bpm_duration[bpm] > bpm_duration[mode_bpm]: + mode_bpm = bpm + + return (starting_bpm, mode_bpm, highest_bpm, lowest_bpm) + + def get_header(self) -> str: + """Generates a 7 line header required in ma2 formats. + + If there are no defined meter events, it is assumed that the + meter is 4/4. + + Returns: + A multiline string. + """ + bpms = self.get_bpm_statistic() + try: + starting_meter = self.get_meter(0.0) + except ValueError: + try: + starting_meter = self.get_meter(1.0) + except ValueError: + print("Warning: No starting meter. Assuming 4/4") + meter_num = 4 + meter_den = 4 + else: + meter_num = starting_meter[0] + meter_den = starting_meter[1] + else: + meter_num = starting_meter[0] + meter_den = starting_meter[1] + + result = f"VERSION\t0.00.00\t{MA2_VERSION}\n" + result += f"FES_MODE\t{1 if self.fes_mode else 0}\n" + result += ( + f"BPM_DEF\t{bpms[0]:.3f}\t{bpms[1]:.3f}\t{bpms[2]:.3f}\t{bpms[3]:.3f}\n" + ) + result += f"MET_DEF\t{meter_num}\t{meter_den}\n" + result += f"RESOLUTION\t{self.resolution}\n" + result += f"CLK_DEF\t{self.click_res}\n" + result += "COMPATIBLE_CODE\tMA2\n" + + return result + + def get_breakdown(self) -> str: + """Prints all BPM and MET events in chronological order. + + Returns: + A multiline string. Each line + contains information about every BPM and Meter events + defined. + """ + bpms = self.bpms + bpms.sort(key=lambda bpm: bpm.measure) + meters = self.meters + meters.sort(key=lambda meter: meter.measure) + + result = "" + for bpm in bpms: + result += event_to_str(bpm) + + for meter in meters: + result += event_to_str(meter) + + return result + + def get_epilog(self) -> str: + """Prints summary of all notes and score information. + + Returns: + A multiline string. + First part gives the total number of notes are in + the chart. Second part is about score related information. + + """ + result = "" + total_notes = 0 + for note_type in self.notes_stat: + total_notes += self.notes_stat[note_type] + result += "T_REC_{}\t{}\n".format(note_type, self.notes_stat[note_type]) + result += "T_REC_ALL\t{}\n".format(total_notes) + + num_taps = sum( + [self.notes_stat[i] for i in ["TAP", "XTP", "STR", "XST", "TTP"]] + ) + num_breaks = self.notes_stat["BRK"] + self.notes_stat["BST"] + num_holds = sum([self.notes_stat[i] for i in ["HLD", "XHO", "THO"]]) + num_slides = self.notes_stat["SLD"] + + result += "T_NUM_TAP\t{}\n".format(num_taps) + result += "T_NUM_BRK\t{}\n".format(num_breaks) + result += "T_NUM_HLD\t{}\n".format(num_holds) + result += "T_NUM_SLD\t{}\n".format(num_slides) + result += "T_NUM_ALL\t{}\n".format(total_notes) + + judge_taps = num_taps + num_breaks + judge_holds = round(num_holds * 1.75) + judge_all = judge_taps + judge_holds + num_slides + result += "T_JUDGE_TAP\t{}\n".format(judge_taps) + result += "T_JUDGE_HLD\t{}\n".format(judge_holds) + result += "T_JUDGE_SLD\t{}\n".format(num_slides) + result += "T_JUDGE_ALL\t{}\n".format(judge_all) + + taps = [ + note.measure + for note in self.notes + if isinstance(note, (TapNote, HoldNote, TouchTapNote, TouchHoldNote)) + ] + measures = set(taps) + num_eachpairs = 0 + + for measure in measures: + if taps.count(measure) > 1: + num_eachpairs += 1 + + result += "TTM_EACHPAIRS\t{}\n".format(num_eachpairs) + + # From https://docs.google.com/document/d/1gQlxtxOj-E3H2SClJH5PNxLnG6eBufDFrw2yLsffbp0 + total_max_score_tap = 500 * num_taps + total_max_score_break = 2600 * num_breaks + total_max_score_hold = 1000 * num_holds + total_max_score_slide = 1500 * num_slides + total_max_score = ( + total_max_score_tap + + total_max_score_break + + total_max_score_hold + + total_max_score_slide + ) + result += "TTM_SCR_TAP\t{}\n".format(total_max_score_tap) + result += "TTM_SCR_BRK\t{}\n".format(total_max_score_break) + result += "TTM_SCR_HLD\t{}\n".format(total_max_score_hold) + result += "TTM_SCR_SLD\t{}\n".format(total_max_score_slide) + result += "TTM_SCR_ALL\t{}\n".format(total_max_score) + total_base_score = ( + total_max_score_tap + + total_max_score_hold + + total_max_score_slide + + 2500 * num_breaks + ) + max_finale_achievement = int(10000 * total_max_score / total_base_score) + total_max_score_s = round(0.97 * total_base_score / 100) * 100 + total_max_score_ss = total_base_score + result += "TTM_SCR_S\t{}\n".format(total_max_score_s) + result += "TTM_SCR_SS\t{}\n".format(total_max_score_ss) + result += "TTM_RAT_ACV\t{}\n".format(max_finale_achievement) + return result + + def add_tap( + self, + measure: float, + position: int, + is_break: bool = False, + is_star: bool = False, + is_ex: bool = False, + ) -> None: + """Adds a tap note to the list of notes. + + Used to add TAP, XTP, BRK, STR, or BST to the list of notes. Increments + the total note type produced by 1. + + Args: + measure: Time when the note starts, in terms of measures. + position: Button where the tap note happens. + is_break: Whether a tap note is a break note. + is_star: Whether a tap note is a star note. + is_ex: Whether a tap note is an ex note. + + Examples: + Add a regular tap note at measure 1, break tap note at + measure 2, ex tap note at measure 2.5, star note at + measure 3, and a break star note at measure 5. All at + position 7. + + >>> ma2 = MaiMa2() + >>> ma2.add_tap(1, 7) + >>> ma2.add_tap(2, 7, is_break=True) + >>> ma2.add_tap(2.5, 7, is_ex=True) + >>> ma2.add_tap(3, 7, is_star=True) + >>> ma2.add_tap(5, 7, is_break=True, is_star=True) + """ + tap_note = TapNote( + measure=measure, + position=position, + is_star=is_star, + is_break=is_break, + is_ex=is_ex, + ) + + if is_ex and is_star: + self.notes_stat["XST"] += 1 + elif is_ex and not is_star: + self.notes_stat["XTP"] += 1 + elif is_break and is_star and not is_ex: + self.notes_stat["BST"] += 1 + elif is_break and not is_star and not is_ex: + self.notes_stat["BRK"] += 1 + elif not is_break and is_star and not is_ex: + self.notes_stat["STR"] += 1 + elif not is_break and not is_star and not is_ex: + self.notes_stat["TAP"] += 1 + + self.notes.append(tap_note) + + def del_tap(self, measure: float, position: int) -> None: + """Deletes a tap note from the list of notes. + + Args: + measure: Time when the note starts, in terms of measures. + position: Button where the tap note happens. + + Examples: + Create a break tap note at measure 26.75 at button 4. Then delete it. + + >>> ma2 = MaiMa2() + >>> ma2.add_tap(26.75, 4, is_break=True) + >>> ma2.del_tap(26.75, 4) + """ + tap_notes = [ + x + for x in self.notes + if isinstance(x, TapNote) + and x.measure == measure + and x.position == position + ] + for note in tap_notes: + is_ex = note.note_type in [NoteType.ex_tap, NoteType.ex_star] + is_break = note.note_type in [NoteType.break_tap, NoteType.break_star] + is_star = note.note_type in [ + NoteType.star, + NoteType.ex_star, + NoteType.break_star, + ] + self.notes.remove(note) + if is_ex and is_star: + self.notes_stat["XST"] -= 1 + elif is_ex and not is_star: + self.notes_stat["XTP"] -= 1 + elif is_break and is_star and not is_ex: + self.notes_stat["BST"] -= 1 + elif is_break and not is_star and not is_ex: + self.notes_stat["BRK"] -= 1 + elif not is_break and is_star and not is_ex: + self.notes_stat["STR"] -= 1 + elif not is_break and not is_star and not is_ex: + self.notes_stat["TAP"] -= 1 + + def add_hold( + self, measure: float, position: int, duration: float, is_ex: bool = False + ) -> None: + """Adds a hold note to the list of notes. + + Used to add HLD or XHO to the list of notes. Increments the total + note type produced by 1. + + Args: + measure: Time when the note starts, in terms of measures. + position: Button where the hold note happens. + duration: Total time duration of the hold note. + is_ex: Whether a hold note is an ex note. + + Examples: + Add a regular hold note at button 2 at measure 1, with + duration of 5 measures. And an ex hold note at button + 6 at measure 3, with duration of 0.5 measures. + + >>> ma2 = MaiMa2() + >>> ma2.add_hold(1, 2, 5) + >>> ma2.add_hold(3, 6, 0.5, is_ex=True) + """ + hold_note = HoldNote(measure, position, duration, is_ex) + + if is_ex: + self.notes_stat["XHO"] += 1 + else: + self.notes_stat["HLD"] += 1 + + self.notes.append(hold_note) + + def del_hold(self, measure: float, position: int) -> None: + """Deletes the matching hold note in the list of notes. If there are multiple + matches, all matching notes are deleted. If there are no match, nothing happens. + + Args: + measure: Time when the note starts, in terms of measures. + position: Button where the hold note happens. + + Examples: + Add a regular hold note at button 0 at measure 3.25 with duration of 2 measures + and delete it. + + >>> ma2 = MaiMa2() + >>> ma2.add_hold(3.25, 0, 2) + >>> ma2.del_hold(3.25, 0) + """ + hold_notes = [ + x + for x in self.notes + if isinstance(x, HoldNote) + and x.measure == measure + and x.position == position + ] + for note in hold_notes: + is_ex = note.note_type == NoteType.ex_hold + self.notes.remove(note) + if is_ex: + self.notes_stat["XHO"] -= 1 + else: + self.notes_stat["HLD"] -= 1 + + def add_slide( + self, + measure: float, + start_position: int, + end_position: int, + duration: float, + pattern: int, + delay: float = 0.25, + ) -> None: + """Adds a slide note to the list of notes. + + Used to add SI_, SCL, SCR, SUL, SUR, SSL, SSR, SV_, SXL, SXR, + SLL, SLR, or SF_ to the list of notes. Increments the total + slides produced by 1. + + Args: + measure: Time when the note starts, in terms of measures. + start_position: Starting button. + end_position: Ending button. + pattern: Numerical representation of the slide pattern. + duration: Time duration of when the slide starts moving and + when it ends (delay is not included.) in terms of + measures. + delay: Time duration of when the slide appears and when it + starts to move, in terms of measures. + + Examples: + Add an SUL at measure 2.5 from button 1 to 5, with a duration + of 0.5 measures and default delay. + + >>> ma2 = MaiMa2() + >>> ma2.add_slide(2.5, 1, 5, 0.5, 5) + """ + slide_note = SlideNote( + measure, + start_position, + end_position, + pattern, + duration, + delay, + ) + self.notes_stat["SLD"] += 1 + self.notes.append(slide_note) + + def del_slide(self, measure: float, start_position: int, end_position: int) -> None: + slide_notes = [ + x + for x in self.notes + if isinstance(x, SlideNote) + and x.measure == measure + and x.position == start_position + and x.end_position == end_position + ] + + for note in slide_notes: + self.notes.remove(note) + self.notes_stat["SLD"] -= 1 + + def add_touch_tap( + self, + measure: float, + position: int, + region: str, + is_firework: bool = False, + size: str = "M1", + ) -> None: + """Adds a touch tap note to the list of notes. + + Used to add TTP to the list of notes. Increments the total touch taps + produced by 1. + + Args: + measure: Time when the note starts, in terms of measures. + position: Position in the region where the note happens. + region: Touch region where the note happens. + is_firework: Whether a touch tap note will produce + fireworks. Optional bool, defaults to False. + size: Optional str. Defaults to "M1" + + Examples: + Add a touch tap at measure 0.75 at B1 with fireworks. + + >>> ma2 = MaiMa2() + >>> ma2.add_touch_tap(0.75, 1, "B", is_firework=True) + """ + touch_tap = TouchTapNote(measure, position, region, is_firework, size) + self.notes_stat["TTP"] += 1 + self.notes.append(touch_tap) + + def del_touch_tap(self, measure: float, position: int, region: str) -> None: + touch_taps = [ + x + for x in self.notes + if isinstance(x, TouchTapNote) + and x.measure == measure + and x.position == position + and x.region == region + ] + for note in touch_taps: + self.notes.remove(note) + self.notes_stat["TTP"] -= 1 + + def add_touch_hold( + self, + measure: float, + position: int, + region: str, + duration: float, + is_firework: bool = False, + size: str = "M1", + ) -> None: + """Adds a touch hold note to the list of notes. + + Used to add THO to the list of notes. Increments the total touch holds + produced by 1. + + Args: + measure: Time when the note starts, in terms of measures. + position: Position in the region where the note happens. + region: Touch region where the note happens. + duration: Total time duration of the touch hold note. + is_firework: Whether a touch hold note will produce + fireworks. Optional bool, defaults to False. + size: Optional str. Defaults to "M1" + + Examples: + Add a touch hold at measure 2 at C0 with duration of + 2 measures and produces fireworks. + + >>> ma2 = MaiMa2() + >>> ma2.add_touch_hold(2, 0, "C", 2, is_firework=True) + """ + touch_tap = TouchHoldNote( + measure, position, region, duration, is_firework, size + ) + self.notes_stat["THO"] += 1 + self.notes.append(touch_tap) + + def del_touch_hold(self, measure: float, position: int, region: str) -> None: + touch_holds = [ + x + for x in self.notes + if isinstance(x, TouchHoldNote) + and x.measure == measure + and x.position == position + and x.region == region + ] + for note in touch_holds: + self.notes.remove(note) + self.notes_stat["THO"] -= 1 + + def offset(self, offset: Union[float, str]) -> None: + initial_bpm = self.get_bpm(1.0) + if isinstance(offset, float): + offset_abs = measure_to_second(offset, initial_bpm) + elif isinstance(offset, str) and offset[-1] in ["s", "S"]: + offset_abs = float(offset[:-1]) + elif isinstance(offset, str) and "/" in offset: + fraction = offset.split("/") + if len(fraction) != 2: + raise ValueError(f"Invalid fraction: {offset}") + + offset_abs = measure_to_second( + int(fraction[0]) / int(fraction[1]), initial_bpm + ) + else: + offset_abs = measure_to_second(float(offset), initial_bpm) + + for note in self.notes: + if note.measure <= 1.0: + current_eps_bpm = initial_bpm + else: + current_eps_bpm = self.get_bpm(note.measure - 0.0001) + + offset_compensated = second_to_measure(offset_abs, current_eps_bpm) + note.measure = ( + round((note.measure + offset_compensated) * 10000.0) / 10000.0 + ) + + new_bpms = copy.deepcopy(self.bpms) + for event in new_bpms: + if event.measure <= 1.0: + continue + + current_eps_bpm = self.get_bpm(event.measure - 0.0001) + offset_compensated = second_to_measure(offset_abs, current_eps_bpm) + event.measure = ( + round((event.measure + offset_compensated) * 10000.0) / 10000.0 + ) + + new_meters = copy.deepcopy(self.meters) + for meter in new_meters: + if meter.measure <= 1.0: + continue + + current_eps_bpm = self.get_bpm(meter.measure - 0.0001) + offset_compensated = second_to_measure(offset_abs, current_eps_bpm) + meter.measure = ( + round((meter.measure + offset_compensated) * 10000.0) / 10000.0 + ) + + self.bpms = new_bpms + self.meters = new_meters + + def export(self) -> str: + """Generates a ma2 text from all the notes and events defined. + + Returns: + A multiline string. The returned + string is a complete and functioning ma2 text and should + be stored as-is in a text file with a .ma2 file extension. + """ + result = self.get_header() + result += "\n" + result += self.get_breakdown() + result += "\n" + + self.notes.sort() + for note in self.notes: + result += event_to_str(event=note, resolution=self.resolution) + + result += "\n" + result += self.get_epilog() + result += "\n" + return result diff --git a/maiconverter/maima2/tools.py b/maiconverter/maima2/tools.py new file mode 100644 index 0000000..925ec30 --- /dev/null +++ b/maiconverter/maima2/tools.py @@ -0,0 +1,108 @@ +from typing import List + +from .ma2note import note_dict, slide_dict + +_ignored_v1 = [ + "VERSION", + "FES_MODE", + "BPM_DEF", + "MET_DEF", + "CLK_DEF", + "CLK", + "COMPATIBLE_CODE", + "T_REC_TAP", + "T_REC_BRK", + "T_REC_XTP", + "T_REC_HLD", + "T_REC_XHO", + "T_REC_STR", + "T_REC_BST", + "T_REC_XST", + "T_REC_TTP", + "T_REC_THO", + "T_REC_SLD", + "T_REC_ALL", + "T_NUM_TAP", + "T_NUM_BRK", + "T_NUM_HLD", + "T_NUM_SLD", + "T_NUM_ALL", + "T_JUDGE_TAP", + "T_JUDGE_HLD", + "T_JUDGE_SLD", + "T_JUDGE_ALL", + "TTM_EACHPAIRS", + "TTM_SCR_TAP", + "TTM_SCR_BRK", + "TTM_SCR_HLD", + "TTM_SCR_SLD", + "TTM_SCR_ALL", + "TTM_SCR_S", + "TTM_SCR_SS", + "TTM_RAT_ACV", +] + + +def parse_v1(ma2, values: List[str]) -> None: + """Ma2 line parser for versions 1.02.00 and 1.03.00 currently.""" + # For notes and events, the first value is a 3-character text + line_type = values[0] + # Create a list of all valid ma2 note and slide types + if line_type in _ignored_v1: + # Ignore some parts of the header and all summary statistics lines + return + if line_type == "RESOLUTION": + # Set the max number of ticks in a measure + ma2.resolution = int(values[1]) + elif line_type == "BPM": + # Set the BPM for a measure + measure = float(values[1]) + float(values[2]) / ma2.resolution + bpm = float(values[3]) + ma2.set_bpm(measure, bpm) + elif line_type == "MET": + # Set MET event + measure = float(values[1]) + float(values[2]) / ma2.resolution + ma2.set_meter(measure, int(values[3]), int(values[4])) + elif line_type in list(note_dict.keys()): + _handle_notes_v1(ma2, values) + elif line_type in list(slide_dict.keys()): + _handle_slides_v1(ma2, values) + else: + print(f"Warning: Ignoring unknown line type {line_type}") + + +def _handle_notes_v1(ma2, values: List[str]) -> None: + line_type = values[0] + measure = float(values[1]) + float(values[2]) / ma2.resolution + position = int(values[3]) + if line_type in ["TAP", "BRK", "XTP", "STR", "BST", "XST"]: + is_break = line_type in ["BRK", "BST"] + is_ex = line_type in ["XTP", "XST"] + is_star = line_type in ["STR", "BST", "XST"] + ma2.add_tap(measure, position, is_break, is_star, is_ex) + elif line_type in ["XHO", "HLD"]: + is_ex = line_type == "XHO" + duration = float(values[4]) / ma2.resolution + ma2.add_hold(measure, position, duration, is_ex) + elif line_type == "TTP": + region = values[4] + is_firework = values[5] == "1" + size = values[6] if len(values) > 6 else "M1" + ma2.add_touch_tap(measure, position, region, is_firework, size) + elif line_type == "THO": + duration = float(values[4]) / ma2.resolution + region = values[5] + is_firework = values[6] == "1" + size = values[7] if len(values) > 7 else "M1" + ma2.add_touch_hold(measure, position, region, duration, is_firework, size) + + +def _handle_slides_v1(ma2, values: List[str]) -> None: + line_type = values[0] + pattern = slide_dict[line_type] + measure = float(values[1]) + float(values[2]) / ma2.resolution + start_position = int(values[3]) + delay = int(values[4]) / ma2.resolution + duration = int(values[5]) / ma2.resolution + end_position = int(values[6]) + ma2.add_slide(measure, start_position, end_position, duration, pattern, delay) diff --git a/maiconverter/maisdt.py b/maiconverter/maisdt.py deleted file mode 100644 index 6d9ca64..0000000 --- a/maiconverter/maisdt.py +++ /dev/null @@ -1,486 +0,0 @@ -import math - -from .note import MaiNote, NoteType - - -class MaiSDTStarNote(MaiNote): - def __init__( - self, measure: float, position: int, amount: int, is_break: bool = False - ) -> None: - """Produces an sdt star note. - - Note: - Please use MaiSDT class' add_tap method for adding taps. - - Args: - measure: Time when the note starts, in terms of measures. - position: Button where the star note happens. - amount: Amount of slides a star notes produces. - is_break: Whether a star note is a break note. - - Raises: - ValueError: When amount is negative - """ - if amount < 0: - raise ValueError("Star note amount is negative " + str(amount)) - - measure = round(10000.0 * measure) / 10000.0 - - if is_break: - super().__init__(measure, position, NoteType.break_star) - else: - super().__init__(measure, position, NoteType.star) - - self.amount = amount - - def __str__(self) -> str: - return sdt_note_to_str(self) - - -class MaiSDTSlideStartNote(MaiNote): - def __init__( - self, - measure: float, - position: int, - slide_id: int, - pattern: int, - duration: float, - delay: float = 0.25, - ) -> None: - """Produces an sdt slide start note. - - Note: - Please use MaiSDT class' add_slide method for adding - slide start and slide end. - - Args: - measure: Time when the slide starts, in terms of measures. - position: Button where the slide starts. - slide_id: Unique non-zero integer for slide start - and end pair. - pattern: Numerical representation of the slide pattern. - duration: Total duration of the slide, in terms of - measures. Includes slide delay. - delay: Duration from when the slide appears and when it - starts to move, in terms of measures. - """ - if slide_id <= 0: - raise ValueError("Slide id is not positive " + str(slide_id)) - elif pattern <= 0: - raise ValueError("Slide pattern is not positive " + str(pattern)) - elif duration <= 0: - raise ValueError("Slide duration is not positive " + str(duration)) - elif delay < 0: - raise ValueError("Slide delay is negative " + str(delay)) - - measure = round(10000.0 * measure) / 10000.0 - duration = round(10000.0 * duration) / 10000.0 - delay = round(10000.0 * delay) / 10000.0 - super().__init__(measure, position, NoteType.start_slide) - self.slide_id = slide_id - self.pattern = pattern - self.delay = delay - self.duration = duration - - def __str__(self) -> str: - return sdt_note_to_str(self) - - -class MaiSDTSlideEndNote(MaiNote): - def __init__( - self, measure: float, position: int, slide_id: int, pattern: int - ) -> None: - """Produces an sdt slide end note. - - Note: - Please use MaiSDT class' add_slide method for adding - slide start and slide end. - - Args: - measure: Time when the slide ends, in terms of measures. - position: Button where the slide ends. - slide_id: Unique non-zero integer for slide start - and end pair. - pattern: Numerical representation of the slide pattern. - """ - if slide_id <= 0: - raise ValueError("Slide id is not positive " + str(slide_id)) - elif pattern <= 0: - raise ValueError("Slide pattern is not positive " + str(pattern)) - - measure = round(10000 * measure) / 10000 - super().__init__(measure, position, NoteType.end_slide) - self.slide_id = slide_id - self.pattern = pattern - - def __str__(self) -> str: - return sdt_note_to_str(self) - - -class MaiSDTHoldNote(MaiNote): - def __init__(self, measure: float, position: int, duration: float) -> None: - """Produces an sdt hold note. - - Note: - Please use MaiSDT class' add_hold method for adding - hold notes. - - Args: - measure: Time when the hold starts, in terms of measures. - position: Button where the hold happens. - duration: Total duration of the hold, in terms of measures. - """ - if duration <= 0: - raise ValueError("Slide duration is not positive " + str(duration)) - - measure = round(10000.0 * measure) / 10000.0 - duration = round(10000.0 * duration) / 10000.0 - super().__init__(measure, position, NoteType.hold) - self.duration = duration - - def __str__(self) -> str: - return sdt_note_to_str(self) - - -class MaiSDTTapNote(MaiNote): - def __init__(self, measure: float, position: int, is_break: bool = False) -> None: - """Produces an sdt tap note. - - Note: - Please use MaiSDT class' add_tap method for adding - tap notes. - - Args: - measure: Time when the tap happens, in terms of measures. - position: Button where the tap note happens. - is_break: Whether a tap note is a break note. - """ - measure = round(10000.0 * measure) / 10000.0 - if is_break: - super().__init__(measure, position, NoteType.break_tap) - else: - super().__init__(measure, position, NoteType.tap) - - def __str__(self) -> str: - return sdt_note_to_str(self) - - -def sdt_note_to_str(note: MaiNote) -> str: - """Prints note into sdt-compatible lines. - - Args: - note: A MaiNote to be converted to a sdt string. - - Returns: - A single line string. - """ - measure = math.modf(note.measure) - note_type = note.note_type - if isinstance(note, (MaiSDTHoldNote, MaiSDTSlideStartNote)): - note_duration = note.duration - elif isinstance(note, MaiSDTSlideEndNote): - note_duration = 0 - else: - note_duration = 0.0625 - - if isinstance(note, (MaiSDTSlideStartNote, MaiSDTSlideEndNote)): - slide_id = note.slide_id - pattern = note.pattern - else: - slide_id = 0 - pattern = 0 - - if isinstance(note, MaiSDTStarNote): - slide_amount = note.amount - else: - slide_amount = 0 - - delay = 0 if note_type != NoteType.start_slide else note.delay - line_template = ( - "{:.4f}, {:.4f}, {:.4f}, {:2d}, {:3d}, {:3d}, {:2d}, {:2d}, {:.4f},\n" - ) - result = line_template.format( - measure[1], - measure[0], - note_duration, - note.position, - note_type.value, - slide_id, - pattern, - slide_amount, - delay, - ) - return result - - -class MaiSDT: - """A class that represents one sdt chart. Only contains notes, - and does not include information such as bpm, song name, - chart difficulty, composer, chart maker, etc. - It only contains enough information to build a working sdt - chart file. - - Attributes: - notes (list[MaiNote]): Contains notes of the chart. - """ - - def __init__(self) -> None: - self.notes = [] - self.start_slide_notes = {} - self.slide_count = 1 - - def parse_line(self, line: str) -> None: - values = line.rstrip().rstrip(",").replace(" ", "").split(",") - if not (len(values) >= 7 and len(values) <= 9): - raise Exception("Line has invalid number of columns! {}".format(len(line))) - measure = float(values[0]) + float(values[1]) - position = int(values[3]) - note_type = int(values[4]) - - if note_type == 1: # Regular tap note - self.add_tap(measure, position) - elif note_type == 2: # Hold note - duration = float(values[2]) - self.add_hold(measure, position, duration) - elif note_type == 3: # Break tap note - self.add_tap(measure, position, is_break=True) - elif note_type in [4, 5]: # Regular star note - if len(values) > 7: - # SCT and later - amount = int(values[7]) - else: - amount = 1 - - is_break = True if note_type == 5 else False - self.add_star(measure, position, amount, is_break) - elif note_type == 0: # Start slide - if len(values) == 9: # SDT includes delay - delay = float(values[8]) - else: - delay = 0.25 - - duration = float(values[2]) - slide_id = int(values[5]) - pattern = int(values[6]) - start_slide = { - "position": position, - "measure": measure, - "duration": duration, - "delay": delay, - "pattern": pattern, - } - self.start_slide_notes[slide_id] = start_slide - elif note_type == 128: # End slide - slide_id = int(values[5]) - - # Get information about corresponding start slide - start_slide = self.start_slide_notes.get(slide_id) - if start_slide is None: - raise Exception("End slide is declared before slide start!") - - start_measure = start_slide.get("measure") - start_position = start_slide.get("position") - duration = start_slide.get("duration") - delay = start_slide.get("delay") - pattern = start_slide.get("pattern") - self.add_slide( - start_measure, - start_position, - position, - pattern, - duration, - self.slide_count, - delay, - ) - self.slide_count += 1 - else: - raise TypeError("Unknown note type {}".format(note_type)) - - def parse_srt_line(self, line: str) -> None: - srt_slide_to_later_dict = { - 0: 1, - 1: 3, - 2: 2, - } - values = line.rstrip().rstrip(",").replace(" ", "").split(",") - if len(values) != 7: - raise Exception("SRT should have 7 columns. Given: {}".format(line)) - - measure = float(values[0]) + float(values[1]) - position = int(values[3]) - note_type = int(values[4]) - - if note_type == 0: # Regular tap note, or star and start slide note - slide_id = int(values[5]) - # Tap notes with a non-zero slide id are stars and start slide - if slide_id != 0: - self.add_star(measure, position, 1) - delay = 0.25 - duration = float(values[2]) - # Slide patterns in SZT, and later, starts at 1 - pattern = srt_slide_to_later_dict.get(int(values[6])) - start_slide = { - "position": position, - "measure": measure, - "duration": duration, - "delay": delay, - "pattern": pattern, - } - self.start_slide_notes[slide_id] = start_slide - else: - self.add_tap(measure, position) - elif note_type == 2: # Hold note - duration = float(values[2]) - self.add_hold(measure, position, duration) - elif note_type == 4: # Break tap note - self.add_tap(measure, position, is_break=True) - elif note_type == 128: - slide_id = int(values[5]) - # Get information about corresponding start slide - start_slide = self.start_slide_notes.get(slide_id) - if start_slide is None: - raise Exception("End slide is declared before slide start!") - - start_measure = start_slide.get("measure") - start_position = start_slide.get("position") - duration = start_slide.get("duration") - delay = start_slide.get("delay") - pattern = start_slide.get("pattern") - self.add_slide( - start_measure, - start_position, - position, - pattern, - duration, - self.slide_count, - delay, - ) - self.slide_count += 1 - else: - raise TypeError("Unknown note type {}".format(note_type)) - - def add_tap(self, measure: float, position: int, is_break: bool = False) -> None: - """Adds a tap note to the object. - - Args: - measure: Time when the tap happens, in terms of measures. - position: Button where the tap note happens. - is_break: Whether a tap note is a break note. - - Examples: - Add a regular tap note at measure 1 at button 2, - and a break tap note at measure 2 at button 7. - - >>> sdt = MaiSDT() - >>> sdt.add_tap(1, 2) - >>> sdt.add_tap(2, 7, is_break=True) - """ - tap_note = MaiSDTTapNote(measure, position, is_break) - self.notes.append(tap_note) - - def add_hold(self, measure: float, position: int, duration: float) -> None: - """Adds a hold note to the object. - - Args: - measure: Time when the hold starts, in terms of measures. - position: Button where the hold happens. - duration: Total duration of the hold, in terms of measures. - - Examples: - Add a regular hold note at button 5 at measure 1.5, with - duration of 2.75 measures. - - >>> sdt = MaiSDT() - >>> sdt.add_hold(1.5, 5, 2.75) - """ - hold_note = MaiSDTHoldNote(measure, position, duration) - self.notes.append(hold_note) - - def add_star( - self, measure: float, position: int, amount: int, is_break: bool = False - ) -> None: - """Adds a star note to the object. - - Args: - measure: Time when the note starts, in terms of measures. - position: Button where the star note happens. - amount: Amount of slides a star notes produces. - is_break: Whether a star note is a break note. - - Examples: - Add a regular star note at button 3 at measure 1, which - produces 1 slide. And a break star note at button 1 at - measure 2.5, which produces no slides. - - >>> sdt = MaiSDT() - >>> sdt.add_star(1, 3, 1) - >>> sdt.add_star(2.5, 1, 0, is_break=True) - """ - star_note = MaiSDTStarNote(measure, position, amount, is_break) - self.notes.append(star_note) - - # Note: SDT slide duration includes delay - def add_slide( - self, - start_measure: float, - start_position: int, - end_position: int, - pattern: int, - duration: float, - slide_id: int, - delay: float = 0.25, - ) -> None: - """Adds a star note to the object. - - Note: - Having two pairs of slides with the same slide id will - produce undefined behavior. - - Args: - start_measure: Time when the slide starts, in - terms of measures. - start_position: Button where the slide starts. - end_position: Button where the slide ends. - pattern: Numerical representation of the slide pattern. - duration: Total duration of the slide, in terms of - measures. Includes slide delay. - slide_id: Unique non-zero integer for slide start - and end pair. - delay: Duration from when the slide appears and when it - starts to move, in terms of measures. - - Examples: - Add a slide at measure 2 from button 6 to button 3 with - duration of 1.75 measures, delay of 0.25 measures, - pattern of 1, and slide id of 7. - - >>> sdt = MaiSDT() - >>> sdt.add_slide(2, 6, 3, 1, 1.75, 7) - """ - start_slide = MaiSDTSlideStartNote( - start_measure, start_position, slide_id, pattern, duration, delay - ) - end_measure = start_measure + duration - end_slide = MaiSDTSlideEndNote(end_measure, end_position, slide_id, pattern) - self.notes.append(start_slide) - self.notes.append(end_slide) - - def offset(self, offset: float) -> None: - for note in self.notes: - new_measure = round((note.measure + offset) * 10000.0) / 10000.0 - note.measure = new_measure - - def export(self) -> str: - """Generates an sdt text from all the notes defined. - - Returns: - A multiline string. The returned - string is a complete and functioning sdt text and should - be stored as-is in a text file with an sdt file extension. - """ - self.notes.sort() - result = "" - for note in self.notes: - result += str(note) - - return result diff --git a/maiconverter/maisdt/__init__.py b/maiconverter/maisdt/__init__.py new file mode 100644 index 0000000..7bc8841 --- /dev/null +++ b/maiconverter/maisdt/__init__.py @@ -0,0 +1,2 @@ +from .maisdt import * +from .sdtnote import * diff --git a/maiconverter/maisdt/maisdt.py b/maiconverter/maisdt/maisdt.py new file mode 100644 index 0000000..a78b386 --- /dev/null +++ b/maiconverter/maisdt/maisdt.py @@ -0,0 +1,417 @@ +from __future__ import annotations +import re +from typing import Union, List, Dict, Optional + +from .sdtnote import TapNote, HoldNote, SlideStartNote, SlideEndNote +from ..event import NoteType +from ..time import second_to_measure + + +class MaiSDT: + """A class that represents one sdt chart. Only contains notes, + and does not include information such as bpm, song name, + chart difficulty, composer, chart maker, etc. + It only contains enough information to build a working sdt + chart file. + + Attributes: + notes: Contains notes of the chart. + """ + + def __init__(self) -> None: + self.notes: List[Union[TapNote, HoldNote, SlideStartNote, SlideEndNote]] = [] + self.start_slide_notes: Dict[int, Dict[str, Union[int, float]]] = {} + self.slide_count = 1 + + @classmethod + def open(cls, path: str) -> MaiSDT: + sdt = cls() + with open(path, "r") as file: + for line in file: + if line in ["\n", "\r\n"]: + continue + + if re.search(r"\.srt", path) is None: + sdt.parse_line(line) + else: + sdt.parse_srt_line(line) + + return sdt + + def parse_line(self, line: str) -> None: + """Parse a non-SRT comma-separated line. + + Args: + line: A non-SRT comma-separated line. + + Raises: + ValueError: When the number of columns are not between 7 and 9. + RuntimeError: When an end slide is declared before an beginning slide. + TypeError: When an unknown note type is given, + """ + values = line.rstrip().rstrip(",").replace(" ", "").split(",") + if not (7 <= len(values) <= 9): + raise ValueError(f"Line has invalid number of columns! {len(values)}") + measure = float(values[0]) + float(values[1]) + position = int(values[3]) + note_type = int(values[4]) + + if note_type in [1, 3, 4, 5]: + # Regular tap note, break tap note, star note, + is_star = note_type in [4, 5] + is_break = note_type in [3, 5] + self.add_tap( + measure=measure, position=position, is_break=is_break, is_star=is_star + ) + elif note_type == 2: + # Hold note + duration = float(values[2]) + self.add_hold(measure, position, duration) + elif note_type == 0: # Start slide + if len(values) == 9: + # SDT includes delay + delay = float(values[8]) + else: + delay = 0.25 + + duration = float(values[2]) + slide_id = int(values[5]) + pattern = int(values[6]) + start_slide = { + "position": position, + "measure": measure, + "duration": duration, + "delay": delay, + "pattern": pattern, + } + self.start_slide_notes[slide_id] = start_slide + elif note_type == 128: # End slide + slide_id = int(values[5]) + + # Get information about corresponding start slide + if slide_id not in self.start_slide_notes: + raise RuntimeError("End slide is declared before slide start!") + + start_slide = self.start_slide_notes[slide_id] + start_measure = start_slide["measure"] + start_position = start_slide["position"] + duration = start_slide["duration"] + delay = start_slide["delay"] + slide_pattern = start_slide["pattern"] + self.add_slide( + start_measure, + int(start_position), + position, + duration, + int(slide_pattern), + delay, + ) + else: + raise TypeError("Unknown note type {}".format(note_type)) + + def parse_srt_line(self, line: str) -> None: + """Parse an SRT comma-separated line. + + Args: + line: An SRT comma-separated line. + + Raises: + ValueError: When the number of columns are not between 7 and 9. + RuntimeError: When an end slide is declared before an beginning slide. + TypeError: When an unknown note type is given, + """ + srt_slide_to_later_dict = { + 0: 1, + 1: 3, + 2: 2, + } + values = line.rstrip().rstrip(",").replace(" ", "").split(",") + if len(values) != 7: + raise ValueError(f"SRT should have 7 columns. Given: {len(values)}") + + measure = float(values[0]) + float(values[1]) + position = int(values[3]) + note_type = int(values[4]) + + if note_type in [0, 4]: + # 0: Regular tap note or star and start slide note + # 4: Break tap note + slide_id = int(values[5]) + if slide_id != 0: + # Tap notes with a non-zero slide id are stars and start slide + self.add_tap(measure=measure, position=position, is_star=True) + duration = float(values[2]) + # Slide patterns in SZT, and later, starts at 1 + pattern = srt_slide_to_later_dict[int(values[6])] + start_slide: Dict[str, Union[float, int]] = { + "position": position, + "measure": measure, + "duration": duration, + "pattern": pattern, + } + self.start_slide_notes[slide_id] = start_slide + else: + is_break = note_type == 4 + self.add_tap(measure=measure, position=position, is_break=is_break) + elif note_type == 2: # Hold note + duration = float(values[2]) + self.add_hold(measure, position, duration) + elif note_type == 128: + slide_id = int(values[5]) + # Get information about corresponding start slide + if slide_id not in self.start_slide_notes: + raise RuntimeError("End slide is declared before slide start!") + + start: Dict[str, Union[float, int]] = self.start_slide_notes[slide_id] + start_measure = start["measure"] + start_position = start["position"] + duration = start["duration"] + slide_pattern = start["pattern"] + self.add_slide( + start_measure, + int(start_position), + position, + duration, + int(slide_pattern), + ) + else: + raise TypeError("Unknown note type {}".format(note_type)) + + def add_tap( + self, + measure: float, + position: int, + is_break: bool = False, + is_star: bool = False, + ) -> None: + """Adds a tap note to the list of notes. + + Args: + measure: Time when the tap happens, in terms of measures. + position: Button where the tap note happens. + is_break: Whether a tap note is a break note. + is_star: Whether a tap note is a star note. + + Examples: + Add a regular tap note at measure 1 at button 2, + and a break tap note at measure 2 at button 7. + + >>> sdt = MaiSDT() + >>> sdt.add_tap(1, 2) + >>> sdt.add_tap(2, 7, is_break=True) + """ + tap_note = TapNote( + measure=measure, position=position, is_break=is_break, is_star=is_star + ) + self.notes.append(tap_note) + + def del_tap(self, measure: float, position: int) -> None: + """Deletes a tap note from the list of notes. + + Args: + measure: Time when the note starts, in terms of measures. + position: Button where the tap note happens. + + Examples: + Create a break tap note at measure 26.75 at button 4. Then delete it. + + >>> sdt = MaiSDT() + >>> sdt.add_tap(26.75, 4, is_break=True) + >>> sdt.del_tap(26.75, 4) + """ + tap_notes = [ + x + for x in self.notes + if isinstance(x, TapNote) + and x.measure == measure + and x.position == position + ] + for note in tap_notes: + self.notes.remove(note) + + def add_hold(self, measure: float, position: int, duration: float) -> None: + """Adds a hold note to the list of notes. + + Args: + measure: Time when the hold starts, in terms of measures. + position: Button where the hold happens. + duration: Total duration of the hold, in terms of measures. + + Examples: + Add a regular hold note at button 5 at measure 1.5, with + duration of 2.75 measures. + + >>> sdt = MaiSDT() + >>> sdt.add_hold(1.5, 5, 2.75) + """ + hold_note = HoldNote(measure=measure, position=position, duration=duration) + self.notes.append(hold_note) + + def del_hold(self, measure: float, position: int) -> None: + """Deletes the matching hold note in the list of notes. If there are multiple + matches, all matching notes are deleted. If there are no match, nothing happens. + + Args: + measure: Time when the note starts, in terms of measures. + position: Button where the hold note happens. + + Examples: + Add a regular hold note at button 0 at measure 3.25 with duration of 2 measures + and delete it. + + >>> sdt = MaiSDT() + >>> sdt.add_hold(3.25, 0, 2) + >>> sdt.del_hold(3.25, 0) + """ + hold_notes = [ + x + for x in self.notes + if isinstance(x, HoldNote) + and x.measure == measure + and x.position == position + ] + for note in hold_notes: + self.notes.remove(note) + + # Note: SDT slide duration includes delay + def add_slide( + self, + measure: float, + start_position: int, + end_position: int, + duration: float, + pattern: int, + delay: float = 0.25, + ) -> None: + """Adds both a start slide and end slide note to the list of notes. + + Args: + measure: Time when the slide starts, in + terms of measures. + start_position: Button where the slide starts. + end_position: Button where the slide ends. + duration: Total duration of the slide, in terms of + measures. Includes slide delay. + pattern: Numerical representation of the slide pattern. + delay: Duration from when the slide appears and when it + starts to move, in terms of measures. Defaults to 0.25. + + Examples: + Add a slide at measure 2 from button 6 to button 3 with + duration of 1.75 measures, delay of 0.25 measures, + pattern of 1. + + >>> sdt = MaiSDT() + >>> sdt.add_slide(2, 6, 3, 1.75, 1) + """ + slide_id = self.slide_count + start_slide = SlideStartNote( + measure=measure, + position=start_position, + pattern=pattern, + duration=duration, + slide_id=slide_id, + delay=delay, + ) + end_measure = measure + duration + end_slide = SlideEndNote( + measure=end_measure, + position=end_position, + pattern=pattern, + slide_id=slide_id, + ) + self.notes.append(start_slide) + self.notes.append(end_slide) + self.slide_count += 1 + + star_notes = [ + x + for x in self.notes + if isinstance(x, TapNote) + and x.note_type in [NoteType.star, NoteType.break_star] + and x.measure == measure + and x.position == start_position + ] + for star_note in star_notes: + star_note.amount += 1 + + def del_slide(self, measure: float, start_position: int, end_position: int) -> None: + start_slides = [ + x + for x in self.notes + if isinstance(x, SlideStartNote) + and x.measure == measure + and x.position == start_position + ] + end_slides: List[SlideEndNote] = [] + for note in start_slides: + slides = [ + x + for x in self.notes + if isinstance(x, SlideEndNote) + and x.slide_id == note.slide_id + and x.position == end_position + ] + end_slides += slides + + correct_start_slides = [] + for note in end_slides: + slides = [ + x + for x in self.notes + if isinstance(x, SlideStartNote) and x.slide_id == note.slide_id + ] + correct_start_slides += slides + + star_notes = [ + x + for x in self.notes + if isinstance(x, TapNote) + and x.note_type in [NoteType.star, NoteType.break_star] + and x.measure == measure + and x.position == start_position + ] + + for note in correct_start_slides: + self.notes.remove(note) + + for note in end_slides: + self.notes.remove(note) + + for star_note in star_notes: + star_note.amount -= 1 + + def offset(self, offset: Union[float, str], bpm: Optional[float] = None) -> None: + if isinstance(offset, float): + offset_mes = offset + elif isinstance(offset, str) and offset[-1] in ["s", "S"]: + if bpm is None: + raise ValueError("No BPM given") + + offset_mes = second_to_measure(float(offset[:-1]), bpm) + elif isinstance(offset, str) and "/" in offset: + fraction = offset.split("/") + if len(fraction) != 2: + raise ValueError(f"Invalid fraction: {offset}") + + offset_mes = int(fraction[0]) / int(fraction[1]) + else: + offset_mes = float(offset) + + for note in self.notes: + note.measure = round((note.measure + offset_mes) * 10000.0) / 10000.0 + + def export(self) -> str: + """Generates an sdt text from all the notes defined. + + Returns: + A multiline string. The returned + string is a complete and functioning sdt text and should + be stored as-is in a text file with an sdt file extension. + """ + self.notes.sort() + result = "" + for note in self.notes: + result += str(note) + + return result diff --git a/maiconverter/maisdt/sdtnote.py b/maiconverter/maisdt/sdtnote.py new file mode 100644 index 0000000..80c24b6 --- /dev/null +++ b/maiconverter/maisdt/sdtnote.py @@ -0,0 +1,197 @@ +import math +from typing import Union + +from ..event import MaiNote, NoteType + + +class TapNote(MaiNote): + def __init__( + self, + measure: float, + position: int, + is_break: bool = False, + is_star: bool = False, + ) -> None: + """Produces an sdt tap note. + + Note: + Please use MaiSDT class' add_tap method for adding + tap notes. + + Args: + measure: Time when the tap happens, in terms of measures. + position: Button where the tap note happens. + is_break: Whether a tap note is a break note. + """ + measure = round(10000.0 * measure) / 10000.0 + if is_break and is_star: + super().__init__(measure, position, NoteType.break_star) + self.amount = 0 + elif is_break: + super().__init__(measure, position, NoteType.break_tap) + elif is_star: + super().__init__(measure, position, NoteType.star) + self.amount = 0 + else: + super().__init__(measure, position, NoteType.tap) + + def __str__(self) -> str: + return sdt_note_to_str(self) + + +class SlideStartNote(MaiNote): + def __init__( + self, + measure: float, + position: int, + pattern: int, + duration: float, + slide_id: int, + delay: float = 0.25, + ) -> None: + """Produces an sdt slide start note. + + Note: + Please use MaiSDT class' add_slide method for adding + slide start and slide end. + + Args: + measure: Time when the slide starts, in terms of measures. + position: Button where the slide starts. + slide_id: Unique non-zero integer for slide start + and end pair. + pattern: Numerical representation of the slide pattern. + duration: Total duration of the slide, in terms of + measures. Includes slide delay. + delay: Duration from when the slide appears and when it + starts to move, in terms of measures. + """ + if slide_id <= 0: + raise ValueError("Slide id is not positive " + str(slide_id)) + if pattern <= 0: + raise ValueError("Slide pattern is not positive " + str(pattern)) + if duration <= 0: + raise ValueError("Slide duration is not positive " + str(duration)) + if delay < 0: + raise ValueError("Slide delay is negative " + str(delay)) + + measure = round(10000.0 * measure) / 10000.0 + duration = round(10000.0 * duration) / 10000.0 + delay = round(10000.0 * delay) / 10000.0 + super().__init__(measure, position, NoteType.start_slide) + self.slide_id = slide_id + self.pattern = pattern + self.delay = delay + self.duration = duration + + def __str__(self) -> str: + return sdt_note_to_str(self) + + +class SlideEndNote(MaiNote): + def __init__( + self, measure: float, position: int, pattern: int, slide_id: int + ) -> None: + """Produces an sdt slide end note. + + Note: + Please use MaiSDT class' add_slide method for adding + slide start and slide end. + + Args: + measure: Time when the slide ends, in terms of measures. + position: Button where the slide ends. + slide_id: Unique non-zero integer for slide start + and end pair. + pattern: Numerical representation of the slide pattern. + """ + if slide_id <= 0: + raise ValueError("Slide id is not positive " + str(slide_id)) + if pattern <= 0: + raise ValueError("Slide pattern is not positive " + str(pattern)) + + measure = round(10000 * measure) / 10000 + super().__init__(measure, position, NoteType.end_slide) + self.slide_id = slide_id + self.pattern = pattern + + def __str__(self) -> str: + return sdt_note_to_str(self) + + +class HoldNote(MaiNote): + def __init__(self, measure: float, position: int, duration: float) -> None: + """Produces an sdt hold note. + + Note: + Please use MaiSDT class' add_hold method for adding + hold notes. + + Args: + measure: Time when the hold starts, in terms of measures. + position: Button where the hold happens. + duration: Total duration of the hold, in terms of measures. + """ + if duration < 0: + raise ValueError(f"Hold duration is negative: {duration}") + + measure = round(10000.0 * measure) / 10000.0 + duration = round(10000.0 * duration) / 10000.0 + super().__init__(measure, position, NoteType.hold) + self.duration = duration + + def __str__(self) -> str: + return sdt_note_to_str(self) + + +def sdt_note_to_str( + note: Union[TapNote, HoldNote, SlideEndNote, SlideStartNote] +) -> str: + """Prints note into sdt-compatible lines. + + Args: + note: A MaiNote to be converted to a sdt string. + + Returns: + A single line string. + """ + measure = math.modf(note.measure) + note_type = note.note_type + if isinstance(note, (HoldNote, SlideStartNote)): + note_duration = note.duration + elif isinstance(note, SlideEndNote): + note_duration = 0 + else: + note_duration = 0.0625 + + if isinstance(note, (SlideStartNote, SlideEndNote)): + slide_id = note.slide_id + pattern = note.pattern + else: + slide_id = 0 + pattern = 0 + + if isinstance(note, TapNote) and note.note_type in [ + NoteType.star, + NoteType.break_star, + ]: + slide_amount = note.amount + else: + slide_amount = 0 + + delay = 0.0 if not isinstance(note, SlideStartNote) else note.delay + line_template = ( + "{:.4f}, {:.4f}, {:.4f}, {:2d}, {:3d}, {:3d}, {:2d}, {:2d}, {:.4f},\n" + ) + result = line_template.format( + measure[1], + measure[0], + note_duration, + note.position, + note_type.value, + slide_id, + pattern, + slide_amount, + delay, + ) + return result diff --git a/maiconverter/maisdttosimai.py b/maiconverter/maisdttosimai.py deleted file mode 100644 index 4b3c3c2..0000000 --- a/maiconverter/maisdttosimai.py +++ /dev/null @@ -1,80 +0,0 @@ -from dataclasses import dataclass -from typing import List - -from .simai import SimaiChart, simai_pattern_from_int -from .maisdt import MaiSDT -from .note import MaiNote, NoteType - - -@dataclass -class StartSlide: - measure: float - position: int - duration: float - delay: float - slide_id: int - - -def sdt_to_simai(sdt: MaiSDT, initial_bpm: float) -> SimaiChart: - simai_chart = SimaiChart() - simai_chart.set_bpm(1.0, initial_bpm) - convert_notes(simai_chart, sdt.notes) - return simai_chart - - -def convert_notes(simai_chart: SimaiChart, sdt_notes: List[MaiNote]) -> None: - start_slides = [] - for sdt_note in sdt_notes: - note_type = sdt_note.note_type - if note_type in [ - NoteType.tap, - NoteType.break_tap, - NoteType.star, - NoteType.break_star, - ]: - is_break = ( - True - if note_type in [NoteType.break_tap, NoteType.break_star] - else False - ) - is_star = ( - True if note_type in [NoteType.star, NoteType.break_star] else False - ) - simai_chart.add_tap(sdt_note.measure, sdt_note.position, is_break, is_star) - elif note_type == NoteType.hold: - simai_chart.add_hold(sdt_note.measure, sdt_note.position, sdt_note.duration) - elif note_type == NoteType.start_slide: - # simai slide durations does not include the delay - # unlike in sdt - start_slide = StartSlide( - sdt_note.measure, - sdt_note.position, - sdt_note.duration - sdt_note.delay, - sdt_note.delay, - sdt_note.slide_id, - ) - start_slides.append(start_slide) - elif note_type == NoteType.end_slide: - start_slide = [ - slide for slide in start_slides if slide.slide_id == sdt_note.slide_id - ] - if len(start_slide) == 0: - raise Exception("No corresponding start slide") - elif len(start_slide) > 1: - raise Exception("Multiple start slides with same slide id") - else: - start_slide = start_slide[0] - pattern = simai_pattern_from_int( - sdt_note.pattern, start_slide.position, sdt_note.position - ) - simai_chart.add_slide( - start_slide.measure, - start_slide.position, - sdt_note.position, - start_slide.duration, - pattern[0], - start_slide.delay, - pattern[1], - ) - else: - print("Warning: Unknown note type {}".format(note_type)) \ No newline at end of file diff --git a/maiconverter/simai.py b/maiconverter/simai.py deleted file mode 100644 index 337cfe0..0000000 --- a/maiconverter/simai.py +++ /dev/null @@ -1,810 +0,0 @@ -from fractions import Fraction -import math -from typing import Optional, Tuple, List - -from .note import SimaiNote, NoteType -from .event import Event, EventType -from .simai_lark_parser import simai_parse_fragment - -# I hate the simai format can we use bmson for -# community-made charts instead - -# For straightforward slide pattern conversion from simai to sdt/ma2. -# Use simai_pattern_to_int to cover all simai slide patterns. -simai_slide_dict = { - "-": 1, - "p": 4, - "q": 5, - "s": 6, - "z": 7, - "v": 8, - "pp": 9, - "qq": 10, - "w": 13, -} - -simai_slide_patterns = [ - "-", - "^", - ">", - "<", - "p", - "q", - "s", - "z", - "v", - "pp", - "qq", - "V", - "w", -] - - -class SimaiHoldNote(SimaiNote): - def __init__( - self, measure: float, position: int, duration: float, is_ex: bool = False - ) -> None: - measure = round(10000.0 * measure) / 10000.0 - duration = round(10000.0 * duration) / 10000.0 - if is_ex: - super().__init__(measure, position, NoteType.ex_hold) - else: - super().__init__(measure, position, NoteType.hold) - - self.duration = duration - - -class SimaiTapNote(SimaiNote): - def __init__( - self, - measure: float, - position: int, - is_break: bool = False, - is_star: bool = False, - is_ex: bool = False, - ) -> None: - measure = round(10000.0 * measure) / 10000.0 - if is_ex and is_star: - super().__init__(measure, position, NoteType.ex_star) - elif is_ex and not is_star: - super().__init__(measure, position, NoteType.ex_tap) - elif is_star and is_break: - super().__init__(measure, position, NoteType.break_star) - elif is_star and not is_break: - super().__init__(measure, position, NoteType.star) - elif is_break: - super().__init__(measure, position, NoteType.break_tap) - else: - super().__init__(measure, position, NoteType.tap) - - -class SimaiSlideNote(SimaiNote): - def __init__( - self, - measure: float, - start_position: int, - end_position: int, - duration: float, - pattern: str, - delay: float = 0.25, - reflect_position: Optional[int] = None, - ) -> None: - """Produces a simai slide note. - - Note: Simai slide durations does not include slide delay. - - Args: - measure (float): Measure where the slide note begins. - start_position (int): Button where slide begins. [0, 7] - end_position (int): Button where slide ends. [0, 7] - duration (float): Total duration, in measures, of - the slide, including delay. - pattern (str): Simai slide pattern. If 'V', then - reflect_position should not be None. - delay (float, optional): Time duration, in measures, - from where slide appears and when it starts to move. - Defaults to 0.25. - reflect_position (Optional[int], optional): For 'V' - patterns. Defaults to None. - - Raises: - ValueError: When duration is not positive - or when delay is negative - """ - measure = round(10000.0 * measure) / 10000.0 - duration = round(10000.0 * duration) / 10000.0 - delay = round(10000.0 * delay) / 10000.0 - if duration <= 0: - raise ValueError("Duration is not positive " + str(duration)) - elif delay < 0: - raise ValueError("Delay is negative " + str(duration)) - elif not pattern in simai_slide_patterns: - raise ValueError("Unknown slide pattern " + str(pattern)) - elif pattern == "V" and reflect_position is None: - raise Exception("Slide pattern 'V' is given " + "without reflection point") - - super().__init__(measure, start_position, NoteType.complete_slide) - self.duration = duration - self.end_position = end_position - self.pattern = pattern - self.delay = delay - self.reflect_position = reflect_position - - -class SimaiTouchTapNote(SimaiNote): - def __init__( - self, measure: float, position: int, zone: str, is_firework: bool = False - ) -> None: - measure = round(measure * 10000.0) / 10000.0 - - super().__init__(measure, position, NoteType.touch_tap) - self.is_firework = is_firework - self.zone = zone - - -class SimaiTouchHoldNote(SimaiNote): - def __init__( - self, - measure: float, - position: int, - zone: str, - duration: float, - is_firework: bool = False, - ) -> None: - measure = round(measure * 10000.0) / 10000.0 - duration = round(duration * 10000.0) / 10000.0 - - super().__init__(measure, position, NoteType.touch_hold) - self.is_firework = is_firework - self.zone = zone - self.duration = duration - - -class SimaiBPM(Event): - def __init__(self, measure: float, bpm: float) -> None: - if bpm <= 0: - raise ValueError("BPM is not positive " + str(bpm)) - - measure = round(measure * 10000.0) / 10000.0 - - super().__init__(measure, EventType.bpm) - self.bpm = bpm - - -def slide_distance(start_position: int, end_position: int, is_cw: bool) -> int: - end = end_position - start = start_position - if is_cw: - if start >= end: - end += 8 - - return end - start - else: - if start <= end: - start += 8 - - return start - end - - -def slide_is_cw(start_position: int, end_position: int) -> bool: - # Handles slide cases where the direction is not specified - # Returns True for clockwise and False for counterclockwise - diff = abs(end_position - start_position) - other_diff = abs(8 - diff) - if diff == 4: - raise ValueError("Can't choose direction for a 180 degree angle.") - - if (end_position > start_position and diff > other_diff) or ( - end_position < start_position and diff < other_diff - ): - return False - else: - return True - - -def simai_slide_to_pattern_str(slide_note: SimaiSlideNote) -> str: - pattern = slide_note.pattern - if pattern != "V": - return pattern - elif pattern == "V" and slide_note.reflect_position is None: - raise Exception("Slide has 'V' pattern but no reflect position") - else: - return "V{}".format(slide_note.reflect_position + 1) - - -def simai_pattern_from_int( - pattern: int, start_position: int, end_position: int -) -> (str, Optional[int]): - top_list = [0, 1, 6, 7] - inv_slide_dict = {v: k for k, v in simai_slide_dict.items()} - dict_result = inv_slide_dict.get(pattern) - if not dict_result is None: - return (dict_result, None) - elif pattern in [2, 3]: - # Have I told you how much I hate the simai format? - is_cw = True if pattern == 3 else False - distance = slide_distance(start_position, end_position, is_cw) - if distance <= 3: - return ("^", None) - elif (start_position in top_list and is_cw) or not ( - start_position in top_list or is_cw - ): - return (">", None) - else: - return ("<", None) - elif pattern in [11, 12]: - if pattern == 11: - reflect_position = start_position - 2 - if reflect_position < 0: - reflect_position += 8 - else: - reflect_position = start_position + 2 - if reflect_position > 7: - reflect_position -= 8 - - return ("V", reflect_position) - else: - raise ValueError("Unknown pattern: " + str(pattern)) - - -def simai_pattern_to_int(slide_note: SimaiSlideNote) -> Optional[int]: - pattern = slide_note.pattern - top_list = [0, 1, 6, 7] - if not pattern in simai_slide_patterns: - return None - - dict_result = simai_slide_dict.get(pattern) - if not dict_result is None: - return dict_result - elif pattern == "^": - is_cw = slide_is_cw(slide_note.position, slide_note.end_position) - if is_cw: - return 3 - else: - return 2 - elif pattern == ">": - is_top = True if slide_note.position in top_list else False - if is_top: - return 3 - else: - return 2 - elif pattern == "<": - is_top = True if slide_note.position in top_list else False - if is_top: - return 2 - else: - return 3 - elif pattern == "V": - is_cw = slide_is_cw(slide_note.position, slide_note.reflect_position) - if is_cw: - return 12 - else: - return 11 - - -def simai_get_rest( - current_measure: float, next_measure: float -) -> Optional[Tuple[int, int, int]]: - # Finds the amount of rest needed to get to the next measure - # Returns a tuple (whole, divisor, amount) - if next_measure < current_measure: - raise ValueError("Current measure is greater than next.") - elif next_measure == current_measure: - return None - else: - difference = math.modf(next_measure - current_measure) - whole = difference[1] - decimal_fraction = Fraction(difference[0]).limit_denominator(1000) - amount = decimal_fraction.numerator - divisor = decimal_fraction.denominator - return (int(whole), divisor, amount) - - -def simai_convert_to_fragment( - events: List[Event], current_bpm: float, divisor: Optional[int] = None -) -> str: - # Accepts a list of events that starts at the same measure - - if len(events) == 0: - return "" - - bpms = [bpm for bpm in events if isinstance(bpm, SimaiBPM)] - tap_notes = [note for note in events if isinstance(note, SimaiTapNote)] - hold_notes = [note for note in events if isinstance(note, SimaiHoldNote)] - touch_tap_notes = [note for note in events if isinstance(note, SimaiTouchTapNote)] - touch_hold_notes = [note for note in events if isinstance(note, SimaiTouchHoldNote)] - slide_notes = [note for note in events if isinstance(note, SimaiSlideNote)] - slide_notes.sort( - key=lambda slide_note: ( - slide_note.position, - slide_note.end_position, - slide_note.pattern, - ) - ) - - fragment = "" - counter = 0 - if len(bpms) > 1: - raise Exception("Multiple BPM defined at same measure " + str(bpms)) - elif len(bpms) == 1: - fragment += "({})".format(bpms[0].bpm) - - if not divisor is None: - fragment += "{" + str(divisor) + "}" - - for tap_note in tap_notes: - note_type = tap_note.note_type - if note_type in [NoteType.tap, NoteType.break_tap, NoteType.ex_tap]: - # Regular, break, and ex tap note - if note_type == NoteType.break_tap: - modifier_string = "b" - elif note_type == NoteType.ex_tap: - modifier_string = "x" - else: - modifier_string = "" - - if counter > 0: - fragment += "/" - - fragment += "{}{}".format(tap_note.position + 1, modifier_string) - elif note_type in [NoteType.star, NoteType.break_star, NoteType.ex_star]: - produced_slides = [ - slide for slide in slide_notes if slide.position == tap_note.position - ] - if len(produced_slides) > 0: - continue - else: - # Adding $ would make a star note with no slides - if note_type == NoteType.break_star: - modifier_string = "b$" - elif note_type == NoteType.ex_star: - modifier_string = "x$" - else: - modifier_string = "$" - - if counter > 0: - fragment += "/" - fragment += "{}{}".format(tap_note.position + 1, modifier_string) - - counter += 1 - - for hold_note in hold_notes: - frac = Fraction(hold_note.duration).limit_denominator(1000) - if hold_note.note_type == NoteType.ex_hold: - modifier_string = "hx" - else: - modifier_string = "h" - - if counter > 0: - fragment += "/" - - fragment += "{}{}[{}:{}]".format( - hold_note.position + 1, modifier_string, frac.denominator, frac.numerator - ) - - counter += 1 - - for touch_tap_note in touch_tap_notes: - if touch_tap_note.is_firework: - modifier_string = "f" - else: - modifier_string = "" - - if counter > 0: - fragment += "/" - - fragment += "{}{}{}".format( - touch_tap_note.zone, touch_tap_note.position + 1, modifier_string - ) - - counter += 1 - - for touch_hold_note in touch_hold_notes: - frac = Fraction(touch_hold_note.duration).limit_denominator(1000) - if touch_hold_note.is_firework: - modifier_string = "hf" - else: - modifier_string = "h" - - if counter > 0: - fragment += "/" - - fragment += "{}{}[{}:{}]".format( - touch_hold_note.zone, modifier_string, frac.denominator, frac.numerator - ) - - counter += 1 - - positions = [] - for slide_note in slide_notes: - stars = [star for star in tap_notes if star.position == slide_note.position] - if counter > 0 and not slide_note.position in positions: - fragment += "/" - - if len(stars) == 0 and not slide_note.position in positions: - # No star - modifier_string = "?" - elif ( - stars[0].note_type == NoteType.break_star - and not slide_note.position in positions - ): - modifier_string = "b" - elif ( - stars[0].note_type == NoteType.ex_star - and not slide_note.position in positions - ): - # Ex star - modifier_string = "x" - else: - # Regular star - modifier_string = "" - - if slide_note.position in positions: - start_position = "*" - else: - start_position = str(slide_note.position + 1) - - pattern = simai_slide_to_pattern_str(slide_note) - if slide_note.delay != 0.25: - scale = 0.25 / slide_note.delay - equivalent_bpm = round(current_bpm * scale * 10000.0) / 10000.0 - equivalent_duration = slide_note.duration * scale - frac = Fraction(equivalent_duration).limit_denominator(1000) - fragment += "{}{}{}{}[{:.2f}#{}:{}]".format( - start_position, - modifier_string, - pattern, - slide_note.end_position + 1, - equivalent_bpm, - frac.denominator, - frac.numerator, - ) - else: - frac = Fraction(slide_note.duration).limit_denominator(1000) - fragment += "{}{}{}{}[{}:{}]".format( - start_position, - modifier_string, - pattern, - slide_note.end_position + 1, - frac.denominator, - frac.numerator, - ) - - if not slide_note.position in positions: - positions.append(slide_note.position) - - counter += 1 - - return fragment - - -class SimaiChart: - def __init__(self): - self.notes = [] - self.bpms = [] - - def add_tap( - self, - measure: float, - position: int, - is_break: bool = False, - is_star: bool = False, - is_ex: bool = False, - ) -> None: - tap_note = SimaiTapNote(measure, position, is_break, is_star, is_ex) - self.notes.append(tap_note) - - def add_hold( - self, measure: float, position: int, duration: float, is_ex: bool = False - ) -> None: - hold_note = SimaiHoldNote(measure, position, duration, is_ex) - self.notes.append(hold_note) - - def add_slide( - self, - measure: float, - start_position: int, - end_position: int, - duration: float, - pattern: str, - delay: float = 0.25, - reflect_position: Optional[int] = None, - ) -> None: - slide_note = SimaiSlideNote( - measure, - start_position, - end_position, - duration, - pattern, - delay, - reflect_position, - ) - self.notes.append(slide_note) - - def add_touch_tap( - self, measure: float, position: int, zone: str, is_firework: bool = False - ) -> None: - touch_tap_note = SimaiTouchTapNote(measure, position, zone, is_firework) - self.notes.append(touch_tap_note) - - def add_touch_hold( - self, - measure: float, - position: int, - zone: str, - duration: float, - is_firework: bool = False, - ) -> None: - touch_hold_note = SimaiTouchHoldNote( - measure, position, zone, duration, is_firework - ) - self.notes.append(touch_hold_note) - - def set_bpm(self, measure: float, bpm: float) -> None: - bpm_event = SimaiBPM(measure, bpm) - self.bpms.append(bpm_event) - - def get_bpm(self, measure) -> Optional[float]: - bpm_measures = [bpm.measure for bpm in self.bpms] - bpm_measures = list(set(bpm_measures)) - bpm_measures.sort() - - previous_measure = 0 - for bpm_measure in bpm_measures: - if bpm_measure <= measure: - previous_measure = bpm_measure - else: - break - - bpm_result = [bpm.bpm for bpm in self.bpms if bpm.measure == previous_measure] - if len(bpm_result) == 0: - return None - else: - return bpm_result[0] - - def offset(self, offset: float) -> None: - for note in self.notes: - new_measure = round((note.measure + offset) * 10000.0) / 10000.0 - note.measure = new_measure - - for event in self.bpms: - if event.measure == 1.0: - continue - - new_measure = round((event.measure + offset) * 10000.0) / 10000.0 - event.measure = new_measure - - def export(self) -> str: - measures = [note.measure for note in self.notes] - - if len(self.bpms) == 0: - raise Exception("No BPMs defined") - elif self.get_bpm(1.0) is None: - raise Exception("No starting BPM defined") - - for bpm_event in self.bpms: - measures.append(bpm_event.measure) - - measures = list(set(measures)) - measures.sort() - - previous_measure_whole = 1 - slide_hold_last_end_measure = 1.0 - previous_divisor = None - result = "" - for (i, current_measure) in enumerate(measures): - bpm = [bpm for bpm in self.bpms if bpm.measure == current_measure] - notes = [note for note in self.notes if note.measure == current_measure] - hold_slides = [ - note - for note in notes - if note.note_type - in [ - NoteType.hold, - NoteType.ex_hold, - NoteType.touch_hold, - NoteType.complete_slide, - ] - ] - for hold_slide in hold_slides: - # Get measure where a hold or slide will end - if hold_slide.note_type == NoteType.complete_slide: - end_measure = ( - current_measure + hold_slide.delay + hold_slide.duration - ) - else: - end_measure = current_measure + hold_slide.duration - - if end_measure > slide_hold_last_end_measure: - # Assign greatest end measure - slide_hold_last_end_measure = end_measure - - if int(current_measure) > previous_measure_whole: - previous_measure_whole = int(current_measure) - result += "\n" - - # Why doesn't Python have a safe list 'get' method - next_measure = measures[i + 1] if i + 1 < len(measures) else None - if next_measure is None: - # We are at the end so let's check if there's any - # active holds or slides - if slide_hold_last_end_measure > current_measure: - (whole, post_div, post_amount) = simai_get_rest( - current_measure, slide_hold_last_end_measure - ) - if whole > 0: - current_divisor = 1 - is_move_whole = True - else: - current_divisor = post_div - is_move_whole = False - else: - # Nothing to do - current_divisor = previous_divisor - is_move_whole = False - whole, post_div, post_amount = None, None, None - else: - # Determine if we'll move by whole notes or not - (whole, post_div, post_amount) = simai_get_rest( - current_measure, next_measure - ) - if whole > 0: - current_divisor = 1 - is_move_whole = True - else: - current_divisor = post_div - is_move_whole = False - - divisor = current_divisor if current_divisor != previous_divisor else None - - result += simai_convert_to_fragment( - notes + bpm, self.get_bpm(current_measure), divisor - ) - if is_move_whole: - result += "," * whole - - if not post_div is None and post_div != current_divisor: - result += "{" + str(post_div) + "}" - result += "," * post_amount - current_divisor = post_div - elif not post_div is None and post_div == current_divisor: - result += "," * post_amount - - previous_divisor = current_divisor - - result += ",\nE" - return result - - -def simai_parse_chart(chart: str) -> SimaiChart: - print("Parsing simai chart...") - simai_chart = SimaiChart() - current_bpm = 120 - current_divisor = 4 - current_measure = 1.0 - chart = "".join(chart.split()) - fragments = chart.split(",") - for fragment in fragments: - if fragment == "E": - break - elif fragment == "": - pass - else: - star_positions = [] - events = simai_parse_fragment(fragment) - for event in events: - event_type = event["event_type"] - if event_type == "bpm": - current_bpm = event["value"] - simai_chart.set_bpm(current_measure, current_bpm) - elif event_type == "divisor": - current_divisor = event["value"] - elif event_type == "tap": - is_break, is_ex, is_star = False, False, False - modifier = event["modifier"] - if not modifier is None: - if "b" in modifier: - is_break = True - elif "x" in modifier: - is_ex = True - - if "$" in modifier: - is_star = True - - simai_chart.add_tap( - current_measure, event["button"], is_break, is_star, is_ex - ) - elif event_type == "hold": - is_ex = False - modifier = event["modifier"] - if modifier == "x": - is_ex = True - - simai_chart.add_hold( - current_measure, event["button"], event["duration"], is_ex - ) - elif event_type == "slide": - is_break, is_ex, is_tapless = False, False, False - modifier = event["modifier"] - if modifier == "b": - is_break = True - elif modifier == "x": - is_ex = True - elif modifier in ["?", "!", "$"]: - # Tapless slides - # ? means the slide has delay - # ! produces a slide with no path, just a moving star - # $ is a remnant of 2simai, it is equivalent to ? - is_tapless = True - elif not modifier is None: - raise ValueError("Unknown slide modifier" + str(modifier)) - - if not (is_tapless or event["start_button"] in star_positions): - simai_chart.add_tap( - current_measure, - event["start_button"], - is_break, - True, - is_ex, - ) - star_positions.append(event["start_button"]) - - equivalent_bpm = event["equivalent_bpm"] - duration = event["duration"] - delay = 0.25 - if not equivalent_bpm is None: - multiplier = current_bpm / equivalent_bpm - duration = multiplier * duration - delay = multiplier * delay - - simai_chart.add_slide( - current_measure, - event["start_button"], - event["end_button"], - duration, - event["pattern"], - delay, - event["reflect_position"], - ) - elif event_type == "touch_tap": - is_firework = False - modifier = event["modifier"] - if modifier == "f": - is_firework = True - elif not modifier is None: - raise ValueError("Unknown touch modifier" + str(modifier)) - - zone = event["location"][0] - if len(event["location"]) > 1: - location = int(event["location"][1]) - 1 - else: - location = 0 - - simai_chart.add_touch_tap( - current_measure, location, zone, is_firework - ) - - elif event_type == "touch_hold": - is_firework = False - modifier = event["modifier"] - if modifier == "f": - is_firework = True - elif not modifier is None: - raise ValueError("Unknown touch modifier" + str(modifier)) - - zone = event["location"][0] - if len(event["location"]) > 1: - location = int(event["location"][1]) - 1 - else: - location = 0 - - simai_chart.add_touch_hold( - current_measure, location, zone, event["duration"], is_firework - ) - else: - raise Exception("Unknown event type: " + str(event_type)) - - current_measure += 1 / current_divisor - - print("Done!") - return simai_chart diff --git a/maiconverter/simai/__init__.py b/maiconverter/simai/__init__.py new file mode 100644 index 0000000..0f91d2b --- /dev/null +++ b/maiconverter/simai/__init__.py @@ -0,0 +1,13 @@ +from .simai_parser import * +from .simainote import * +from .tools import ( + get_rest, + convert_to_fragment, + get_measure_divisor, + handle_tap, + handle_hold, + handle_slide, + handle_touch_tap, + handle_touch_hold, +) +from .simai import SimaiChart, parse_file, parse_file_str diff --git a/maiconverter/simai/simai.lark b/maiconverter/simai/simai.lark new file mode 100644 index 0000000..6cf42a6 --- /dev/null +++ b/maiconverter/simai/simai.lark @@ -0,0 +1,45 @@ +// Grammar for a simai file +// TODO: Check if all possible simai entries are here + +?start: chain + +chain: value value* + +?value: title + | artist + | smsg + | des + | first + | wholebpm + | level + | chart + | amsg_first + | amsg_time + | amsg_content + | demo_seek + | demo_len + | NEWLINE + +title: "&title=" STRING +artist: "&artist=" STRING +smsg: "&smsg" ("_" INT)? "=" STRING +des: "&des" ("_" INT)? "=" STRING +first: "&first" ("_" INT)? "=" NUMBER +wholebpm: "&wholebpm=" STRING +// String because simai +level: "&lv_" INT "=" STRING +chart: "&inote_" INT "=" MULTILINE_STRING +amsg_first: "&amsg_first=" FLOAT +amsg_time: "&amsg_time=" MULTILINE_STRING +amsg_content: "&amsg_content=" /\s*(┃.+(\r?\n)*)+/ +demo_seek: "&demo_seek=" NUMBER +demo_len: "&demo_len=" NUMBER + +STRING: /[^\r\n]+/ +MULTILINE_STRING: /([\s\r\n]*[^&\r\n]+)+/ + +%import common.INT +%import common.FLOAT +%import common.NUMBER +%import common.NEWLINE +%import common.WS diff --git a/maiconverter/simai/simai.py b/maiconverter/simai/simai.py new file mode 100644 index 0000000..63c039d --- /dev/null +++ b/maiconverter/simai/simai.py @@ -0,0 +1,734 @@ +from __future__ import annotations +from typing import Optional, Tuple, List, Union +import copy +from lark import Lark + +from .tools import ( + get_measure_divisor, + convert_to_fragment, + get_rest, + parallel_parse_fragments, +) +from ..event import NoteType +from .simainote import TapNote, HoldNote, SlideNote, TouchTapNote, TouchHoldNote, BPM +from .simai_parser import SimaiTransformer +from ..time import second_to_measure, measure_to_second + +# I hate the simai format can we use bmson or stepmania chart format for +# community-made charts instead + + +class SimaiChart: + """A class that represents a simai chart. Contains notes and bpm + information. Does not include information such as + song name, chart difficulty, composer, chart maker, etc. + It only contains enough information to build a working simai + chart. + + Attributes: + bpms: Contains bpm events of the chart. + notes: Contains notes of the chart. + """ + + def __init__(self): + self.notes: List[ + Union[TapNote, HoldNote, SlideNote, TouchTapNote, TouchHoldNote] + ] = [] + self.bpms: List[BPM] = [] + self._divisor: Optional[int] = None + self._measure = 1.0 + + @classmethod + def from_str(cls, chart_text: str, message: Optional[str] = None) -> SimaiChart: + # TODO: Rewrite this + if message is None: + print("Parsing simai chart...", end="", flush=True) + else: + print(message, end="", flush=True) + + simai_chart = cls() + chart_text = "".join(chart_text.split()) + try: + events_list = parallel_parse_fragments(chart_text.split(",")) + except: + print("ERROR") + raise + else: + print("Done") + + for events in events_list: + star_positions = [] + offset = 0 + for event in events: + event_type = event["type"] + if event_type == "bpm": + simai_chart.set_bpm(simai_chart._measure, event["value"]) + elif event_type == "divisor": + simai_chart._divisor = event["value"] + elif event_type == "tap": + is_break, is_ex, is_star = False, False, False + modifier = event["modifier"] + if "b" in modifier: + is_break = True + if "x" in modifier: + is_ex = True + if "$" in modifier: + is_star = True + + if "`" in modifier: + # Equivalent to one tick in ma2 with resolution of 384 + offset += 0.0027 + else: + offset = 0 + + simai_chart.add_tap( + measure=simai_chart._measure + offset, + position=event["button"], + is_break=is_break, + is_star=is_star, + is_ex=is_ex, + ) + elif event_type == "hold": + is_ex = False + modifier = event["modifier"] + if "x" in modifier: + is_ex = True + + if "`" in modifier: + # Equivalent to one tick in ma2 with resolution of 384 + offset += 0.0027 + else: + offset = 0 + + simai_chart.add_hold( + measure=simai_chart._measure + offset, + position=event["button"], + duration=event["duration"], + is_ex=is_ex, + ) + elif event_type == "slide": + is_break, is_ex, is_tapless = False, False, False + modifier = event["modifier"] + if "b" in modifier: + is_break = True + if "x" in modifier: + is_ex = True + if any([a in modifier for a in "?!$"]): + # Tapless slides + # ? means the slide has no tap + # ! produces a tapless slide with no path, just a moving star + # $ is a remnant of 2simai, it is equivalent to ? + is_tapless = True + + if "*" in modifier: + # Chained slides should have the same offset + pass + elif "`" in modifier: + # Equivalent to one tick in ma2 with resolution of 384 + offset += 0.0027 + else: + offset = 0 + + if not (is_tapless or event["start_button"] in star_positions): + simai_chart.add_tap( + measure=simai_chart._measure + offset, + position=event["start_button"], + is_break=is_break, + is_star=True, + is_ex=is_ex, + ) + star_positions.append(event["start_button"]) + + equivalent_bpm = event["equivalent_bpm"] + duration = event["duration"] + delay = 0.25 + if equivalent_bpm is not None: + multiplier = ( + simai_chart.get_bpm(simai_chart._measure) / equivalent_bpm + ) + duration = multiplier * duration + delay = multiplier * delay + + simai_chart.add_slide( + measure=simai_chart._measure + offset, + start_position=event["start_button"], + end_position=event["end_button"], + duration=duration, + pattern=event["pattern"], + delay=delay, + reflect_position=event["reflect_position"], + ) + elif event_type == "touch_tap": + is_firework = False + modifier = event["modifier"] + if "f" in modifier: + is_firework = True + + if "`" in modifier: + # Equivalent to one tick in ma2 with resolution of 384 + offset += 0.0027 + else: + offset = 0 + + simai_chart.add_touch_tap( + measure=simai_chart._measure + offset, + position=event["location"], + region=event["region"], + is_firework=is_firework, + ) + + elif event_type == "touch_hold": + is_firework = False + modifier = event["modifier"] + if "f" in modifier: + is_firework = True + + if "`" in modifier: + # Equivalent to one tick in ma2 with resolution of 384 + offset += 0.0027 + else: + offset = 0 + + simai_chart.add_touch_hold( + measure=simai_chart._measure + offset, + position=event["location"], + region=event["region"], + duration=event["duration"], + is_firework=is_firework, + ) + else: + raise Exception("Unknown event type: " + str(event_type)) + + simai_chart._measure += 1 / simai_chart._divisor + + return simai_chart + + @classmethod + def open(cls, file: str) -> SimaiChart: + """Opens a text file containing only a Simai chart. Does NOT accept a regular Simai file which contains + metadata and multiple charts. Use `parse_file` to parse a normal Simai file. + + Args: + file: The path of the Simai chart file. + + Examples: + Open a Simai chart file named "example.txt" at current directory. + + >>> simai = SimaiChart.open("./example.txt") + """ + with open(file, "r") as f: + chart = f.read() + + return cls.from_str(chart) + + def add_tap( + self, + measure: float, + position: int, + is_break: bool = False, + is_star: bool = False, + is_ex: bool = False, + ) -> None: + """Adds a tap note to the list of notes. + + Args: + measure: Time when the note starts, in terms of measures. + position: Button where the tap note happens. + is_break: Whether a tap note is a break note. + is_star: Whether a tap note is a star note. + is_ex: Whether a tap note is an ex note. + + Examples: + Add a regular tap note at measure 1, break tap note at + measure 2, ex tap note at measure 2.5, star note at + measure 3, and a break star note at measure 5. All at + position 7. + + >>> simai = SimaiChart() + >>> simai.add_tap(1, 7) + >>> simai.add_tap(2, 7, is_break=True) + >>> simai.add_tap(2.5, 7, is_ex=True) + >>> simai.add_tap(3, 7, is_star=True) + >>> simai.add_tap(5, 7, is_break=True, is_star=True) + """ + tap_note = TapNote( + measure=measure, + position=position, + is_break=is_break, + is_star=is_star, + is_ex=is_ex, + ) + self.notes.append(tap_note) + + def del_tap(self, measure: float, position: int) -> None: + """Deletes a tap note from the list of notes. + + Args: + measure: Time when the note starts, in terms of measures. + position: Button where the tap note happens. + + Examples: + Remove tap note at measure 26.75 at button 4. + >>> simai = SimaiChart() + >>> simai.add_tap(26.5, 4) + >>> simai.del_tap(26.75, 4) + """ + tap_notes = [ + x + for x in self.notes + if isinstance(x, TapNote) + and x.measure == measure + and x.position == position + ] + for note in tap_notes: + self.notes.remove(note) + + def add_hold( + self, measure: float, position: int, duration: float, is_ex: bool = False + ) -> None: + """Adds a hold note to the list of notes. + + Args: + measure: Time when the note starts, in terms of measures. + position: Button where the hold note happens. + duration: Total time duration of the hold note. + is_ex: Whether a hold note is an ex note. + + Examples: + Add a regular hold note at button 2 at measure 1, with + duration of 5 measures. And an ex hold note at button + 6 at measure 3, with duration of 0.5 measures. + + >>> simai = SimaiChart() + >>> simai.add_hold(1, 2, 5) + >>> simai.add_hold(3, 6, 0.5, is_ex=True) + """ + hold_note = HoldNote(measure, position, duration, is_ex) + self.notes.append(hold_note) + + def del_hold(self, measure: float, position: int) -> None: + """Deletes the matching hold note in the list of notes. If there are multiple + matches, all matching notes are deleted. If there are no match, nothing happens. + + Args: + measure: Time when the note starts, in terms of measures. + position: Button where the hold note happens. + + Examples: + Add a regular hold note at button 0 at measure 3.25 with duration of 2 measures + and delete it. + + >>> simai = SimaiChart() + >>> simai.add_hold(3.25, 0, 2) + >>> simai.del_hold(3.25, 0) + """ + hold_notes = [ + x + for x in self.notes + if isinstance(x, HoldNote) + and x.measure == measure + and x.position == position + ] + for note in hold_notes: + self.notes.remove(note) + + def add_slide( + self, + measure: float, + start_position: int, + end_position: int, + duration: float, + pattern: str, + delay: float = 0.25, + reflect_position: Optional[int] = None, + ) -> None: + """Adds both a slide note to the list of notes. + + Args: + measure: Time when the slide starts, in + terms of measures. + start_position: Button where the slide starts. + end_position: Button where the slide ends. + duration: Total duration of the slide, in terms of + measures. Includes slide delay. + pattern: The one or two character slide pattern used. + delay: Duration from when the slide appears and when it + starts to move, in terms of measures. Defaults to 0.25. + reflect_position: The button where the 'V' slide will first go to. + Optional, defaults to None. + + Examples: + Add a '-' slide at measure 2.25 from button 1 to button 5 with + duration of 1.5 measures + + >>> simai = SimaiChart() + >>> simai.add_slide(2.25, 1, 5, 1.5, "-") + >>> simai.add_slide(3, 2, 7, 0.5, "V", reflect_position=4) + """ + slide_note = SlideNote( + measure, + start_position, + end_position, + duration, + pattern, + delay, + reflect_position, + ) + self.notes.append(slide_note) + + def del_slide(self, measure: float, start_position: int, end_position: int) -> None: + slide_notes = [ + x + for x in self.notes + if isinstance(x, SlideNote) + and x.measure == measure + and x.position == start_position + and x.end_position == end_position + ] + for note in slide_notes: + self.notes.remove(note) + + def add_touch_tap( + self, measure: float, position: int, region: str, is_firework: bool = False + ) -> None: + touch_tap_note = TouchTapNote(measure, position, region, is_firework) + self.notes.append(touch_tap_note) + + def del_touch_tap(self, measure: float, position: int, region: str) -> None: + touch_taps = [ + x + for x in self.notes + if isinstance(x, TouchTapNote) + and x.measure == measure + and x.position == position + and x.region == region + ] + for note in touch_taps: + self.notes.remove(note) + + def add_touch_hold( + self, + measure: float, + position: int, + region: str, + duration: float, + is_firework: bool = False, + ) -> None: + touch_hold_note = TouchHoldNote( + measure, position, region, duration, is_firework + ) + self.notes.append(touch_hold_note) + + def del_touch_hold(self, measure: float, position: int, region: str) -> None: + touch_holds = [ + x + for x in self.notes + if isinstance(x, TouchHoldNote) + and x.measure == measure + and x.position == position + and x.region == region + ] + for note in touch_holds: + self.notes.remove(note) + + def set_bpm(self, measure: float, bpm: float) -> None: + """Sets the bpm at given measure. + + Note: + If BPM event is already defined at given measure, + the method will overwrite it. + + Args: + measure: Time, in measures, where the bpm is defined. + bpm: The tempo in beat per minutes. + + Examples: + In a chart, the initial bpm is 180 then changes + to 250 in measure 12. + + >>> simai = SimaiChart() + >>> simai.set_bpm(0, 180) + >>> simai.set_bpm(12, 250) + """ + bpm_event = BPM(measure, bpm) + self.del_bpm(measure) + self.bpms.append(bpm_event) + + def get_bpm(self, measure: float) -> float: + """Gets the bpm at given measure. + + Args: + measure: Time, in measures. + + Returns: + Returns the bpm defined at given measure or None. + + Raises: + ValueError: When measure is negative, there are no BPMs + defined, or there are no starting BPM defined. + + Examples: + In a chart, the initial bpm is 180 then changes + to 250 in measure 12. + + >>> simai.get_bpm(0) + 180.0 + >>> simai.get_bpm(11.99) + 180.0 + >>> simai.get_bpm(12) + 250.0 + """ + if len(self.bpms) == 0: + raise ValueError("No BPMs defined") + if measure < 0: + raise ValueError("Measure is negative") + + bpm_measures = [bpm.measure for bpm in self.bpms] + bpm_measures = list(set(bpm_measures)) + bpm_measures.sort() + + if 0.0 not in bpm_measures and 1.0 not in bpm_measures: + raise ValueError("No starting BPMs defined") + + previous_measure = 0 + for bpm_measure in bpm_measures: + if bpm_measure <= measure: + previous_measure = bpm_measure + else: + break + + bpm_result = [bpm.bpm for bpm in self.bpms if bpm.measure == previous_measure] + return bpm_result[0] + + def del_bpm(self, measure: float): + """Deletes the bpm at given measure. + + Note: + If there are no BPM defined for that measure, nothing happens. + + Args: + measure: Time, in measures, where the bpm is defined. + + Examples: + Delete the BPM change defined at measure 24. + + >>> simai.del_bpm(24) + """ + bpms = [x for x in self.bpms if x.measure == measure] + for x in bpms: + self.bpms.remove(x) + + def offset(self, offset: Union[float, str]) -> None: + initial_bpm = self.get_bpm(1.0) + if isinstance(offset, float): + offset_abs = measure_to_second(offset, initial_bpm) + elif isinstance(offset, str) and offset[-1] in ["s", "S"]: + offset_abs = float(offset[:-1]) + elif isinstance(offset, str) and "/" in offset: + fraction = offset.split("/") + if len(fraction) != 2: + raise ValueError(f"Invalid fraction: {offset}") + + offset_abs = measure_to_second( + int(fraction[0]) / int(fraction[1]), initial_bpm + ) + else: + offset_abs = measure_to_second(float(offset), initial_bpm) + + for note in self.notes: + if note.measure <= 1.0: + current_eps_bpm = initial_bpm + else: + current_eps_bpm = self.get_bpm(note.measure - 0.0001) + + offset_compensated = second_to_measure(offset_abs, current_eps_bpm) + note.measure = ( + round((note.measure + offset_compensated) * 10000.0) / 10000.0 + ) + + new_bpms = copy.deepcopy(self.bpms) + for event in new_bpms: + if event.measure <= 1.0: + continue + + current_eps_bpm = self.get_bpm(event.measure - 0.0001) + offset_compensated = second_to_measure(offset_abs, current_eps_bpm) + event.measure = ( + round((event.measure + offset_compensated) * 10000.0) / 10000.0 + ) + + self.bpms = new_bpms + + def export(self, max_den: int = 1000) -> str: + # TODO: Rewrite this + measures = [event.measure for event in self.notes + self.bpms] + measures += [1.0] + + measures.sort() + measure_wholes = [int(i) for i in measures] + measures += measure_wholes + measures = list(set(measures)) + measures.sort() + + last_whole_measure = int(measures[-1]) + whole_divisors = [] + for whole_measure in range(1, last_whole_measure + 1): + x = [y for y in measures if int(y) == whole_measure] + whole_divisors.append(get_measure_divisor(x)) + + # last_measure takes into account slide and hold notes' end measure + last_measure = 1.0 + # measure_tick is our time-tracking variable. Used to know what measure + # are we in-between rests "," + measure_tick = 1.0 + # previous_divisor is used for comparing to current_divisor + # to know if we should add a "{}" indicator + previous_divisor: Optional[int] = None + # previous_measure_int is used for comparing to current measure. + # If we are in a new whole measure, add a new line and add the divisor. + previous_measure_int = 1 + # Our resulting chart in text form. Assuming that string fits in memory + result = "" + for (i, current_measure) in enumerate(measures): + bpm = [bpm for bpm in self.bpms if bpm.measure == current_measure] + notes = [note for note in self.notes if note.measure == current_measure] + hold_slides = [ + note + for note in notes + if note.note_type + in [ + NoteType.hold, + NoteType.ex_hold, + NoteType.touch_hold, + NoteType.complete_slide, + ] + ] + for hold_slide in hold_slides: + # Get hold and slide end measure and compare with last_measure + if hold_slide.note_type == NoteType.complete_slide: + last_measure = max( + current_measure + hold_slide.delay + hold_slide.duration, + last_measure, + ) + else: + last_measure = max( + current_measure + hold_slide.duration, last_measure + ) + + whole_divisor = whole_divisors[int(current_measure) - 1] + + # Why doesn't Python have a safe list 'get' method + next_measure: Optional[float] = ( + measures[i + 1] if i + 1 < len(measures) else None + ) + after_next_measure: Optional[float] = ( + measures[i + 2] if i + 2 < len(measures) else None + ) + if next_measure is None: + # We are at the end so let's check if there are any + # active holds or slides + if last_measure > current_measure: + (whole, current_divisor, rest_amount) = get_rest( + current_measure, + last_measure, + current_divisor=( + previous_divisor if whole_divisor is None else whole_divisor + ), + max_den=max_den, + ) + + else: + # Nothing to do + current_divisor = ( + previous_divisor if whole_divisor is None else whole_divisor + ) + whole, rest_amount = 0, 0 + else: + (whole, current_divisor, rest_amount) = get_rest( + current_measure, + next_measure, + after_next_measure=after_next_measure, + current_divisor=( + previous_divisor if whole_divisor is None else whole_divisor + ), + max_den=max_den, + ) + + if int(measure_tick) > previous_measure_int: + result += "\n" + + if ( + previous_divisor != current_divisor + or int(measure_tick) > previous_measure_int + ): + result += convert_to_fragment( + notes + bpm, + self.get_bpm(current_measure), + current_divisor, + max_den=max_den, + ) + previous_divisor = current_divisor + previous_measure_int = int(measure_tick) + else: + result += convert_to_fragment( + notes + bpm, self.get_bpm(current_measure), max_den=max_den + ) + + measure_tick = current_measure + + for _ in range(rest_amount): + result += "," + measure_tick += 1 / current_divisor + + if whole > 0: + if current_divisor != 1: + result += "{1}" + previous_divisor = 1 + + for _ in range(whole): + result += "," + measure_tick += 1 + + measure_tick = round(measure_tick * 10000) / 10000 + + result += ",\nE\n" + return result + + +def parse_file_str( + file: str, lark_file: str = "simai.lark" +) -> Tuple[str, List[Tuple[int, SimaiChart]]]: + parser = Lark.open(lark_file, rel_to=__file__, parser="lalr") + + dicts: List[dict] = SimaiTransformer().transform(parser.parse(file)) + + title = "" + charts: List[Tuple[int, SimaiChart]] = [] + for element in dicts: + if element["type"] == "title": + title: str = element["value"] + elif element["type"] == "chart": + num, chart = element["value"] + simai_chart = SimaiChart.from_str(chart, message=f"Parsing chart #{num}...") + charts.append((num, simai_chart)) + + return title, charts + + +def parse_file( + path: str, + encoding: str = "UTF-8", + lark_file: str = "simai.lark", +) -> Tuple[str, List[Tuple[int, SimaiChart]]]: + with open(path, encoding=encoding) as f: + simai = f.read() + + print(f"Parsing Simai file at {path}") + try: + result = parse_file_str(simai, lark_file=lark_file) + except: + print(f"Error parsing Simai file at {path}") + raise + else: + print(f"Done parsing Simai file at {path}") + return result diff --git a/maiconverter/simai/simai_fragment.lark b/maiconverter/simai/simai_fragment.lark new file mode 100644 index 0000000..abffd33 --- /dev/null +++ b/maiconverter/simai/simai_fragment.lark @@ -0,0 +1,40 @@ +// Do not pass empty strings or "E" + +// TODO: There are still some edge cases that this lark file doesn't +// cover. Mostly stuff that falls under "Special format" in +// https://w.atwiki.jp/simai/pages/25.html +// But I hate the simai format and implementing these are as fun as +// playing Pandora Paradoxxx Re:master. + +?start: chain + +?value: slide_note + | tap_hold_note + | divisor + | touch_tap_hold_note + | bpm + | pseudo_each + +chain: value ("/"? value)* + +duration: "[" /(\d+(\.\d*)?#)/? INT ":" INT "]" +// duration: "[" equivalent_bpm INT ":" INT "]" + +bpm: "(" NUMBER ")" +divisor: "{" INT "}" + +tap_hold_note: /[0-8][hbex$]*/ duration? + +slide_beg: /[0-8][b@x?$!]*([-^<>szvw]|p{1,2}|q{1,2}|V[0-8])[0-8]/ +slide_note: slide_beg duration chained_slide_note* +chained_slide_note: "*" /[b@ex?$!]*([-^<>szvw]|p{1,2}|q{1,2}|V[0-8])[0-8]/ duration + +touch_tap_hold_note: /((C[012]?)|(B[0-8])|(E[0-8])|(A[0-8])|(D[0-8]))[hfe]*/ duration? + +pseudo_each: "`" (slide_note | tap_hold_note | touch_tap_hold_note) + +%import common.INT +%import common.DECIMAL +%import common.NUMBER +%import common.WS +%ignore WS diff --git a/maiconverter/simai/simai_parser.py b/maiconverter/simai/simai_parser.py new file mode 100644 index 0000000..d144dd1 --- /dev/null +++ b/maiconverter/simai/simai_parser.py @@ -0,0 +1,407 @@ +from typing import List +from lark import Lark, Transformer + + +class SimaiTransformer(Transformer): + def title(self, n): + n = n[0] + return {"type": "title", "value": n.rstrip()} + + def artist(self, n): + n = n[0] + return {"type": "artist", "value": n.rstrip()} + + def smsg(self, n): + pass + + def des(self, n): + pass + + def first(self, n): + pass + + def wholebpm(self, n): + pass + + def level(self, n): + num, level = n + return {"type": "level", "value": (int(num), level.rstrip())} + + def chart(self, n): + num, raw_chart = n + chart = "" + for x in raw_chart.splitlines(): + if "||" not in x: + chart += x + + chart = "".join(chart.split()) + return {"type": "chart", "value": (int(num), chart)} + + def amsg_first(self, n): + pass + + def amsg_time(self, n): + pass + + def amsg_content(self, n): + pass + + def demo_seek(self, n): + pass + + def demo_len(self, n): + pass + + def chain(self, values): + result = [] + for value in values: + if isinstance(value, dict): + result.append(value) + + return result + + +class FragmentTransformer(Transformer): + def bpm(self, n) -> dict: + (n,) = n + event_dict = { + "type": "bpm", + "value": float(n), + } + return event_dict + + def divisor(self, n) -> dict: + (n,) = n + event_dict = { + "type": "divisor", + "value": int(n), + } + return event_dict + + def equivalent_bpm(self, n) -> dict: + if len(n) == 0: + return {"type": "equivalent_bpm", "value": None} + + (n,) = n + return {"type": "equivalent_bpm", "value": float(n)} + + def duration(self, items) -> dict: + # Set defaults + equivalent_bpm = None + den = None + num = None + + for item in items: + if isinstance(item, str) and item.type == "INT" and den is None: + den = int(item) + if den <= 0: + return { + "type": "duration", + "equivalent_bpm": equivalent_bpm, + "duration": 0, + } + elif isinstance(item, str) and item.type == "INT" and num is None: + num = int(item) + elif isinstance(item, str) and item[-1] == "#": + equivalent_bpm = float(item[:-1]) + + if den is None or num is None: + raise ValueError("No denominator or numerator given") + + return { + "type": "duration", + "equivalent_bpm": equivalent_bpm, + "duration": num / den, + } + + def slide_beg(self, items) -> dict: + text = items[0] + start = int(text[0]) - 1 + text = text[1:] + + modifier = "" + for i, char in enumerate(text): + if char in "b@x?$!": + modifier += char + else: + text = text[i:] + break + + reflect = None + + if text[0] not in "-^<>szvwpqV": + raise ValueError(f"Unknown slide pattern {text[0]}") + + pattern = text[0] + if text[0] in "-^<>szvw": + text = text[1:] + elif text[0] in "pq" and text[1] in "pq": + pattern += text[1] + text = text[2:] + elif text[0] in "pq" and text[1] not in "pq": + text = text[1:] + else: # V slide + reflect = int(text[1]) - 1 + text = text[2:] + + end = int(text[0]) - 1 + + return { + "type": "slide_beg", + "start": start, + "modifier": modifier, + "pattern": pattern, + "reflect": reflect, + "end": end, + } + + def chained_slide_note(self, items) -> dict: + ( + text, + duration_dict, + ) = items + + # Skip the modifiers in a chained slide + for i, char in enumerate(text): + if char not in "b@x?$!": + text = text[i:] + break + + pattern = text[0] + reflect = None + if text[0] in "-^<>szvw": + text = text[1:] + elif text[0] in "pq" and text[1] in "pq": + pattern += text[1] + text = text[2:] + elif text[0] in "pq" and text[1] not in "pq": + text = text[1:] + else: # V slide + reflect = int(text[1]) - 1 + text = text[2:] + + end = int(text[0]) - 1 + + if not isinstance(duration_dict, dict): + raise ValueError(f"Not a dict: {duration_dict}") + + duration = duration_dict["duration"] + equivalent_bpm = duration_dict["equivalent_bpm"] + + return { + "type": "chained_slide_note", + "pattern": pattern, + "reflect": reflect, + "end": end, + "equivalent_bpm": equivalent_bpm, + "duration": duration, + } + + def slide_note(self, items): + start = None + end = None + modifier = "" + pattern = None + reflect = None + equivalent_bpm = None + duration = None + chained_slides = [] + + for item in items: + if isinstance(item, dict) and item["type"] == "slide_beg": + if item["start"] == -1 or item["reflect"] == -1 or item["end"] == -1: + return + + start = item["start"] + modifier = item["modifier"] + pattern = item["pattern"] + end = item["end"] + reflect = item["reflect"] + elif isinstance(item, dict) and item["type"] == "duration": + equivalent_bpm = item["equivalent_bpm"] + duration = item["duration"] + elif isinstance(item, dict) and item["type"] == "chained_slide_note": + chained_slides.append(item) + else: + raise ValueError(f"Unknown value: {item}") + + if any((c is None) for c in [start, end, pattern, duration]): + raise ValueError("Incomplete data") + + slides = [] + if start != -1 and end != -1 and reflect != -1: + note_dict = { + "type": "slide", + "start_button": start, + "modifier": modifier, + "pattern": pattern, + "reflect_position": reflect, + "end_button": end, + "duration": duration, + "equivalent_bpm": equivalent_bpm, + } + slides.append(note_dict) + + if len(chained_slides) != 0: + slides += process_chained_slides(start, modifier + "*", chained_slides) + + if len(slides) > 0: + return slides + + def tap_hold_note(self, items): + if len(items) == 2: + ( + text, + duration_dict, + ) = items + else: + (text,) = items + duration_dict = None + + button = int(text[0]) - 1 + text = text[1:] + if button == -1: + # Ignore simai notes that has button position 0 + return + + is_tap = True + if "h" in text: + is_tap = False + + modifier = "" + for char in text: + if char == "h": + continue + + if is_tap and char in "bx$": + modifier += char + elif not is_tap and char in "x": + modifier += char + + if not is_tap: + if duration_dict is None: + duration = 0 + else: + duration = duration_dict["duration"] + + return { + "type": "hold", + "button": button, + "modifier": modifier, + "duration": duration, + } + + return { + "type": "tap", + "button": button, + "modifier": modifier, + } + + def touch_tap_hold_note(self, items): + if len(items) == 2: + ( + text, + duration_dict, + ) = items + else: + (text,) = items + duration_dict = None + + region = text[0] + if len(text) > 1 and text[1] in "012345678": + position = int(text[1]) - 1 + text = text[2:] + else: + position = 0 + text = text[1:] + + if region not in "CBE" or position == -1: + return + + is_tap = True + if "h" in text: + is_tap = False + + modifier = "" + for char in text: + if char == "h": + continue + + if char in "f": + modifier += char + + if not is_tap: + if duration_dict is None: + duration = 0 + else: + duration = duration_dict["duration"] + + return { + "type": "touch_hold", + "region": region, + "location": position, + "modifier": modifier, + "duration": duration, + } + + return { + "type": "touch_tap", + "region": region, + "location": position, + "modifier": modifier, + } + + def pseudo_each(self, items): + (item,) = items + if isinstance(item, list): + notes = item + elif isinstance(item, dict): + notes = [item] + else: + raise TypeError(f"Invalid type: {type(item)}") + + for note in notes: + note["modifier"] += "`" + + return notes + + def chain(self, items) -> list: + result = [] + # Flatten list + for item in items: + if isinstance(item, list): + for subitem in item: + result.append(subitem) + elif isinstance(item, dict): + result.append(item) + return result + + +def process_chained_slides(start_button, slide_modifier, chained_slides): + complete_slides = [] + for slide in chained_slides: + if start_button == -1 or slide["reflect"] == -1 or slide["end"] == -1: + continue + + note_dict = { + "type": "slide", + "start_button": start_button, + "modifier": slide_modifier, + "pattern": slide["pattern"], + "reflect_position": slide["reflect"], + "end_button": slide["end"], + "duration": slide["duration"], + "equivalent_bpm": slide["equivalent_bpm"], + } + complete_slides.append(note_dict) + + return complete_slides + + +def parse_fragment(fragment: str, lark_file: str = "simai_fragment.lark") -> List[dict]: + parser = Lark.open(lark_file, rel_to=__file__, parser="lalr") + try: + return FragmentTransformer().transform(parser.parse(fragment)) + except Exception as e: + print(f"Error parsing {fragment}") + raise diff --git a/maiconverter/simai/simainote.py b/maiconverter/simai/simainote.py new file mode 100644 index 0000000..9395370 --- /dev/null +++ b/maiconverter/simai/simainote.py @@ -0,0 +1,283 @@ +from typing import Optional, Tuple + +from ..event import Event, EventType, SimaiNote, NoteType + + +# For straightforward slide pattern conversion from simai to sdt/ma2. +# Use simai_pattern_to_int to cover all simai slide patterns. +slide_dict = { + "-": 1, + "p": 4, + "q": 5, + "s": 6, + "z": 7, + "v": 8, + "pp": 9, + "qq": 10, + "w": 13, +} + +slide_patterns = [ + "-", + "^", + ">", + "<", + "p", + "q", + "s", + "z", + "v", + "pp", + "qq", + "V", + "w", +] + + +class TapNote(SimaiNote): + def __init__( + self, + measure: float, + position: int, + is_break: bool = False, + is_star: bool = False, + is_ex: bool = False, + ) -> None: + measure = round(100000.0 * measure) / 100000.0 + if is_ex and is_star: + super().__init__(measure, position, NoteType.ex_star) + elif is_ex and not is_star: + super().__init__(measure, position, NoteType.ex_tap) + elif is_star and is_break: + super().__init__(measure, position, NoteType.break_star) + elif is_star and not is_break: + super().__init__(measure, position, NoteType.star) + elif is_break: + super().__init__(measure, position, NoteType.break_tap) + else: + super().__init__(measure, position, NoteType.tap) + + +class HoldNote(SimaiNote): + def __init__( + self, measure: float, position: int, duration: float, is_ex: bool = False + ) -> None: + if duration < 0: + raise ValueError(f"Hold duration is negative: {duration}") + + measure = round(100000.0 * measure) / 100000.0 + duration = round(100000.0 * duration) / 100000.0 + if is_ex: + super().__init__(measure, position, NoteType.ex_hold) + else: + super().__init__(measure, position, NoteType.hold) + + self.duration = duration + + +class SlideNote(SimaiNote): + def __init__( + self, + measure: float, + start_position: int, + end_position: int, + duration: float, + pattern: str, + delay: float = 0.25, + reflect_position: Optional[int] = None, + ) -> None: + """Produces a simai slide note. + + Note: Simai slide durations does not include slide delay. + + Args: + measure (float): Measure where the slide note begins. + start_position (int): Button where slide begins. [0, 7] + end_position (int): Button where slide ends. [0, 7] + duration (float): Total duration, in measures, of + the slide, including delay. + pattern (str): Simai slide pattern. If 'V', then + reflect_position should not be None. + delay (float, optional): Time duration, in measures, + from where slide appears and when it starts to move. + Defaults to 0.25. + reflect_position (Optional[int], optional): For 'V' + patterns. Defaults to None. + + Raises: + ValueError: When duration is not positive + or when delay is negative + """ + measure = round(100000.0 * measure) / 100000.0 + duration = round(100000.0 * duration) / 100000.0 + delay = round(100000.0 * delay) / 100000.0 + if duration <= 0: + raise ValueError("Duration is not positive " + str(duration)) + if delay < 0: + raise ValueError("Delay is negative " + str(duration)) + if not pattern in slide_patterns: + raise ValueError("Unknown slide pattern " + str(pattern)) + if pattern == "V" and reflect_position is None: + raise Exception("Slide pattern 'V' is given " + "without reflection point") + + super().__init__(measure, start_position, NoteType.complete_slide) + self.duration = duration + self.end_position = end_position + self.pattern = pattern + self.delay = delay + self.reflect_position = reflect_position + + +class TouchTapNote(SimaiNote): + def __init__( + self, measure: float, position: int, region: str, is_firework: bool = False + ) -> None: + measure = round(measure * 100000.0) / 100000.0 + + super().__init__(measure, position, NoteType.touch_tap) + self.is_firework = is_firework + self.region = region + + +class TouchHoldNote(SimaiNote): + def __init__( + self, + measure: float, + position: int, + region: str, + duration: float, + is_firework: bool = False, + ) -> None: + measure = round(measure * 100000.0) / 100000.0 + duration = round(duration * 100000.0) / 100000.0 + + super().__init__(measure, position, NoteType.touch_hold) + self.is_firework = is_firework + self.region = region + self.duration = duration + + +class BPM(Event): + def __init__(self, measure: float, bpm: float) -> None: + if bpm <= 0: + raise ValueError("BPM is not positive " + str(bpm)) + + measure = round(measure * 100000.0) / 100000.0 + + super().__init__(measure, EventType.bpm) + self.bpm = bpm + + +def slide_distance(start_position: int, end_position: int, is_cw: bool) -> int: + end = end_position + start = start_position + if is_cw: + if start >= end: + end += 8 + + return end - start + else: + if start <= end: + start += 8 + + return start - end + + +def slide_is_cw(start_position: int, end_position: int) -> bool: + # Handles slide cases where the direction is not specified + # Returns True for clockwise and False for counterclockwise + diff = abs(end_position - start_position) + other_diff = abs(8 - diff) + if diff == 4: + raise ValueError("Can't choose direction for a 180 degree angle.") + + if (end_position > start_position and diff > other_diff) or ( + end_position < start_position and diff < other_diff + ): + return False + else: + return True + + +def slide_to_pattern_str(slide_note: SlideNote) -> str: + pattern = slide_note.pattern + if pattern != "V": + return pattern + + if slide_note.reflect_position is None: + raise Exception("Slide has 'V' pattern but no reflect position") + + return "V{}".format(slide_note.reflect_position + 1) + + +def pattern_from_int( + pattern: int, start_position: int, end_position: int +) -> Tuple[str, Optional[int]]: + top_list = [0, 1, 6, 7] + inv_slide_dict = {v: k for k, v in slide_dict.items()} + dict_result = inv_slide_dict.get(pattern) + if not dict_result is None: + return (dict_result, None) + elif pattern in [2, 3]: + # Have I told you how much I hate the simai format? + is_cw = pattern == 3 + distance = slide_distance(start_position, end_position, is_cw) + if distance <= 3: + return ("^", None) + elif (start_position in top_list and is_cw) or not ( + start_position in top_list or is_cw + ): + return (">", None) + else: + return ("<", None) + elif pattern in [11, 12]: + if pattern == 11: + reflect_position = start_position - 2 + if reflect_position < 0: + reflect_position += 8 + else: + reflect_position = start_position + 2 + if reflect_position > 7: + reflect_position -= 8 + + return ("V", reflect_position) + else: + raise ValueError("Unknown pattern: " + str(pattern)) + + +def pattern_to_int(slide_note: SlideNote) -> int: + pattern = slide_note.pattern + top_list = [0, 1, 6, 7] + + dict_result = slide_dict.get(pattern) + if dict_result is not None: + return dict_result + elif pattern == "^": + is_cw = slide_is_cw(slide_note.position, slide_note.end_position) + if is_cw: + return 3 + else: + return 2 + elif pattern == ">": + is_top = slide_note.position in top_list + if is_top: + return 3 + else: + return 2 + elif pattern == "<": + is_top = slide_note.position in top_list + if is_top: + return 2 + else: + return 3 + elif pattern == "V": + if slide_note.reflect_position is None: + raise ValueError("Slide pattern 'V' has no reflect position defined") + + is_cw = slide_is_cw(slide_note.position, slide_note.reflect_position) + if is_cw: + return 12 + else: + return 11 + else: + raise ValueError(f"Unknown slide pattern {pattern}") diff --git a/maiconverter/simai/tools.py b/maiconverter/simai/tools.py new file mode 100644 index 0000000..55890c2 --- /dev/null +++ b/maiconverter/simai/tools.py @@ -0,0 +1,379 @@ +import math +from typing import List, Union, Optional, Tuple +from fractions import Fraction +from multiprocessing import Pool, Event +import os + +from ..event import NoteType +from .simainote import ( + TapNote, + HoldNote, + SlideNote, + TouchTapNote, + TouchHoldNote, + BPM, + slide_to_pattern_str, +) +from .simai_parser import parse_fragment + +ABORT = None + + +def _lcm(a: int, b: int) -> int: + return a * b // math.gcd(a, b) + + +def get_rest( + current_measure: float, + next_measure: float, + after_next_measure: Optional[float] = None, + current_divisor: Optional[int] = None, + max_den: int = 1000, +) -> Tuple[int, int, int]: + # Finds the amount of rest needed to get to the next measure + # Returns a tuple (whole, divisor, amount) + if next_measure < current_measure: + raise ValueError("Current measure is greater than next.") + if next_measure == current_measure: + if current_divisor is None: + return 0, 4, 0 + + return 0, current_divisor, 0 + + difference = math.modf(next_measure - current_measure) + difference_frac = Fraction(difference[0]).limit_denominator(max_den) + + if current_divisor is not None: + _lcm_divisor = _lcm(current_divisor, difference_frac.denominator) + if _lcm_divisor == current_divisor and next_measure - current_measure < 1: + divisor = current_divisor + whole = 0 + amount = int( + difference[1] * divisor + + difference_frac.numerator * (divisor / difference_frac.denominator) + ) + return whole, divisor, amount + + if after_next_measure is not None: + if next_measure > after_next_measure: + raise ValueError("After next measure is greater than next measure") + + difference_after = math.modf(after_next_measure - next_measure) + difference_after_frac = Fraction(difference_after[0]).limit_denominator(max_den) + _lcm_divisor_after = _lcm( + difference_frac.denominator, difference_after_frac.denominator + ) + if _lcm_divisor_after <= 64 and next_measure - current_measure < 1: + divisor = _lcm_divisor_after + whole = 0 + amount = int( + difference[1] * divisor + + difference_frac.numerator * (divisor / difference_frac.denominator) + ) + return whole, divisor, amount + + whole = int(difference[1]) + amount = difference_frac.numerator + divisor = difference_frac.denominator + return whole, divisor, amount + + +def get_measure_divisor(measures: List[float], max_den: int = 1000) -> Optional[int]: + """Accepts a list of measures that all belong to the same whole measure. + Example: [1.23, 1.5, 1.67, 1.89] + Returns a divisor that would fit the list upto an upper bound. + Otherwise, returns None. + + Args: + measures: List of measure that has the same whole measure. + max_den: The maximum denominator when making a fraction. + + Returns: + Returns the smallest divisor that would fit all the measure in the list. + Or None, if no divisor found.""" + if len(measures) == 0: + return None + + differences = [] + previous_measure = int(measures[0]) + measures.sort() + for measure in measures: + differences.append(math.modf(measure - previous_measure)[0]) + previous_measure = measure + + current__lcm = 1 + for difference in differences: + divisor = Fraction(difference).limit_denominator(max_den).denominator + current__lcm = _lcm(current__lcm, divisor) + if current__lcm > 64: + return None + + return current__lcm + + +def handle_tap(tap: TapNote, slides: List[SlideNote], counter: int) -> Tuple[str, int]: + result = "" + note_type = tap.note_type + if note_type in [NoteType.tap, NoteType.break_tap, NoteType.ex_tap]: + # Regular, break, and ex tap note + if note_type == NoteType.break_tap: + modifier_string = "b" + elif note_type == NoteType.ex_tap: + modifier_string = "x" + else: + modifier_string = "" + + if counter > 0: + result += "/" + + result += "{}{}".format(tap.position + 1, modifier_string) + elif note_type in [NoteType.star, NoteType.break_star, NoteType.ex_star]: + produced_slides = [slide for slide in slides if slide.position == tap.position] + if len(produced_slides) > 0: + return "", counter + + # Adding $ would make a star note with no slides + if note_type == NoteType.break_star: + modifier_string = "b$" + elif note_type == NoteType.ex_star: + modifier_string = "x$" + else: + modifier_string = "$" + + if counter > 0: + result += "/" + result += "{}{}".format(tap.position + 1, modifier_string) + + counter += 1 + return result, counter + + +def handle_hold(hold: HoldNote, counter: int, max_den: int = 1000) -> Tuple[str, int]: + result = "" + frac = Fraction(hold.duration).limit_denominator(max_den * 2) + if hold.note_type == NoteType.ex_hold: + modifier_string = "hx" + else: + modifier_string = "h" + + if counter > 0: + result += "/" + + result += "{}{}[{}:{}]".format( + hold.position + 1, modifier_string, frac.denominator, frac.numerator + ) + + counter += 1 + return result, counter + + +def handle_touch_tap(touch: TouchTapNote, counter: int) -> Tuple[str, int]: + result = "" + if touch.is_firework: + modifier_string = "f" + else: + modifier_string = "" + + if counter > 0: + result += "/" + + result += "{}{}{}".format(touch.region, touch.position + 1, modifier_string) + + counter += 1 + return result, counter + + +def handle_touch_hold( + touch: TouchHoldNote, counter: int, max_den: int = 1000 +) -> Tuple[str, int]: + result = "" + frac = Fraction(touch.duration).limit_denominator(max_den * 2) + if touch.is_firework: + modifier_string = "hf" + else: + modifier_string = "h" + + if counter > 0: + result += "/" + + result += "{}{}[{}:{}]".format( + touch.region, modifier_string, frac.denominator, frac.numerator + ) + + counter += 1 + return result, counter + + +def handle_slide( + slide: SlideNote, + taps: List[TapNote], + positions: List[int], + bpm: float, + counter: int, + max_den: int = 1000, +) -> Tuple[str, int]: + result = "" + stars = [star for star in taps if star.position == slide.position] + if counter > 0 and slide.position not in positions: + result += "/" + + if slide.position not in positions: + if len(stars) == 0: + # No star + modifier_string = "?" + elif stars[0].note_type == NoteType.break_star: + modifier_string = "b" + elif stars[0].note_type == NoteType.ex_star: + # Ex star + modifier_string = "x" + else: + modifier_string = "" + else: + # Regular star + modifier_string = "" + + if slide.position in positions: + start_position = "*" + else: + start_position = str(slide.position + 1) + + pattern = slide_to_pattern_str(slide) + if slide.delay != 0.25: + if slide.delay > 0.0025: + scale = 0.25 / slide.delay + else: + # There are no instant slides in Simai due to its + # "unique" way of representing slide delays + # So we just make a very fast slide + scale = 100 + + equivalent_bpm = round(bpm * scale * 10000.0) / 10000.0 + equivalent_duration = slide.duration * scale + frac = Fraction(equivalent_duration).limit_denominator(max_den * 10) + result += "{}{}{}{}[{:.2f}#{}:{}]".format( + start_position, + modifier_string, + pattern, + slide.end_position + 1, + equivalent_bpm, + frac.denominator, + frac.numerator, + ) + else: + frac = Fraction(slide.duration).limit_denominator(max_den * 10) + result += "{}{}{}{}[{}:{}]".format( + start_position, + modifier_string, + pattern, + slide.end_position + 1, + frac.denominator, + frac.numerator, + ) + + if slide.position not in positions: + positions.append(slide.position) + + counter += 1 + return result, counter + + +def convert_to_fragment( + events: List[Union[TapNote, HoldNote, SlideNote, TouchTapNote, TouchHoldNote, BPM]], + current_bpm: float, + divisor: Optional[int] = None, + max_den: int = 1000, +) -> str: + # Accepts a list of events that starts at the same measure + bpms = [bpm for bpm in events if isinstance(bpm, BPM)] + tap_notes = [note for note in events if isinstance(note, TapNote)] + hold_notes = [note for note in events if isinstance(note, HoldNote)] + touch_tap_notes = [note for note in events if isinstance(note, TouchTapNote)] + touch_hold_notes = [note for note in events if isinstance(note, TouchHoldNote)] + slide_notes = [note for note in events if isinstance(note, SlideNote)] + slide_notes.sort( + key=lambda sn: ( + sn.position, + sn.end_position, + sn.pattern, + ) + ) + + fragment = "" + counter = 0 + if len(bpms) != 0: + fragment += "({})".format(bpms[0].bpm) + + if divisor is not None: + fragment += f"{{{divisor}}}" + + for tap_note in tap_notes: + result = handle_tap(tap_note, slide_notes, counter) + fragment += result[0] + counter = result[1] + + for hold_note in hold_notes: + result = handle_hold(hold_note, counter, max_den=max_den) + fragment += result[0] + counter = result[1] + + for touch_tap_note in touch_tap_notes: + result = handle_touch_tap(touch_tap_note, counter) + fragment += result[0] + counter = result[1] + + for touch_hold_note in touch_hold_notes: + result = handle_touch_hold(touch_hold_note, counter, max_den=max_den) + fragment += result[0] + counter = result[1] + + positions: List[int] = [] + for slide_note in slide_notes: + result = handle_slide( + slide_note, tap_notes, positions, current_bpm, counter, max_den=max_den + ) + fragment += result[0] + counter = result[1] + + return fragment + + +def _parse_init(event): + global ABORT + ABORT = event + + +def _parse_helper(fragment: str) -> List: + global ABORT + # Return an empty list when ABORT is set or the fragments is empty or "E" + if ABORT.is_set() or len(fragment) == 0 or fragment == "E": + return [] + + try: + parsed = parse_fragment(fragment) + except Exception as e: + # Abort all jobs + ABORT.set() + raise RuntimeError(f"Error parsing fragment {fragment}") from e + + return parsed + + +def parallel_parse_fragments(fragments: List[str]) -> list: + _abort = Event() + + cpu_count = os.cpu_count() + if cpu_count is None: + cpu_count = 1 + + chunksize = 1 + len(fragments) // cpu_count + + # Stop jobs when abort is set + def fragment_iter(): + for fragment in fragments: + if not _abort.is_set(): + yield fragment + + with Pool(processes=cpu_count, initializer=_parse_init, initargs=(_abort,)) as pool: + result = pool.map(_parse_helper, fragment_iter(), chunksize) + + return result diff --git a/maiconverter/simai_fragment.lark b/maiconverter/simai_fragment.lark deleted file mode 100644 index a42d044..0000000 --- a/maiconverter/simai_fragment.lark +++ /dev/null @@ -1,55 +0,0 @@ -// Do not pass empty strings or "E" - -// TODO: There are still some edge cases that this lark file doesn't -// cover. Mostly stuff that falls under "Special format" in -// https://w.atwiki.jp/simai/pages/25.html -// But I hate the simai format and implementing these are as fun as -// playing Pandora Paradoxxx Re:master. - -// TODO: Can we make this LALR compatible? We can get a large -// performance increase from this. - -?start: chain - -?value: slide_note - | hold_note - | tap_note - | divisor - | touch_tap_note - | touch_hold_note - | bpm - -// In simai, the "/" can be ommited for tap notes. Fun! -chain: value ("/"? value)* - -NUMBER: DECIMAL | INT - -button: "1".."8" -touch_location: /C[12]?/ | /B[1-8]/ | /E[1-8]/ -duration: "[" equivalent_bpm INT ":" INT "]" -// For slides with delay that's not 0.25 -equivalent_bpm: (NUMBER "#")? -slide_pattern_literal: /-/ | /\^/ | // | /p{1,2}/ | /q{1,2}/ | /s/ | /z/ | /v/ | /w/ -slide_pattern_v: /V/ button -slide_pattern: slide_pattern_literal | slide_pattern_v - -bpm: "(" NUMBER ")" -divisor: "{" INT "}" - -tap_modifier: (/b/ | /x/)? (/\$/)? -hold_modifier: (/x/)? -slide_modifier: (/b/ | /x/ | /\?/ | /\$/ | /!/ )? -touch_modifier: (/f/)? - -tap_note: button tap_modifier -hold_note: button "h" hold_modifier duration -slide_note: button slide_modifier slide_pattern button duration chained_slides -chained_slides: chained_slide_note* -chained_slide_note: "*" slide_pattern button duration -touch_tap_note: touch_location touch_modifier -touch_hold_note: touch_location "h" touch_modifier duration - -%import common.INT -%import common.DECIMAL -%import common.WS -%ignore WS diff --git a/maiconverter/simai_lark_parser.py b/maiconverter/simai_lark_parser.py deleted file mode 100644 index ad63f8c..0000000 --- a/maiconverter/simai_lark_parser.py +++ /dev/null @@ -1,185 +0,0 @@ -from lark import Lark, Transformer - - -class SimaiTransformer(Transformer): - def bpm(self, n): - (n,) = n - event_dict = { - "event_type": "bpm", - "value": float(n), - } - return event_dict - - def divisor(self, n): - (n,) = n - event_dict = { - "event_type": "divisor", - "value": int(n), - } - return event_dict - - def button(self, n): - (n,) = n - # We start at 0 - return int(n) - 1 - - def equivalent_bpm(self, n): - if len(n) == 0: - return None - else: - (n,) = n - return float(n) - - def duration(self, items): - ( - equivalent_bpm, - den, - num, - ) = items - - return (float(num) / float(den), equivalent_bpm) - - def slide_modifier(self, item): - if len(item) == 0: - return None - else: - (item,) = item - (item,) = item - return item - - def tap_modifier(self, items): - if len(items) == 0: - return None - else: - result = "" - for item in items: - result += item - return result - - def slide_pattern_literal(self, s): - (s,) = s - return (s, None) - - def slide_pattern_v(self, items): - ( - s, - reflect_position, - ) = items - (s,) = s - return (s, int(reflect_position)) - - def slide_pattern(self, items): - (items,) = items - ( - pattern_string, - reflect_position, - ) = items - return (pattern_string, reflect_position) - - def chained_slides(self, items): - if len(items) == 0: - return None - else: - return list(items) - - def touch_location(self, s): - (s,) = s - s = s[0] - return s - - def slide_note(self, items): - start_button = items[0] - slide_modifier = items[1] - if not items[5] is None: - slides = process_chained_slides(start_button, slide_modifier, items[5]) - else: - slides = [] - - note_dict = { - "event_type": "slide", - "start_button": start_button, - "modifier": slide_modifier, - "pattern": items[2][0], - "reflect_position": items[2][1], - "end_button": items[3], - "duration": items[4][0], - "equivalent_bpm": items[4][1], - } - - slides.append(note_dict) - return slides - - def tap_note(self, items): - note_dict = { - "event_type": "tap", - "button": items[0], - "modifier": items[1], - } - return note_dict - - def hold_note(self, items): - note_dict = { - "event_type": "hold", - "button": items[0], - "modifier": items[1], - "duration": items[2][0], - } - return note_dict - - def touch_tap_note(self, items): - note_dict = { - "event_type": "touch_tap", - "location": items[0], - "modifier": items[1], - } - return note_dict - - def touch_hold_note(self, items): - note_dict = { - "event_type": "touch_hold", - "location": items[0], - "modifier": items[1], - "duration": items[2][0], - } - return note_dict - - def chain(self, items): - result = [] - # Flatten list of slides - for item in items: - if isinstance(item, list): - for subitem in item: - result.append(subitem) - elif isinstance(item, dict): - result.append(item) - else: - raise Exception("Not a dict: " + str(item)) - return result - - chained_slide_note = chained_slides - hold_modifier = slide_modifier - touch_modifier = slide_modifier - - -def process_chained_slides(start_button, slide_modifier, chained_slides): - complete_slides = [] - for chained_slide in chained_slides: - (slide_pattern, slide_reflect_position) = chained_slide[0] - note_dict = { - "event_type": "slide", - "start_button": start_button, - "modifier": slide_modifier, - "pattern": slide_pattern, - "reflect_position": slide_reflect_position, - "end_button": chained_slide[1], - "duration": chained_slide[2][0], - "equivalent_bpm": chained_slide[2][1], - } - complete_slides.append(note_dict) - - return complete_slides - - -def simai_parse_fragment(fragment: str, lark_file: str = "simai_fragment.lark"): - parser = Lark.open(lark_file, rel_to=__file__) - return SimaiTransformer().transform(parser.parse(fragment)) diff --git a/maiconverter/simaitomaisdt.py b/maiconverter/simaitomaisdt.py deleted file mode 100644 index 545aac6..0000000 --- a/maiconverter/simaitomaisdt.py +++ /dev/null @@ -1,120 +0,0 @@ -from typing import Union, Callable, List -import copy - -from .note import SimaiNote, MaiNote, NoteType -from .maisdt import MaiSDTSlideStartNote as SlideStartNote -from .maisdt import MaiSDTHoldNote as HoldNote -from .simai import simai_pattern_to_int, SimaiChart -from .simai import SimaiTouchTapNote as TouchTapNote -from .simai import SimaiTouchHoldNote as TouchHoldNote -from .maisdt import MaiSDT - - -def default_touch_converter( - sdt: MaiSDT, touch_note: Union[TouchTapNote, TouchHoldNote] -) -> None: - if touch_note.note_type == NoteType.touch_tap and touch_note.zone == "C": - sdt.add_tap(touch_note.measure, 0, False) - elif touch_note.note_type == NoteType.touch_tap: - sdt.add_tap(touch_note.measure, touch_note.position, False) - elif touch_note.note_type == NoteType.touch_hold and touch_note.zone == "C": - sdt.add_hold(touch_note.measure, 0, touch_note.duration) - - -def simai_to_sdt( - simai: SimaiChart, - touch_converter: Callable[ - [MaiSDT, Union[TouchHoldNote, TouchTapNote]], None - ] = default_touch_converter, - convert_touch: bool = False, -) -> MaiSDT: - sdt = MaiSDT() - convert_notes(sdt, simai.notes, touch_converter, convert_touch) - sdt.notes.sort() - - event_list = [note for note in sdt.notes] - for bpm in simai.bpms: - event_list.append(bpm) - - event_list.sort(key=lambda event: event.measure) - previous_measure = 1 - equivalent_current_measure = 1 - equivalent_notes = [] - initial_bpm = simai.get_bpm(1) - for event in event_list: - current_measure = event.measure - current_bpm = simai.get_bpm(current_measure) - gap = current_measure - previous_measure - scale = initial_bpm / current_bpm - equivalent_gap = gap * scale - equivalent_current_measure += equivalent_gap - - previous_measure = current_measure - if not isinstance(event, MaiNote): - continue - - note = copy.deepcopy(event) - note.measure = equivalent_current_measure - - if isinstance(note, HoldNote): - note.duration = note.duration * scale - elif isinstance(note, SlideStartNote): - note.duration = note.duration * scale - note.delay = note.delay * scale - - equivalent_notes.append(note) - - sdt.notes = equivalent_notes - return sdt - - -def convert_notes( - sdt: MaiSDT, - simai_notes: List[SimaiNote], - touch_converter: Callable[[MaiSDT, Union[TouchHoldNote, TouchTapNote]], None], - convert_touch: bool, -) -> None: - slide_counter = 1 - skipped_notes = 0 - for simai_note in simai_notes: - note_type = simai_note.note_type - if note_type in [NoteType.tap, NoteType.break_tap, NoteType.ex_tap]: - is_break = True if note_type == NoteType.break_tap else False - sdt.add_tap(simai_note.measure, simai_note.position, is_break) - elif note_type in [NoteType.hold, NoteType.ex_hold]: - sdt.add_hold(simai_note.measure, simai_note.position, simai_note.duration) - elif note_type in [NoteType.star, NoteType.break_star, NoteType.ex_star]: - is_break = True if note_type == NoteType.break_star else False - slides = [ - slide - for slide in simai_notes - if slide.note_type == NoteType.complete_slide - and slide.measure == simai_note.measure - and slide.position == simai_note.position - ] - sdt.add_star(simai_note.measure, simai_note.position, len(slides), is_break) - elif note_type == NoteType.complete_slide: - # SDT slide duration include the delay - # unlike in simai - pattern = simai_pattern_to_int(simai_note) - sdt.add_slide( - simai_note.measure, - simai_note.position, - simai_note.end_position, - pattern, - simai_note.duration + simai_note.delay, - slide_counter, - simai_note.delay, - ) - slide_counter += 1 - elif note_type in [NoteType.touch_tap, NoteType.touch_hold]: - # Touch tap and touch hold - if convert_touch: - touch_converter(sdt, simai_note) - else: - skipped_notes += 1 - else: - print("Warning: Unknown note type {}".format(note_type)) - - if skipped_notes > 0: - print("Skipped {} touch note(s)".format(skipped_notes)) diff --git a/maiconverter/time.py b/maiconverter/time.py new file mode 100644 index 0000000..5c9b9a5 --- /dev/null +++ b/maiconverter/time.py @@ -0,0 +1,8 @@ +def second_to_measure(seconds: float, bpm: float, beats_per_bar: int = 4) -> float: + beats = bpm * seconds / 60 + return beats / beats_per_bar + + +def measure_to_second(measure: float, bpm: float, beats_per_bar: int = 4) -> float: + beats = measure * beats_per_bar + return 60 * beats / bpm diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f9b654d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,4 @@ +[build-system] +requires = ["setuptools>=49", "wheel", "setuptools_scm[toml]>=6.0"] + +[tool.setuptools_scm] diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..86ee54f --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,64 @@ +Old scripts. Consider using the included command-line tool when you install the package. + +# Usage +## ma2_to_sdt.py +Converts a ma2 file or a directory containing ma2 files to sdt. + +All output files are stored in 'output' folder in the same directory as the input. Unless output directory is specified by using the --output or -o parameter + +```python ma2_to_sdt.py /path/to/file/or/directory``` + +To convert ma2 touch tap and touch hold notes into approximate regular tap and hold notes, add the --convert-touch or -ct argument. + +## ma2_to_simai.py +Converts a ma2 file or a directory containing ma2 files to simai. + +**NOTE**: This is a proof of concept and does **not** produce a complete simai file. The output file only contains the chart for one difficulty with no "&inote_=" or any simai fields. + +Usage is just like ma2_to_sdt.py but there are no touch note conversion. + +## ma2_to_sdt.py +Converts a ma2 file or a directory containing ma2 files to sdt. + +All output files are stored in 'output' folder in the same directory as the input. Unless output directory is specified by using the --output or -o parameter + +```python ma2_to_sdt.py /path/to/file/or/directory``` + +To convert ma2 touch tap and touch hold notes into approximate regular tap and hold notes, add the --convert-touch or -ct argument. + +## sxt_to_ma2.py +Converts an s\*t file or a directory containing s\*t files to ma2. + +Usage is just like ma2_to_sdt.py but you need to manually specify the bpm of the chart or group of charts because s\*t doesn't include that data. And there are no touch note conversion. + +## sxt_to_simai.py +Converts an s\*t file or a directory containing s\*t files to simai. + +**NOTE**: This is a proof of concept and does **not** produce a complete simai file. The output file only contains the chart for one difficulty with no "&inote_=" or any simai fields. + +Usage is just like sxt_to_ma2.py + +## simai_to_sdt.py +Converts a simai file or a directory containing simai files to sdt. + +**NOTE**: This is a proof of concept and does **not** accept a complete simai file. The input file should only contain the chart for one difficulty with no "&inote_=" or any simai fields. + +Usage is just like ma2_to_sdt.py + +## simai_to_ma2.py +Converts a simai file or a directory containing simai files to ma2. + +**NOTE**: This is a proof of concept and does **not** accept a complete simai file. The input file should only contain the chart for one difficulty with no "&inote_=" or any simai fields. + +Usage is just like ma2_to_simai.py + +## encrypt_decrypt.py +Encrypts and decrypts finale files. S\*T files are converted to S\*B files and vice versa. Database files can be encrypted and decrypted by using the --database or -db parameter. + +All output files are stored in 'output' folder in the same directory as the input. Unless output directory is specified by using the --output or -o parameter + +### Encrypting +```python encrypt_decrypt.py encrypt 'AES KEY HERE IN HEX' /path/to/file/or/directory``` + +### Decrypting +```python encrypt_decrypt.py decrypt 'AES KEY HERE IN HEX' /path/to/file/or/directory``` \ No newline at end of file diff --git a/encrypt_decrypt.py b/scripts/encrypt_decrypt.py similarity index 97% rename from encrypt_decrypt.py rename to scripts/encrypt_decrypt.py index f5bf7ac..08c5012 100644 --- a/encrypt_decrypt.py +++ b/scripts/encrypt_decrypt.py @@ -1,9 +1,9 @@ -from maiconverter import MaiFinaleCrypt import argparse import os -import sys import re +from maiconverter.maicrypt import MaiFinaleCrypt + def main(): parser = argparse.ArgumentParser( @@ -151,16 +151,16 @@ def handle_db(input_path, output_dir, command, key): def file_path(string): if os.path.exists(string): return string.rstrip("/\\") - else: - raise FileNotFoundError(string) + + raise FileNotFoundError(string) # Only accepts directory paths def dir_path(string): if os.path.isdir(string): return string.rstrip("/\\") - else: - raise NotADirectoryError(string) + + raise NotADirectoryError(string) if __name__ == "__main__": diff --git a/scripts/ma2_to_sdt.py b/scripts/ma2_to_sdt.py new file mode 100644 index 0000000..33b1fc1 --- /dev/null +++ b/scripts/ma2_to_sdt.py @@ -0,0 +1,120 @@ +import os +import argparse +import re +import sys + +from maiconverter.maima2 import MaiMa2 +from maiconverter.converter import ma2_to_sdt + + +def main(): + parser = argparse.ArgumentParser( + description="Converts ma2 to sdt", allow_abbrev=False + ) + parser.add_argument( + "path", metavar="Path", type=file_path, help="Path to score file or directory" + ) + parser.add_argument( + "-o", + "--output", + metavar="Output directory", + type=dir_path, + help="Path to output. Defaults to /path/to/input/output", + ) + parser.add_argument( + "-ct", + "--convert-touch", + metavar="Convert touch notes toggle", + action="store_const", + default=False, + const=True, + help="Optional toggle to convert touch notes", + ) + parser.add_argument( + "-d", + "--delay", + metavar="Delay", + type=float, + help="Offset a chart by set measures (can be negative)", + ) + + args = parser.parse_args() + + if args.output is None and os.path.isdir(args.path): + output_dir = os.path.join(args.path, "output") + elif args.output is None and not os.path.isdir(args.path): + output_dir = os.path.join(os.path.dirname(args.path), "output") + else: + output_dir = args.output + + if not os.path.exists(output_dir): + os.makedirs(output_dir) + elif os.path.exists(output_dir) and not os.path.isdir(output_dir): + raise NotADirectoryError(output_dir) + + if os.path.isdir(args.path): + files = os.listdir(args.path) + files = [ + file for file in files if os.path.isfile(os.path.join(args.path, file)) + ] + + files = [file for file in files if not re.search(r".ma2", file) is None] + + for file in files: + convert_ma2_file( + os.path.join(args.path, file), + output_dir, + args.convert_touch, + args.delay, + ) + elif not re.search(r".ma2", args.path) is None: + convert_ma2_file(args.path, output_dir, args.convert_touch, args.delay) + else: + print("Error: Not a ma2 file") + sys.exit(1) + + sys.exit(0) + + +def convert_ma2_file(input_path, output_dir, convert_touch, delay): + ma2 = MaiMa2() + file_name = os.path.splitext(os.path.basename(input_path))[0] + file_ext = ".sdt" + + if os.path.exists(os.path.join(output_dir, file_name + file_ext)): + print("File {} exists! Skipping".format(file_name + file_ext)) + return + + with open(input_path, "r") as in_f: + for line in in_f: + if line in ["\n", "\r\n"]: + continue + + ma2.parse_line(line) + + sdt = ma2_to_sdt(ma2, convert_touch=convert_touch) + if delay is not None: + sdt.offset(delay) + + with open(os.path.join(output_dir, file_name + file_ext), "x") as out_f: + out_f.write(sdt.export()) + + +# Accepts both file and directory paths +def file_path(string): + if os.path.exists(string): + return string.rstrip("/\\") + + raise FileNotFoundError(string) + + +# Only accepts directory paths +def dir_path(string): + if os.path.isdir(string): + return string.rstrip("/\\") + + raise NotADirectoryError(string) + + +if __name__ == "__main__": + main() diff --git a/scripts/ma2_to_simai.py b/scripts/ma2_to_simai.py new file mode 100644 index 0000000..c8afaa1 --- /dev/null +++ b/scripts/ma2_to_simai.py @@ -0,0 +1,106 @@ +import os +import argparse +import re +import sys + +from maiconverter.maima2 import MaiMa2 +from maiconverter.converter import ma2_to_simai + + +def main(): + parser = argparse.ArgumentParser( + description="Converts ma2 to simai", allow_abbrev=False + ) + parser.add_argument( + "path", metavar="Path", type=file_path, help="Path to score file or directory" + ) + parser.add_argument( + "-o", + "--output", + metavar="Output directory", + type=dir_path, + help="Path to output. Defaults to " + "/path/to/input/output", + ) + parser.add_argument( + "-d", + "--delay", + metavar="Delay", + type=float, + help="Offset a chart by set measures (can be negative)", + ) + + args = parser.parse_args() + + if args.output is None and os.path.isdir(args.path): + output_dir = os.path.join(args.path, "output") + elif args.output is None and not os.path.isdir(args.path): + output_dir = os.path.join(os.path.dirname(args.path), "output") + else: + output_dir = args.output + + if not os.path.exists(output_dir): + os.makedirs(output_dir) + elif os.path.exists(output_dir) and not os.path.isdir(output_dir): + raise NotADirectoryError(output_dir) + + if os.path.isdir(args.path): + files = os.listdir(args.path) + files = [ + file for file in files if os.path.isfile(os.path.join(args.path, file)) + ] + + files = [file for file in files if not re.search(r".ma2", file) is None] + + for file in files: + convert_ma2_file(os.path.join(args.path, file), output_dir, args.delay) + elif not re.search(r".ma2", args.path) is None: + convert_ma2_file(args.path, output_dir, args.delay) + else: + print("Error: Not a ma2 file") + sys.exit(1) + + sys.exit(0) + + +def convert_ma2_file(input_path, output_dir, delay): + ma2 = MaiMa2() + file_name = os.path.splitext(os.path.basename(input_path))[0] + file_ext = ".txt" + + if os.path.exists(os.path.join(output_dir, file_name + file_ext)): + print("File {} exists! Skipping".format(file_name + file_ext)) + return + + with open(input_path, "r") as in_f: + for line in in_f: + if line in ["\n", "\r\n"]: + continue + + ma2.parse_line(line) + + simai_chart = ma2_to_simai(ma2) + if delay is not None: + simai_chart.offset(delay) + + with open(os.path.join(output_dir, file_name + file_ext), "x") as out_f: + out_f.write(simai_chart.export()) + + +# Accepts both file and directory paths +def file_path(string): + if os.path.exists(string): + return string.rstrip("/\\") + + raise FileNotFoundError(string) + + +# Only accepts directory paths +def dir_path(string): + if os.path.isdir(string): + return string.rstrip("/\\") + + raise NotADirectoryError(string) + + +if __name__ == "__main__": + main() diff --git a/scripts/simai_to_ma2.py b/scripts/simai_to_ma2.py new file mode 100644 index 0000000..06e4d26 --- /dev/null +++ b/scripts/simai_to_ma2.py @@ -0,0 +1,103 @@ +import os +import argparse +import re +import sys + +from maiconverter.simai import SimaiChart +from maiconverter.converter import simai_to_ma2 + + +def main(): + parser = argparse.ArgumentParser( + description="Converts simai to ma2", allow_abbrev=False + ) + parser.add_argument( + "path", metavar="Path", type=file_path, help="Path to score file or directory" + ) + parser.add_argument( + "-o", + "--output", + metavar="Output directory", + type=dir_path, + help="Path to output. Defaults to " + "/path/to/input/output", + ) + parser.add_argument( + "-d", + "--delay", + metavar="Delay", + type=float, + help="Offset a chart by set measures (can be negative)", + ) + + args = parser.parse_args() + + if args.output is None and os.path.isdir(args.path): + output_dir = os.path.join(args.path, "output") + elif args.output is None and not os.path.isdir(args.path): + output_dir = os.path.join(os.path.dirname(args.path), "output") + else: + output_dir = args.output + + if not os.path.exists(output_dir): + os.makedirs(output_dir) + elif os.path.exists(output_dir) and not os.path.isdir(output_dir): + raise NotADirectoryError(output_dir) + + if os.path.isdir(args.path): + files = os.listdir(args.path) + files = [ + file for file in files if os.path.isfile(os.path.join(args.path, file)) + ] + + files = [file for file in files if not re.search(r".txt", file) is None] + + for file in files: + convert_simai_file(os.path.join(args.path, file), output_dir, args.delay) + elif not re.search(r".txt", args.path) is None: + convert_simai_file(args.path, output_dir, args.delay) + else: + print("Error: Not a simai file") + sys.exit(1) + + sys.exit(0) + + +def convert_simai_file(input_path, output_dir, delay): + file_name = os.path.splitext(os.path.basename(input_path))[0] + file_ext = ".ma2" + + if os.path.exists(os.path.join(output_dir, file_name + file_ext)): + print("File {} exists! Skipping".format(file_name + file_ext)) + return + + with open(input_path, "r") as in_f: + simai_string = in_f.read() + + simai_chart = SimaiChart.from_str(simai_string) + + ma2 = simai_to_ma2(simai_chart) + if delay is not None: + ma2.offset(delay) + + with open(os.path.join(output_dir, file_name + file_ext), "x") as out_f: + out_f.write(ma2.export()) + + +# Accepts both file and directory paths +def file_path(string): + if os.path.exists(string): + return string.rstrip("/\\") + + raise FileNotFoundError(string) + + +# Only accepts directory paths +def dir_path(string): + if os.path.isdir(string): + return string.rstrip("/\\") + + raise NotADirectoryError(string) + + +if __name__ == "__main__": + main() diff --git a/simai_to_sdt.py b/scripts/simai_to_sdt.py similarity index 93% rename from simai_to_sdt.py rename to scripts/simai_to_sdt.py index 50b5c5c..2bf186e 100644 --- a/simai_to_sdt.py +++ b/scripts/simai_to_sdt.py @@ -3,7 +3,8 @@ import re import sys -from maiconverter import simai_to_sdt, simai_parse_chart +from maiconverter.simai import SimaiChart +from maiconverter.converter import simai_to_sdt def main(): @@ -86,7 +87,7 @@ def convert_simai_file(input_path, output_dir, convert_touch, delay): with open(input_path, "r") as in_f: simai_string = in_f.read() - simai_chart = simai_parse_chart(simai_string) + simai_chart = SimaiChart.from_str(simai_string) sdt = simai_to_sdt(simai_chart, convert_touch) if delay is not None: @@ -100,16 +101,16 @@ def convert_simai_file(input_path, output_dir, convert_touch, delay): def file_path(string): if os.path.exists(string): return string.rstrip("/\\") - else: - raise FileNotFoundError(string) + + raise FileNotFoundError(string) # Only accepts directory paths def dir_path(string): if os.path.isdir(string): return string.rstrip("/\\") - else: - raise NotADirectoryError(string) + + raise NotADirectoryError(string) if __name__ == "__main__": diff --git a/scripts/sxt_to_ma2.py b/scripts/sxt_to_ma2.py new file mode 100644 index 0000000..ce82d71 --- /dev/null +++ b/scripts/sxt_to_ma2.py @@ -0,0 +1,124 @@ +import os +import argparse +import re +import sys + +from maiconverter.maisdt import MaiSDT +from maiconverter.converter import sdt_to_ma2 + + +def main(): + parser = argparse.ArgumentParser( + description="Converts sdt to ma2", allow_abbrev=False + ) + parser.add_argument( + "path", metavar="Path", type=file_path, help="Path to score file or directory" + ) + parser.add_argument( + "--bpm", + metavar="Song BPM", + type=float, + help="BPM of the chart or group of chart's song", + ) + parser.add_argument( + "-o", + "--output", + metavar="Output directory", + type=dir_path, + help="Path to output. Defaults to " + "/path/to/input/output", + ) + parser.add_argument( + "-d", + "--delay", + metavar="Delay", + type=float, + help="Offset a chart by set measures (can be negative)", + ) + + args = parser.parse_args() + + if args.bpm is None: + print("Error: BPM is required") + parser.print_help() + sys.exit(1) + + if args.output is None and os.path.isdir(args.path): + output_dir = os.path.join(args.path, "output") + elif args.output is None and not os.path.isdir(args.path): + output_dir = os.path.join(os.path.dirname(args.path), "output") + else: + output_dir = args.output + + if not os.path.exists(output_dir): + os.makedirs(output_dir) + elif os.path.exists(output_dir) and not os.path.isdir(output_dir): + raise NotADirectoryError(output_dir) + + if os.path.isdir(args.path): + files = os.listdir(args.path) + files = [ + file for file in files if os.path.isfile(os.path.join(args.path, file)) + ] + + files = [file for file in files if not re.search(r"\.s..", file) is None] + + for file in files: + convert_sdt_file( + os.path.join(args.path, file), output_dir, args.bpm, args.delay + ) + elif not re.search(r"\.s..", args.path) is None: + convert_sdt_file(args.path, output_dir, args.bpm, args.delay) + else: + print("Error: Not an sdt file") + sys.exit(1) + + sys.exit(0) + + +def convert_sdt_file(input_path, output_dir, initial_bpm, delay): + sdt = MaiSDT() + + file_name = os.path.splitext(os.path.basename(input_path))[0] + file_ext = ".ma2" + + if os.path.exists(os.path.join(output_dir, file_name + file_ext)): + print("File {} exists! Skipping".format(file_name + file_ext)) + return + + with open(input_path, "r") as in_f: + for line in in_f: + if line in ["\n", "\r\n"]: + continue + + if re.search(r"\.sr.", input_path) is None: + sdt.parse_line(line) + else: + sdt.parse_srt_line(line) + + ma2 = sdt_to_ma2(sdt, initial_bpm) + ma2.set_meter(0, 4, 4) + if delay is not None: + ma2.offset(delay) + + with open(os.path.join(output_dir, file_name + file_ext), "x") as out_f: + out_f.write(ma2.export()) + + +# Accepts both file and directory paths +def file_path(string): + if os.path.exists(string): + return string.rstrip("/\\") + + raise FileNotFoundError(string) + + +# Only accepts directory paths +def dir_path(string): + if os.path.isdir(string): + return string.rstrip("/\\") + + raise NotADirectoryError(string) + + +if __name__ == "__main__": + main() diff --git a/sxt_to_simai.py b/scripts/sxt_to_simai.py similarity index 91% rename from sxt_to_simai.py rename to scripts/sxt_to_simai.py index 0166941..7f4573d 100644 --- a/sxt_to_simai.py +++ b/scripts/sxt_to_simai.py @@ -3,7 +3,8 @@ import re import sys -from maiconverter import MaiSDT, sdt_to_simai +from maiconverter.maisdt import MaiSDT +from maiconverter.converter import sdt_to_simai def main(): @@ -16,7 +17,7 @@ def main(): parser.add_argument( "--bpm", metavar="Song BPM", - type=bpm_type, + type=float, help="BPM of the chart or group of chart's song", ) parser.add_argument( @@ -106,23 +107,16 @@ def convert_sdt_file(input_path, output_dir, initial_bpm, delay): def file_path(string): if os.path.exists(string): return string.rstrip("/\\") - else: - raise FileNotFoundError(string) + + raise FileNotFoundError(string) # Only accepts directory paths def dir_path(string): if os.path.isdir(string): return string.rstrip("/\\") - else: - raise NotADirectoryError(string) - -def bpm_type(number): - try: - return float(number) - except: - raise TypeError("BPM is not a number") + raise NotADirectoryError(string) if __name__ == "__main__": diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..1801df4 --- /dev/null +++ b/setup.py @@ -0,0 +1,39 @@ +from setuptools import setup + +with open("README.md", "r", encoding="utf-8") as fh: + long_description = fh.read() + +setup( + name="MaiConverter", + description="Parse and convert Maimai chart formats", + long_description=long_description, + long_description_content_type="text/markdown", + author="donmai", + url="https://github.com/donmai-me/MaiConverter", + classifiers=[ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Development Status :: 4 - Beta", + "Intended Audience :: Other Audience", + "Topic :: Games/Entertainment :: Arcade", + ], + packages=[ + "maiconverter", + "maiconverter.event", + "maiconverter.maicrypt", + "maiconverter.maima2", + "maiconverter.maisdt", + "maiconverter.simai", + "maiconverter.converter", + ], + package_data={"": ["*.lark"]}, + entry_points={ + "console_scripts": ["maiconverter=maiconverter:main"], + }, + python_requires="~=3.8", + use_scm_version=True, + setup_requires=["setuptools_scm"], + install_requires=["pycryptodome~=3.9", "lark-parser~=0.11"], +)