├── .flake8 ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── pythonapp.yml ├── .gitignore ├── LICENSE ├── README.md ├── docs ├── index.html ├── pycco.css ├── qua2osu-gui.html └── qua2osu.html ├── gui ├── gui.py └── gui.ui ├── qua2osu-gui.py ├── qua2osu.py └── requirements.txt /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = 3 | # Autogenerated file 4 | gui.py, 5 | build,dist,docs,env 6 | 7 | # star imports 8 | ignore = F403,F405 9 | # because 79 is just too low 10 | max-line-length = 125 -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: IceDynamix 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: IceDynamix 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/pythonapp.yml: -------------------------------------------------------------------------------- 1 | name: Python application 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v1 12 | - name: Set up Python 3.7 13 | uses: actions/setup-python@v1 14 | with: 15 | python-version: 3.7 16 | - name: Install dependencies 17 | run: | 18 | python -m pip install --upgrade pip 19 | pip install -r requirements.txt 20 | - name: Lint with flake8 21 | run: | 22 | pip install flake8 23 | # stop the build if there are Python syntax errors or undefined names 24 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 25 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 26 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # celery beat schedule file 95 | celerybeat-schedule 96 | 97 | # SageMath parsed files 98 | *.sage.py 99 | 100 | # Environments 101 | .env 102 | .venv 103 | env/ 104 | venv/ 105 | ENV/ 106 | env.bak/ 107 | venv.bak/ 108 | 109 | # Spyder project settings 110 | .spyderproject 111 | .spyproject 112 | 113 | # Rope project settings 114 | .ropeproject 115 | 116 | # mkdocs documentation 117 | /site 118 | 119 | # mypy 120 | .mypy_cache/ 121 | .dmypy.json 122 | dmypy.json 123 | 124 | # Pyre type checker 125 | .pyre/ 126 | .vscode/settings.json 127 | 128 | # Custom 129 | output/ 130 | input/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 IceDynamix 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # qua2osu 2 | 3 | > Converts quaver map files (.qp) to osu! map files (.osz) 4 | 5 | Outdated and not maintained anymore, use my rewrite [qua3osu](https://github.com/IceDynamix/qua3osu) instead. Currently doesn't have a GUI yet, but saves a lot of hassle. 6 | 7 |
8 | Old content 9 | 10 | ## Screenshot 11 | 12 | ![Image of the GUI](https://i.imgur.com/LYwGaVj.png) 13 | 14 | ## Download 15 | 16 | Download the lastest release from 17 | [here](https://github.com/IceDynamix/qua2osu/releases) 18 | 19 | ## Step by step instructions 20 | 21 | - Download qua2osu, no installation needed etc. 22 | - You can decide to use the command-line tool or the GUI (Graphical User 23 | Interface) 24 | - Command-line: 25 | - Execute the qua2osu.exe file with the --help flag in the console by 26 | running `qua2osu.exe --help` (double-clicking doesn't work) 27 | - Set options with some flags to spice things up (Example: 28 | `qua2osu.exe -i myfolder/subfolder -od 8.5 -hp 9 -hv 0` which would 29 | convert all .qp files in "myfolder/subfolder", set OD to 8.5, HP to 9 and 30 | hitsound volume to 0) 31 | - GUI: 32 | - Execute the qua2osu-gui.exe file, either by double-clicking or by running 33 | it in the console 34 | - GUI should pop up, select a folder with your .qp files and select a folder 35 | to output your .osz files 36 | - Set some settings and click on convert 37 | 38 | ## Step by step instructions to build the project yourself 39 | 40 | - Install [Git](https://git-scm.com/) and [Python](https://www.python.org/) if 41 | necessary 42 | - Install [pip](https://pip.pypa.io/en/stable/installing/) if necessary (should 43 | ship with python) 44 | - Clone this repo: `git clone https://github.com/IceDynamix/qua2osu.git` 45 | - *It's best to set up a 46 | [virtual environment](https://docs.python.org/3/tutorial/venv.html) for the 47 | project, but not necessary if you don't know how to* 48 | - *Activate your virtual environment by running the activate file in your 49 | virtual environment folder* 50 | - Run `pip install -r requirements.txt` in the directory to install all package 51 | dependencies (mainly reamber (conversion), PyQT5 (gui) and some QOL stuff) 52 | - Run `py qua2osu.py` or `py qua2osu-gui.py` 53 | 54 | ## Documentation 55 | 56 | This project uses [pycco](https://github.com/pycco-docs/pycco) to create 57 | documentation. Regenerating the documentation after modifying or adding new 58 | files is done by `pycco ./*.py`. Having a git hook that generates it pre-commit 59 | and adds it to the staged files is recommended. 60 | 61 | [Documentation](https://icedynamix.github.io/qua2osu/index.html) 62 | 63 | ## Contributing 64 | 65 | In case you want to contribute to this project, please keep following things in 66 | mind: 67 | 68 | - This project uses [flake8](http://flake8.pycqa.org/en/latest/) as the primary 69 | linter. A `.flake8` is present in the root directory. Run 70 | `flake8 yourpythonfile.py` to lint your file. 71 | - [QT Designer](https://build-system.fman.io/qt-designer-download) is used to 72 | work with the gui.ui file. Use `pyuic5 -x gui/gui.ui -o gui/gui.py` in the 73 | root directory to regenerate the gui.py file after editing the gui.ui file. 74 | - Use [camelCase](https://en.wikipedia.org/wiki/Camel_case) for variable, 75 | function and method names. 76 | - Use PascalCase (camelCase, but first letter capitalized) for class names. 77 | - Use UPPER_SNAKE_CASE for constants. 78 | - Please document your code. Refer to the Documentation section. 79 | 80 | Please report issues [here on github](https://github.com/IceDynamix/qua2osu/issues). 81 | 82 | ## Referenced games 83 | 84 | - [**Quaver**](https://quavergame.com/) 85 | - [**osu!**](https://osu.ppy.sh/) 86 |
87 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Index 6 | 7 | 8 | 9 |
10 |
11 |
12 |

Index

13 |
14 |
15 |
16 |
17 |
18 | # 19 |
20 |
  • qua2osu-gui.html
  • qua2osu.html
  • 21 |
    22 |
    23 | 24 |
    25 |
    26 |
    27 |
    28 | 29 | -------------------------------------------------------------------------------- /docs/pycco.css: -------------------------------------------------------------------------------- 1 | /*--------------------- Layout and Typography ----------------------------*/ 2 | body { 3 | font-family: 'Palatino Linotype', 'Book Antiqua', Palatino, FreeSerif, serif; 4 | font-size: 16px; 5 | line-height: 24px; 6 | color: #252519; 7 | margin: 0; padding: 0; 8 | background: #f5f5ff; 9 | } 10 | a { 11 | color: #261a3b; 12 | } 13 | a:visited { 14 | color: #261a3b; 15 | } 16 | p { 17 | margin: 0 0 15px 0; 18 | } 19 | h1, h2, h3, h4, h5, h6 { 20 | margin: 40px 0 15px 0; 21 | } 22 | h2, h3, h4, h5, h6 { 23 | margin-top: 0; 24 | } 25 | #container { 26 | background: white; 27 | } 28 | #container, div.section { 29 | position: relative; 30 | } 31 | #background { 32 | position: absolute; 33 | top: 0; left: 580px; right: 0; bottom: 0; 34 | background: #f5f5ff; 35 | border-left: 1px solid #e5e5ee; 36 | z-index: 0; 37 | } 38 | #jump_to, #jump_page { 39 | background: white; 40 | -webkit-box-shadow: 0 0 25px #777; -moz-box-shadow: 0 0 25px #777; 41 | -webkit-border-bottom-left-radius: 5px; -moz-border-radius-bottomleft: 5px; 42 | font: 10px Arial; 43 | text-transform: uppercase; 44 | cursor: pointer; 45 | text-align: right; 46 | } 47 | #jump_to, #jump_wrapper { 48 | position: fixed; 49 | right: 0; top: 0; 50 | padding: 5px 10px; 51 | } 52 | #jump_wrapper { 53 | padding: 0; 54 | display: none; 55 | } 56 | #jump_to:hover #jump_wrapper { 57 | display: block; 58 | } 59 | #jump_page { 60 | padding: 5px 0 3px; 61 | margin: 0 0 25px 25px; 62 | } 63 | #jump_page .source { 64 | display: block; 65 | padding: 5px 10px; 66 | text-decoration: none; 67 | border-top: 1px solid #eee; 68 | } 69 | #jump_page .source:hover { 70 | background: #f5f5ff; 71 | } 72 | #jump_page .source:first-child { 73 | } 74 | div.docs { 75 | float: left; 76 | max-width: 500px; 77 | min-width: 500px; 78 | min-height: 5px; 79 | padding: 10px 25px 1px 50px; 80 | vertical-align: top; 81 | text-align: left; 82 | } 83 | .docs pre { 84 | margin: 15px 0 15px; 85 | padding-left: 15px; 86 | } 87 | .docs p tt, .docs p code { 88 | background: #f8f8ff; 89 | border: 1px solid #dedede; 90 | font-size: 12px; 91 | padding: 0 0.2em; 92 | } 93 | .octowrap { 94 | position: relative; 95 | } 96 | .octothorpe { 97 | font: 12px Arial; 98 | text-decoration: none; 99 | color: #454545; 100 | position: absolute; 101 | top: 3px; left: -20px; 102 | padding: 1px 2px; 103 | opacity: 0; 104 | -webkit-transition: opacity 0.2s linear; 105 | } 106 | div.docs:hover .octothorpe { 107 | opacity: 1; 108 | } 109 | div.code { 110 | margin-left: 580px; 111 | padding: 14px 15px 16px 50px; 112 | vertical-align: top; 113 | } 114 | .code pre, .docs p code { 115 | font-size: 12px; 116 | } 117 | pre, tt, code { 118 | line-height: 18px; 119 | font-family: Monaco, Consolas, "Lucida Console", monospace; 120 | margin: 0; padding: 0; 121 | } 122 | div.clearall { 123 | clear: both; 124 | } 125 | 126 | 127 | /*---------------------- Syntax Highlighting -----------------------------*/ 128 | td.linenos { background-color: #f0f0f0; padding-right: 10px; } 129 | span.lineno { background-color: #f0f0f0; padding: 0 5px 0 5px; } 130 | body .hll { background-color: #ffffcc } 131 | body .c { color: #408080; font-style: italic } /* Comment */ 132 | body .err { border: 1px solid #FF0000 } /* Error */ 133 | body .k { color: #954121 } /* Keyword */ 134 | body .o { color: #666666 } /* Operator */ 135 | body .cm { color: #408080; font-style: italic } /* Comment.Multiline */ 136 | body .cp { color: #BC7A00 } /* Comment.Preproc */ 137 | body .c1 { color: #408080; font-style: italic } /* Comment.Single */ 138 | body .cs { color: #408080; font-style: italic } /* Comment.Special */ 139 | body .gd { color: #A00000 } /* Generic.Deleted */ 140 | body .ge { font-style: italic } /* Generic.Emph */ 141 | body .gr { color: #FF0000 } /* Generic.Error */ 142 | body .gh { color: #000080; font-weight: bold } /* Generic.Heading */ 143 | body .gi { color: #00A000 } /* Generic.Inserted */ 144 | body .go { color: #808080 } /* Generic.Output */ 145 | body .gp { color: #000080; font-weight: bold } /* Generic.Prompt */ 146 | body .gs { font-weight: bold } /* Generic.Strong */ 147 | body .gu { color: #800080; font-weight: bold } /* Generic.Subheading */ 148 | body .gt { color: #0040D0 } /* Generic.Traceback */ 149 | body .kc { color: #954121 } /* Keyword.Constant */ 150 | body .kd { color: #954121; font-weight: bold } /* Keyword.Declaration */ 151 | body .kn { color: #954121; font-weight: bold } /* Keyword.Namespace */ 152 | body .kp { color: #954121 } /* Keyword.Pseudo */ 153 | body .kr { color: #954121; font-weight: bold } /* Keyword.Reserved */ 154 | body .kt { color: #B00040 } /* Keyword.Type */ 155 | body .m { color: #666666 } /* Literal.Number */ 156 | body .s { color: #219161 } /* Literal.String */ 157 | body .na { color: #7D9029 } /* Name.Attribute */ 158 | body .nb { color: #954121 } /* Name.Builtin */ 159 | body .nc { color: #0000FF; font-weight: bold } /* Name.Class */ 160 | body .no { color: #880000 } /* Name.Constant */ 161 | body .nd { color: #AA22FF } /* Name.Decorator */ 162 | body .ni { color: #999999; font-weight: bold } /* Name.Entity */ 163 | body .ne { color: #D2413A; font-weight: bold } /* Name.Exception */ 164 | body .nf { color: #0000FF } /* Name.Function */ 165 | body .nl { color: #A0A000 } /* Name.Label */ 166 | body .nn { color: #0000FF; font-weight: bold } /* Name.Namespace */ 167 | body .nt { color: #954121; font-weight: bold } /* Name.Tag */ 168 | body .nv { color: #19469D } /* Name.Variable */ 169 | body .ow { color: #AA22FF; font-weight: bold } /* Operator.Word */ 170 | body .w { color: #bbbbbb } /* Text.Whitespace */ 171 | body .mf { color: #666666 } /* Literal.Number.Float */ 172 | body .mh { color: #666666 } /* Literal.Number.Hex */ 173 | body .mi { color: #666666 } /* Literal.Number.Integer */ 174 | body .mo { color: #666666 } /* Literal.Number.Oct */ 175 | body .sb { color: #219161 } /* Literal.String.Backtick */ 176 | body .sc { color: #219161 } /* Literal.String.Char */ 177 | body .sd { color: #219161; font-style: italic } /* Literal.String.Doc */ 178 | body .s2 { color: #219161 } /* Literal.String.Double */ 179 | body .se { color: #BB6622; font-weight: bold } /* Literal.String.Escape */ 180 | body .sh { color: #219161 } /* Literal.String.Heredoc */ 181 | body .si { color: #BB6688; font-weight: bold } /* Literal.String.Interpol */ 182 | body .sx { color: #954121 } /* Literal.String.Other */ 183 | body .sr { color: #BB6688 } /* Literal.String.Regex */ 184 | body .s1 { color: #219161 } /* Literal.String.Single */ 185 | body .ss { color: #19469D } /* Literal.String.Symbol */ 186 | body .bp { color: #954121 } /* Name.Builtin.Pseudo */ 187 | body .vc { color: #19469D } /* Name.Variable.Class */ 188 | body .vg { color: #19469D } /* Name.Variable.Global */ 189 | body .vi { color: #19469D } /* Name.Variable.Instance */ 190 | body .il { color: #666666 } /* Literal.Number.Integer.Long */ 191 | -------------------------------------------------------------------------------- /docs/qua2osu-gui.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | qua2osu-gui.py 6 | 7 | 8 | 9 |
    10 |
    11 |
    12 |

    qua2osu-gui.py

    13 |
    14 |
    15 |
    16 |
    17 |
    18 | # 19 |
    20 | 21 |
    22 |
    23 |
    import os
     24 | import sys
     25 | import time
     26 | import webbrowser  # to open the explorer cross-platform
     27 | 
     28 | from PyQt5.QtCore import *
     29 | from PyQt5.QtGui import *
     30 | from PyQt5.QtWidgets import *
     31 | 
     32 | from conversion import convertMapset
    33 |
    34 |
    35 |
    36 |
    37 |
    38 |
    39 | # 40 |
    41 |

    gui.py is autogenerated from gui.ui (qt designer)

    42 |
    43 |
    44 |
    45 |
    46 |
    47 |
    48 |
    49 |
    50 |
    51 | # 52 |
    53 |

    use “pyuic5 -x gui/gui.ui -o gui/gui.py” after editing gui.ui with qt designer

    54 |
    55 |
    56 |
    from gui.gui import Ui_MainWindow
    57 |
    58 |
    59 |
    60 |
    61 |
    62 |
    63 | # 64 |
    65 |

    Custom window class with all of the GUI functionality

    66 |
    67 |
    68 |
    class IceMainWindow(QMainWindow, Ui_MainWindow):
    69 |
    70 |
    71 |
    72 |
    73 |
    74 |
    75 | # 76 |
    77 |

    All of the objects themselves are managed by QT designer in the gui.ui (gui.py) file

    78 |
    79 |
    80 |
    81 |
    82 |
    83 |
    84 |
    85 |
    86 |
    87 | # 88 |
    89 | 90 |
    91 |
    92 |
        def __init__(self):
     93 |         QMainWindow.__init__(self)
     94 |         self.setupUi(self)
     95 |         self.inputToolButton.clicked.connect(self.openInputDirectoryDialog)
     96 |         self.outputToolButton.clicked.connect(self.openOutputDirectoryDialog)
     97 |         self.convertPushButton.clicked.connect(self.convertOnClick)
     98 | 
     99 |         self.updateStatus("Finished setup")
    100 |
    101 |
    102 |
    103 |
    104 |
    105 |
    106 | # 107 |
    108 |

    Opens the input directory dialog and sets the input path in the GUI

    109 |
    110 |
    111 |
        def openInputDirectoryDialog(self):
    112 |
    113 |
    114 |
    115 |
    116 |
    117 |
    118 | # 119 |
    120 | 121 |
    122 |
    123 |
            path = str(QFileDialog.getExistingDirectory(
    124 |             self, "Select Input Folder"))
    125 |         self.inputLineEdit.setText(path)
    126 |         self.updateStatus("Set input path to " + path)
    127 |
    128 |
    129 |
    130 |
    131 |
    132 |
    133 | # 134 |
    135 |

    Opens the output directory dialog and sets the input path in the GUI

    136 |
    137 |
    138 |
        def openOutputDirectoryDialog(self):
    139 |
    140 |
    141 |
    142 |
    143 |
    144 |
    145 | # 146 |
    147 | 148 |
    149 |
    150 |
            path = str(QFileDialog.getExistingDirectory(
    151 |             self, "Select Output Folder"))
    152 |         self.outputLineEdit.setText(path)
    153 |         self.updateStatus("Set output path to " + path)
    154 |
    155 |
    156 |
    157 |
    158 |
    159 |
    160 | # 161 |
    162 |

    Wrapper for updating the status label, always shows the two last lines

    163 |
    164 |
    165 |
        def updateStatus(self, status):
    166 |
    167 |
    168 |
    169 |
    170 |
    171 |
    172 | # 173 |
    174 | 175 |
    176 |
    177 |
            currentText = self.statusLabel.text()
    178 |         lastLine = currentText.split("\n")[-1]
    179 |         self.statusLabel.setText(lastLine + "\n" + status)
    180 |
    181 |
    182 |
    183 |
    184 |
    185 |
    186 | # 187 |
    188 |

    Wrapper for updating the progress bar maximum

    189 |
    190 |
    191 |
        def updateProgressBarMax(self, maximum):
    192 |
    193 |
    194 |
    195 |
    196 |
    197 |
    198 | # 199 |
    200 | 201 |
    202 |
    203 |
            self.progressBar.setMaximum(maximum)
    204 |
    205 |
    206 |
    207 |
    208 |
    209 |
    210 | # 211 |
    212 |

    Increments the progress bar by one unit total, adds a smooth animation

    213 |
    214 |
    215 |
        def incrementProgressBarValue(self):
    216 |
    217 |
    218 |
    219 |
    220 |
    221 |
    222 | # 223 |
    224 | 225 |
    226 |
    227 |
            self.progressBar.setValue(self.progressBar.value() + 1)
    228 |
    229 |
    230 |
    231 |
    232 |
    233 |
    234 | # 235 |
    236 |

    Sets up the converter thread

    237 |
    238 |
    239 |
        def initiateConverterThread(self, inputPath, outputPath, options):
    240 |
    241 |
    242 |
    243 |
    244 |
    245 |
    246 | # 247 |
    248 | 249 |
    250 |
    251 |
            converterThread = ConverterThread(inputPath, outputPath, options)
    252 |         converterThread.updateStatus.connect(self.updateStatus)
    253 |         converterThread.updateProgressbarMax.connect(
    254 |             self.updateProgressBarMax)
    255 |         converterThread.incrementProgressbarValue.connect(
    256 |             self.incrementProgressBarValue)
    257 | 
    258 |         return converterThread
    259 |
    260 |
    261 |
    262 |
    263 |
    264 |
    265 | # 266 |
    267 |

    Starts the map conversion of the folder

    268 |
    269 |
    270 |
        def convertOnClick(self):
    271 |
    272 |
    273 |
    274 |
    275 |
    276 |
    277 | # 278 |
    279 | 280 |
    281 |
    282 |
            inputPath = self.inputLineEdit.text()
    283 |         outputPath = self.outputLineEdit.text()
    284 | 
    285 |         if inputPath == "" or outputPath == "":
    286 |             self.updateStatus("Empty paths detected, using defaults")
    287 | 
    288 |             if inputPath == "":
    289 |                 inputPath = "input"
    290 | 
    291 |             if outputPath == "":
    292 |                 outputPath = "output"
    293 | 
    294 |         selectedOd = self.odDoubleSpinBox.value()
    295 |         selectedHp = self.hpDoubleSpinBox.value()
    296 |         selectedHitSoundVolume = self.hsVolumeSpinBox.value()
    297 |
    298 |
    299 |
    300 |
    301 |
    302 |
    303 | # 304 |
    305 |

    please tell me if there’s an easier way to lookup 306 | which radio button is checked because this is horrible

    307 |
    308 |
    309 |
            selectedSampleSet = ""
    310 |         sampleSetButtons = [
    311 |             self.sampleSetNormalRadioButton,
    312 |             self.sampleSetSoftRadioButton,
    313 |             self.sampleSetDrumRadioButton
    314 |         ]
    315 | 
    316 |         for button in sampleSetButtons:
    317 |             if button.isChecked():
    318 |                 selectedSampleSet = button.text()
    319 |                 break
    320 | 
    321 |         options = {
    322 |             "od": selectedOd,
    323 |             "hp": selectedHp,
    324 |             "hitSoundVolume": selectedHitSoundVolume,
    325 |             "sampleSet": selectedSampleSet
    326 |         }
    327 | 
    328 |         self.progressBar.setValue(0)
    329 | 
    330 |         self.converterThread = self.initiateConverterThread(
    331 |             inputPath, outputPath, options)
    332 |         self.converterThread.start()
    333 |
    334 |
    335 |
    336 |
    337 |
    338 |
    339 | # 340 |
    341 |

    Using a different thread for the map conversion

    342 |
    343 |
    344 |
    class ConverterThread(QThread):
    345 |
    346 |
    347 |
    348 |
    349 |
    350 |
    351 | # 352 |
    353 |

    Otherwise the UI would freeze and become unresponsive during the conversion

    354 |
    355 |
    356 |
        updateStatus = pyqtSignal(str)
    357 |     incrementProgressbarValue = pyqtSignal()
    358 |     updateProgressbarMax = pyqtSignal(int)
    359 |
    360 |
    361 |
    362 |
    363 |
    364 |
    365 | # 366 |
    367 | 368 |
    369 |
    370 |
        def __init__(self, inputPath, outputPath, options):
    371 |         QThread.__init__(self)
    372 |         self.inputPath = inputPath
    373 |         self.outputPath = outputPath
    374 |         self.options = options
    375 |
    376 |
    377 |
    378 |
    379 |
    380 |
    381 | # 382 |
    383 | 384 |
    385 |
    386 |
        def __del__(self):
    387 |         self.wait()
    388 |
    389 |
    390 |
    391 |
    392 |
    393 |
    394 | # 395 |
    396 | 397 |
    398 |
    399 |
        def run(self):
    400 |         qpFilesInInputDir = []
    401 | 
    402 |         for file in os.listdir(self.inputPath):
    403 |             path = os.path.join(self.inputPath, file)
    404 |             if file.endswith('.qp') and os.path.isfile(path):
    405 |                 qpFilesInInputDir.append(file)
    406 | 
    407 |         numberOfQpFiles = len(qpFilesInInputDir)
    408 | 
    409 |         if numberOfQpFiles == 0:
    410 |             self.updateStatus.emit("No mapsets found in " + self.inputPath)
    411 |             return
    412 | 
    413 |         self.updateProgressbarMax.emit(numberOfQpFiles)
    414 | 
    415 |         start = time.time()
    416 |         count = 1
    417 | 
    418 |         for file in qpFilesInInputDir:
    419 |             filePath = os.path.join(self.inputPath, file)
    420 | 
    421 |             self.updateStatus.emit(f"({count}/{numberOfQpFiles}) "
    422 |                                    f"Converting {filePath}")
    423 | 
    424 |             convertMapset(filePath, self.outputPath, self.options)
    425 |             count += 1
    426 |             self.incrementProgressbarValue.emit()
    427 | 
    428 |         end = time.time()
    429 |         timeElapsed = round(end - start, 2)
    430 | 
    431 |         self.updateStatus.emit(
    432 |             f"Finished converting all mapsets,"
    433 |             f"total time elapsed: {timeElapsed} seconds"
    434 |         )
    435 |
    436 |
    437 |
    438 |
    439 |
    440 |
    441 | # 442 |
    443 |

    Opens output folder in explorer

    444 |
    445 |
    446 |
            absoluteOutputPath = os.path.realpath(self.outputPath)
    447 |         webbrowser.open("file:///" + absoluteOutputPath)
    448 | 
    449 |         return
    450 |
    451 |
    452 |
    453 |
    454 |
    455 |
    456 | # 457 |
    458 |

    Custom QApplication class for the sole purpose of applying the Fusion style

    459 |
    460 |
    461 |
    class IceApp(QApplication):
    462 |
    463 |
    464 |
    465 |
    466 |
    467 |
    468 | # 469 |
    470 | 471 |
    472 |
    473 |
    474 |
    475 |
    476 |
    477 |
    478 |
    479 |
    480 | # 481 |
    482 | 483 |
    484 |
    485 |
        def __init__(self):
    486 |         super().__init__(sys.argv)
    487 |         self.setStyle("Fusion")
    488 |
    489 |
    490 |
    491 |
    492 |
    493 |
    494 | # 495 |
    496 | 497 |
    498 |
    499 |
    def main():
    500 |     app = IceApp()
    501 |     iceApp = IceMainWindow()
    502 |     iceApp.show()
    503 |     sys.exit(app.exec_())
    504 | 
    505 | 
    506 | if __name__ == '__main__':
    507 |     main()
    508 | 
    509 | 
    510 |
    511 |
    512 |
    513 |
    514 | 515 | -------------------------------------------------------------------------------- /docs/qua2osu.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | qua2osu.py 6 | 7 | 8 | 9 |
    10 |
    11 |
    12 |

    qua2osu.py

    13 |
    14 |
    15 |
    16 |
    17 |
    18 | # 19 |
    20 |

    Command-line tool for map conversion

    21 |
    22 |
    23 |
    24 |
    25 |
    26 |
    27 |
    28 |
    29 |
    30 | # 31 |
    32 |

    Imports

    33 |
    34 |
    35 |
    import argparse  # parsing command line arguments
     36 | import os  # for paths and directories
     37 | import re
     38 | import sys  # used only for sys.exit()
     39 | import time  # to measure execution time
     40 | import webbrowser  # to open the explorer cross-platform
     41 | import zipfile  # to handle .zip files (.qua and .osz)
     42 | 
     43 | from reamber.algorithms.convert import QuaToOsu
     44 | from reamber.quaver import QuaMap
    45 |
    46 |
    47 |
    48 |
    49 |
    50 |
    51 | # 52 |
    53 |

    Constants

    54 |
    55 |
    56 |
    SAMPLESETS = [
     57 |     "Soft",
     58 |     "Normal",
     59 |     "Drum"
     60 | ]
    61 |
    62 |
    63 |
    64 |
    65 |
    66 |
    67 | # 68 |
    69 |

    Functions

    70 |
    71 |
    72 |
    73 |
    74 |
    75 |
    76 |
    77 |
    78 |
    79 | # 80 |
    81 |

    Creates an argument parser with all of the arguments already added

    82 |
    83 |
    84 |
    def initArgParser() -> argparse.ArgumentParser:
    85 |
    86 |
    87 |
    88 |
    89 |
    90 |
    91 | # 92 |
    93 | 94 |
    95 |
    96 |
        argParser = argparse.ArgumentParser("Converts .qp files to .osz files")
    97 |
    98 |
    99 |
    100 |
    101 |
    102 |
    103 | # 104 |
    105 | 106 |
    107 |
    108 |
        def qpOrDirPath(inputPath):
    109 |         if (inputPath.endswith(".qp") and os.path.isFile(inputPath)) or os.path.isdir(inputPath):
    110 |             return inputPath
    111 |         else:
    112 |             raise argparse.ArgumentTypeError("Path is not a directory or not a .qp file")
    113 | 
    114 |     argParser.add_argument(
    115 |         "input",
    116 |         help="Paths of directories containing .qp files, or direct paths to .qp files. Both are possible.",
    117 |         nargs="*",
    118 |         type=qpOrDirPath
    119 |     )
    120 |
    121 |
    122 |
    123 |
    124 |
    125 |
    126 | # 127 |
    128 | 129 |
    130 |
    131 |
        def directory(path):
    132 |         if os.path.isdir(path):
    133 |             return path
    134 |         raise argparse.ArgumentTypeError("Not a valid path")
    135 | 
    136 |     argParser.add_argument(
    137 |         "-o",
    138 |         "--output",
    139 |         required=False,
    140 |         help="Path of the output folder, defaults to ./output",
    141 |         default="./output",
    142 |         type=directory
    143 |     )
    144 |
    145 |
    146 |
    147 |
    148 |
    149 |
    150 | # 151 |
    152 | 153 |
    154 |
    155 |
        def diffValue(x):
    156 |         x = float(x)
    157 |         if x >= 0 and x <= 10:
    158 |             return x
    159 |         else:
    160 |             raise argparse.ArgumentTypeError("Value must be larger between 0 and 10")
    161 | 
    162 |     argParser.add_argument(
    163 |         "-od",
    164 |         "--overall-difficulty",
    165 |         required=False,
    166 |         help="Overall difficulty as an integer between 0 and 10, defaults to 8",
    167 |         default=8,
    168 |         type=diffValue
    169 |     )
    170 | 
    171 |     argParser.add_argument(
    172 |         "-hp",
    173 |         "--hp-drain",
    174 |         required=False,
    175 |         help="HP drain as an integer between 0 and 10, defaults to 8",
    176 |         default=8,
    177 |         type=diffValue
    178 |     )
    179 |
    180 |
    181 |
    182 |
    183 |
    184 |
    185 | # 186 |
    187 | 188 |
    189 |
    190 |
        def hsVolume(n):
    191 |         n = int(n)
    192 |         if n >= 0 and n <= 100:
    193 |             return n
    194 |         else:
    195 |             raise argparse.ArgumentTypeError("Value must be between 0 and 100")
    196 | 
    197 |     argParser.add_argument(
    198 |         "-hv",
    199 |         "--hitsound-volume",
    200 |         required=False,
    201 |         help="Hitsound volume as an integer between 0 and 100, defaults to 20",
    202 |         default=20,
    203 |         type=hsVolume
    204 |     )
    205 | 
    206 |     argParser.add_argument(
    207 |         "-hs",
    208 |         "--sampleset",
    209 |         required=False,
    210 |         help="Hitsound sample set as either 'Soft', 'Normal' or 'Drum', defaults to Soft",
    211 |         default="Soft",
    212 |         type=str,
    213 |         choices=SAMPLESETS
    214 |     )
    215 | 
    216 |     argParser.add_argument(
    217 |         "-c",
    218 |         "--creator",
    219 |         required=False,
    220 |         help="Sets a different mapper name for all difficulties, in case Quaver username and osu! username are different",
    221 |         type=str
    222 |     )
    223 | 
    224 |     argParser.add_argument(
    225 |         "-p",
    226 |         "--preserve-folder-structure",
    227 |         required=False,
    228 |         help="Outputs in original directory structure if specified",
    229 |         action="store_true"
    230 |     )
    231 | 
    232 |     argParser.add_argument(
    233 |         "-r",
    234 |         "--recursive-search",
    235 |         required=False,
    236 |         help="Looks for .qp in all subdirectories of given directories if specified",
    237 |         action="store_true"
    238 |     )
    239 | 
    240 |     return argParser
    241 |
    242 |
    243 |
    244 |
    245 |
    246 |
    247 | # 248 |
    249 | 250 |
    251 |
    252 |
    def searchForQpFiles(directory: str, qpList: list, recursive: bool) -> list:
    253 |     for path in os.listdir(directory):
    254 |         fullRelativePath = os.path.join(directory, path)
    255 |         if os.path.isfile(fullRelativePath) and fullRelativePath.endswith(".qp"):
    256 |             qpList.append(os.path.normpath(fullRelativePath))
    257 |         elif os.path.isdir(fullRelativePath) and recursive:
    258 |             searchForQpFiles(fullRelativePath, qpList, recursive)
    259 |
    260 |
    261 |
    262 |
    263 |
    264 |
    265 | # 266 |
    267 |

    Converts a whole .qp mapset to a .osz mapset

    268 |
    269 |
    270 |
    def convertQp(path: str, outputFolder: str, options) -> None:
    271 |
    272 |
    273 |
    274 |
    275 |
    276 |
    277 | # 278 |
    279 |

    Moves all files to a new directory and converts all .qua files to .osu files

    280 |

    Options parameter is built up as following:

    281 |
    options = {
    282 |     "od": int,
    283 |     "hp": int,
    284 |     "hitSoundVolume": int,
    285 |     "sampleSet": ["Soft","Normal","Drum"]
    286 | }
    287 | 
    288 |
    289 |
    290 |
    291 |
    292 |
    293 |
    294 |
    295 |
    296 |
    297 | # 298 |
    299 |

    Prefixing with “q_” to prevent osu from showing the wrong preview 300 | backgrounds, because it takes the folder number to 301 | choose the background for whatever reason

    302 |
    303 |
    304 |
        folderName = "q_" + os.path.basename(path).replace(".qp", "")
    305 |     outputPath = os.path.join(outputFolder, folderName)
    306 |
    307 |
    308 |
    309 |
    310 |
    311 |
    312 | # 313 |
    314 |

    Opens the .qp (.zip) mapset file and extracts it into a folder in the same directory

    315 |
    316 |
    317 |
        with zipfile.ZipFile(path, "r") as oldDir:
    318 |         oldDir.extractall(outputPath)
    319 |
    320 |
    321 |
    322 |
    323 |
    324 |
    325 | # 326 |
    327 |

    Converts each .qua difficulty file

    328 |
    329 |
    330 |
        for file in os.listdir(outputPath):
    331 |         filePath = os.path.join(outputPath, file)
    332 |
    333 |
    334 |
    335 |
    336 |
    337 |
    338 | # 339 |
    340 |

    Replaces each .qua file with the converted .osu file, uses Evening’s reamber package

    341 |
    342 |
    343 |
            if file.endswith(".qua"):
    344 |             qua = QuaMap.readFile(filePath)
    345 |             convertedOsu = QuaToOsu.convert(qua)
    346 | 
    347 |             if options["od"]:
    348 |                 convertedOsu.overallDifficulty = options["od"]
    349 |             if options["hp"]:
    350 |                 convertedOsu.hpDrainRate = options["hp"]
    351 |             if options["creator"]:
    352 |                 convertedOsu.creator = options["creator"]
    353 | 
    354 |             if options["hitSoundVolume"] or options["sampleset"]:
    355 |                 for list in [convertedOsu.bpms.data(), convertedOsu.svs.data()]:
    356 |                     for element in list:
    357 |                         if options["hitSoundVolume"]:
    358 |                             element.volume = options["hitSoundVolume"]
    359 |                         if options["sampleSet"]:
    360 |                             element.sampleSet = SAMPLESETS.index(options["sampleSet"])
    361 | 
    362 |             newFileName = re.sub(r"\.qua$", ".osu", filePath, 1, re.MULTILINE)
    363 |             convertedOsu.writeFile(newFileName)
    364 |             os.remove(filePath)
    365 |
    366 |
    367 |
    368 |
    369 |
    370 |
    371 | # 372 |
    373 |

    Creates a new .osz (.zip) mapset file

    374 |
    375 |
    376 |
        with zipfile.ZipFile(outputPath + ".osz", "w") as newDir:
    377 |         for root, dirs, files in os.walk(outputPath):
    378 |             for file in files:
    379 |                 newDir.write(os.path.join(root, file), file)
    380 |
    381 |
    382 |
    383 |
    384 |
    385 |
    386 | # 387 |
    388 |

    Delete all files in output dir

    389 |
    390 |
    391 |
        for root, dirs, files in os.walk(outputPath, topdown=False):
    392 |         for name in files:
    393 |             os.remove(os.path.join(root, name))
    394 |         for name in dirs:
    395 |             os.rmdir(os.path.join(root, name))
    396 | 
    397 |     os.rmdir(outputPath)
    398 |
    399 |
    400 |
    401 |
    402 |
    403 |
    404 | # 405 |
    406 |

    Main

    407 |
    408 |
    409 |
    410 |
    411 |
    412 |
    413 |
    414 |
    415 |
    416 | # 417 |
    418 |

    Runs the map file conversions

    419 |
    420 |
    421 |
    def main():
    422 |
    423 |
    424 |
    425 |
    426 |
    427 |
    428 | # 429 |
    430 |

    Run py qua2osu.py --help for help with command line arguments

    431 |
    432 |
    433 |
        argParser = initArgParser()
    434 |     args = vars(argParser.parse_args())
    435 |
    436 |
    437 |
    438 |
    439 |
    440 |
    441 | # 442 |
    443 |

    Run help command if no params

    444 |
    445 |
    446 |
        if len(args["input"]) == 0:
    447 |         argParser.parse_args(["-h"])
    448 |         sys.exit(1)
    449 | 
    450 |     print(args)
    451 |
    452 |
    453 |
    454 |
    455 |
    456 |
    457 | # 458 |
    459 |

    Filters for all files that end with .qp and puts the 460 | complete path of the files into an array

    461 |
    462 |
    463 |
        qpFilesInInputDir = []
    464 | 
    465 |     for path in args["input"]:
    466 |         if os.path.isfile(path) and path.endswith(".qp"):
    467 |             qpFilesInInputDir.append(path)
    468 |         elif os.path.isdir(path):
    469 |             searchForQpFiles(path, qpFilesInInputDir, args["recursive_search"])
    470 | 
    471 |     print(qpFilesInInputDir)
    472 | 
    473 |     if len(qpFilesInInputDir) == 0:
    474 |         print("No mapsets found in given paths")
    475 |         sys.exit(1)
    476 |
    477 |
    478 |
    479 |
    480 |
    481 |
    482 | # 483 |
    484 |

    Assigns the arguments to an options object to pass to 485 | the convertQp() function

    486 |
    487 |
    488 |
        options = {
    489 |         "od": args["overall_difficulty"],
    490 |         "hp": args["hp_drain"],
    491 |         "hitSoundVolume": args["hitsound_volume"],
    492 |         "sampleSet": args["sampleset"],
    493 |         "creator": args["creator"]
    494 |     }
    495 |
    496 |
    497 |
    498 |
    499 |
    500 |
    501 | # 502 |
    503 |

    Starts the timer for the total execution time

    504 |
    505 |
    506 |
        start = time.time()
    507 |
    508 |
    509 |
    510 |
    511 |
    512 |
    513 | # 514 |
    515 |

    Run the conversion for each .qp file

    516 |
    517 |
    518 |
        for file in qpFilesInInputDir:
    519 |         basePath = os.path.dirname(file) if args["preserve_folder_structure"] else ""
    520 | 
    521 |         outputPath = os.path.join(args["output"], basePath)
    522 |         if not os.path.exists(outputPath):
    523 |             os.mkdir(outputPath)
    524 | 
    525 |         print(f"Converting {file}")
    526 |         convertQp(file, outputPath, options)
    527 |
    528 |
    529 |
    530 |
    531 |
    532 |
    533 | # 534 |
    535 |

    Stops the timer for the total execution time

    536 |
    537 |
    538 |
        end = time.time()
    539 |     timeElapsed = round(end - start, 2)
    540 | 
    541 |     print(f"Finished converting all mapsets, total time elapsed: {timeElapsed} seconds")
    542 |
    543 |
    544 |
    545 |
    546 |
    547 |
    548 | # 549 |
    550 |

    Opens output folder in explorer

    551 |
    552 |
    553 |
        absoluteOutputPath = os.path.realpath(args["output"])
    554 |     webbrowser.open("file:///" + absoluteOutputPath)
    555 | 
    556 | 
    557 | if __name__ == '__main__':
    558 |     main()
    559 |     sys.exit(0)
    560 | 
    561 | 
    562 |
    563 |
    564 |
    565 |
    566 | 567 | -------------------------------------------------------------------------------- /gui/gui.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Form implementation generated from reading ui file 'gui/gui.ui' 4 | # 5 | # Created by: PyQt5 UI code generator 5.13.2 6 | # 7 | # WARNING! All changes made in this file will be lost! 8 | 9 | 10 | from PyQt5 import QtCore, QtGui, QtWidgets 11 | 12 | 13 | class Ui_MainWindow(object): 14 | def setupUi(self, MainWindow): 15 | MainWindow.setObjectName("MainWindow") 16 | MainWindow.setEnabled(True) 17 | MainWindow.resize(587, 193) 18 | sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) 19 | sizePolicy.setHorizontalStretch(0) 20 | sizePolicy.setVerticalStretch(0) 21 | sizePolicy.setHeightForWidth(MainWindow.sizePolicy().hasHeightForWidth()) 22 | MainWindow.setSizePolicy(sizePolicy) 23 | MainWindow.setMinimumSize(QtCore.QSize(0, 0)) 24 | MainWindow.setMaximumSize(QtCore.QSize(99999, 99999)) 25 | MainWindow.setFocusPolicy(QtCore.Qt.ClickFocus) 26 | MainWindow.setContextMenuPolicy(QtCore.Qt.NoContextMenu) 27 | MainWindow.setAcceptDrops(False) 28 | MainWindow.setTabShape(QtWidgets.QTabWidget.Rounded) 29 | self.centralwidget = QtWidgets.QWidget(MainWindow) 30 | self.centralwidget.setObjectName("centralwidget") 31 | self.gridLayout_2 = QtWidgets.QGridLayout(self.centralwidget) 32 | self.gridLayout_2.setObjectName("gridLayout_2") 33 | self.valuesGroupBox = QtWidgets.QGroupBox(self.centralwidget) 34 | self.valuesGroupBox.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignVCenter) 35 | self.valuesGroupBox.setObjectName("valuesGroupBox") 36 | self.verticalLayout = QtWidgets.QVBoxLayout(self.valuesGroupBox) 37 | self.verticalLayout.setObjectName("verticalLayout") 38 | self.valuesFormLayout = QtWidgets.QFormLayout() 39 | self.valuesFormLayout.setLabelAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) 40 | self.valuesFormLayout.setFormAlignment(QtCore.Qt.AlignCenter) 41 | self.valuesFormLayout.setObjectName("valuesFormLayout") 42 | self.label = QtWidgets.QLabel(self.valuesGroupBox) 43 | self.label.setObjectName("label") 44 | self.valuesFormLayout.setWidget(0, QtWidgets.QFormLayout.LabelRole, self.label) 45 | self.odDoubleSpinBox = QtWidgets.QDoubleSpinBox(self.valuesGroupBox) 46 | self.odDoubleSpinBox.setDecimals(1) 47 | self.odDoubleSpinBox.setMaximum(10.0) 48 | self.odDoubleSpinBox.setSingleStep(0.5) 49 | self.odDoubleSpinBox.setProperty("value", 8.0) 50 | self.odDoubleSpinBox.setObjectName("odDoubleSpinBox") 51 | self.valuesFormLayout.setWidget(0, QtWidgets.QFormLayout.FieldRole, self.odDoubleSpinBox) 52 | self.label_2 = QtWidgets.QLabel(self.valuesGroupBox) 53 | self.label_2.setObjectName("label_2") 54 | self.valuesFormLayout.setWidget(1, QtWidgets.QFormLayout.LabelRole, self.label_2) 55 | self.label_3 = QtWidgets.QLabel(self.valuesGroupBox) 56 | self.label_3.setObjectName("label_3") 57 | self.valuesFormLayout.setWidget(2, QtWidgets.QFormLayout.LabelRole, self.label_3) 58 | self.hsVolumeSpinBox = QtWidgets.QSpinBox(self.valuesGroupBox) 59 | self.hsVolumeSpinBox.setMaximum(100) 60 | self.hsVolumeSpinBox.setSingleStep(5) 61 | self.hsVolumeSpinBox.setProperty("value", 20) 62 | self.hsVolumeSpinBox.setObjectName("hsVolumeSpinBox") 63 | self.valuesFormLayout.setWidget(2, QtWidgets.QFormLayout.FieldRole, self.hsVolumeSpinBox) 64 | self.hpDoubleSpinBox = QtWidgets.QDoubleSpinBox(self.valuesGroupBox) 65 | self.hpDoubleSpinBox.setDecimals(1) 66 | self.hpDoubleSpinBox.setMaximum(10.0) 67 | self.hpDoubleSpinBox.setSingleStep(0.5) 68 | self.hpDoubleSpinBox.setProperty("value", 8.0) 69 | self.hpDoubleSpinBox.setObjectName("hpDoubleSpinBox") 70 | self.valuesFormLayout.setWidget(1, QtWidgets.QFormLayout.FieldRole, self.hpDoubleSpinBox) 71 | self.verticalLayout.addLayout(self.valuesFormLayout) 72 | self.gridLayout_2.addWidget(self.valuesGroupBox, 0, 1, 1, 1) 73 | self.sampleSetsGroupBox = QtWidgets.QGroupBox(self.centralwidget) 74 | self.sampleSetsGroupBox.setObjectName("sampleSetsGroupBox") 75 | self.verticalLayout_4 = QtWidgets.QVBoxLayout(self.sampleSetsGroupBox) 76 | self.verticalLayout_4.setObjectName("verticalLayout_4") 77 | self.sampleSetVLayout = QtWidgets.QVBoxLayout() 78 | self.sampleSetVLayout.setObjectName("sampleSetVLayout") 79 | self.sampleSetNormalRadioButton = QtWidgets.QRadioButton(self.sampleSetsGroupBox) 80 | self.sampleSetNormalRadioButton.setChecked(False) 81 | self.sampleSetNormalRadioButton.setObjectName("sampleSetNormalRadioButton") 82 | self.sampleSetVLayout.addWidget(self.sampleSetNormalRadioButton) 83 | self.sampleSetSoftRadioButton = QtWidgets.QRadioButton(self.sampleSetsGroupBox) 84 | self.sampleSetSoftRadioButton.setChecked(True) 85 | self.sampleSetSoftRadioButton.setObjectName("sampleSetSoftRadioButton") 86 | self.sampleSetVLayout.addWidget(self.sampleSetSoftRadioButton) 87 | self.sampleSetDrumRadioButton = QtWidgets.QRadioButton(self.sampleSetsGroupBox) 88 | self.sampleSetDrumRadioButton.setObjectName("sampleSetDrumRadioButton") 89 | self.sampleSetVLayout.addWidget(self.sampleSetDrumRadioButton) 90 | self.verticalLayout_4.addLayout(self.sampleSetVLayout) 91 | self.gridLayout_2.addWidget(self.sampleSetsGroupBox, 0, 2, 1, 1) 92 | self.pathsGroupBox = QtWidgets.QGroupBox(self.centralwidget) 93 | self.pathsGroupBox.setObjectName("pathsGroupBox") 94 | self.verticalLayout_2 = QtWidgets.QVBoxLayout(self.pathsGroupBox) 95 | self.verticalLayout_2.setObjectName("verticalLayout_2") 96 | self.pathsGridLayout = QtWidgets.QGridLayout() 97 | self.pathsGridLayout.setObjectName("pathsGridLayout") 98 | self.outputLineEdit = QtWidgets.QLineEdit(self.pathsGroupBox) 99 | self.outputLineEdit.setLocale(QtCore.QLocale(QtCore.QLocale.English, QtCore.QLocale.UnitedStates)) 100 | self.outputLineEdit.setObjectName("outputLineEdit") 101 | self.pathsGridLayout.addWidget(self.outputLineEdit, 1, 1, 1, 1) 102 | self.outputToolButton = QtWidgets.QToolButton(self.pathsGroupBox) 103 | self.outputToolButton.setObjectName("outputToolButton") 104 | self.pathsGridLayout.addWidget(self.outputToolButton, 1, 2, 1, 1) 105 | self.inputToolButton = QtWidgets.QToolButton(self.pathsGroupBox) 106 | self.inputToolButton.setPopupMode(QtWidgets.QToolButton.DelayedPopup) 107 | self.inputToolButton.setObjectName("inputToolButton") 108 | self.pathsGridLayout.addWidget(self.inputToolButton, 0, 2, 1, 1) 109 | self.inputLabel = QtWidgets.QLabel(self.pathsGroupBox) 110 | self.inputLabel.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) 111 | self.inputLabel.setObjectName("inputLabel") 112 | self.pathsGridLayout.addWidget(self.inputLabel, 0, 0, 1, 1) 113 | self.outputLabel = QtWidgets.QLabel(self.pathsGroupBox) 114 | self.outputLabel.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) 115 | self.outputLabel.setObjectName("outputLabel") 116 | self.pathsGridLayout.addWidget(self.outputLabel, 1, 0, 1, 1) 117 | self.inputLineEdit = QtWidgets.QLineEdit(self.pathsGroupBox) 118 | self.inputLineEdit.setLocale(QtCore.QLocale(QtCore.QLocale.English, QtCore.QLocale.UnitedStates)) 119 | self.inputLineEdit.setObjectName("inputLineEdit") 120 | self.pathsGridLayout.addWidget(self.inputLineEdit, 0, 1, 1, 1) 121 | self.verticalLayout_2.addLayout(self.pathsGridLayout) 122 | self.gridLayout_2.addWidget(self.pathsGroupBox, 0, 0, 1, 1) 123 | self.progressBar = QtWidgets.QProgressBar(self.centralwidget) 124 | self.progressBar.setProperty("value", 0) 125 | self.progressBar.setTextVisible(False) 126 | self.progressBar.setOrientation(QtCore.Qt.Horizontal) 127 | self.progressBar.setInvertedAppearance(False) 128 | self.progressBar.setObjectName("progressBar") 129 | self.gridLayout_2.addWidget(self.progressBar, 1, 0, 1, 2) 130 | self.convertPushButton = QtWidgets.QPushButton(self.centralwidget) 131 | self.convertPushButton.setFlat(False) 132 | self.convertPushButton.setObjectName("convertPushButton") 133 | self.gridLayout_2.addWidget(self.convertPushButton, 1, 2, 1, 1) 134 | self.statusLabel = QtWidgets.QLabel(self.centralwidget) 135 | self.statusLabel.setText("") 136 | self.statusLabel.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignTop) 137 | self.statusLabel.setWordWrap(True) 138 | self.statusLabel.setObjectName("statusLabel") 139 | self.gridLayout_2.addWidget(self.statusLabel, 2, 0, 1, 3) 140 | MainWindow.setCentralWidget(self.centralwidget) 141 | self.statusbar = QtWidgets.QStatusBar(MainWindow) 142 | self.statusbar.setObjectName("statusbar") 143 | MainWindow.setStatusBar(self.statusbar) 144 | self.label.setBuddy(self.odDoubleSpinBox) 145 | self.label_2.setBuddy(self.hpDoubleSpinBox) 146 | self.label_3.setBuddy(self.hsVolumeSpinBox) 147 | self.inputLabel.setBuddy(self.inputLineEdit) 148 | self.outputLabel.setBuddy(self.outputLineEdit) 149 | 150 | self.retranslateUi(MainWindow) 151 | QtCore.QMetaObject.connectSlotsByName(MainWindow) 152 | 153 | def retranslateUi(self, MainWindow): 154 | _translate = QtCore.QCoreApplication.translate 155 | MainWindow.setWindowTitle(_translate("MainWindow", "qua2osu")) 156 | self.valuesGroupBox.setTitle(_translate("MainWindow", "Values")) 157 | self.label.setText(_translate("MainWindow", "OD")) 158 | self.label_2.setText(_translate("MainWindow", "HP")) 159 | self.label_3.setText(_translate("MainWindow", "Hitsound Volume")) 160 | self.hsVolumeSpinBox.setSuffix(_translate("MainWindow", "%")) 161 | self.sampleSetsGroupBox.setTitle(_translate("MainWindow", "Sample Set")) 162 | self.sampleSetNormalRadioButton.setText(_translate("MainWindow", "Normal")) 163 | self.sampleSetSoftRadioButton.setText(_translate("MainWindow", "Soft")) 164 | self.sampleSetDrumRadioButton.setText(_translate("MainWindow", "Drum")) 165 | self.pathsGroupBox.setTitle(_translate("MainWindow", "Paths")) 166 | self.outputLineEdit.setPlaceholderText(_translate("MainWindow", "qua2osu\\output")) 167 | self.outputToolButton.setToolTip(_translate("MainWindow", "

    Open a file dialog to choose where your resulting .osz files should be put

    ")) 168 | self.outputToolButton.setText(_translate("MainWindow", "...")) 169 | self.inputToolButton.setToolTip(_translate("MainWindow", "

    Open a file dialog to select a folder of .qp files

    ")) 170 | self.inputToolButton.setText(_translate("MainWindow", "...")) 171 | self.inputLabel.setText(_translate("MainWindow", "Input Folder")) 172 | self.outputLabel.setText(_translate("MainWindow", "Output Folder")) 173 | self.inputLineEdit.setPlaceholderText(_translate("MainWindow", "qua2osu\\input")) 174 | self.convertPushButton.setText(_translate("MainWindow", "Convert")) 175 | 176 | 177 | if __name__ == "__main__": 178 | import sys 179 | app = QtWidgets.QApplication(sys.argv) 180 | MainWindow = QtWidgets.QMainWindow() 181 | ui = Ui_MainWindow() 182 | ui.setupUi(MainWindow) 183 | MainWindow.show() 184 | sys.exit(app.exec_()) 185 | -------------------------------------------------------------------------------- /gui/gui.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | MainWindow 4 | 5 | 6 | true 7 | 8 | 9 | 10 | 0 11 | 0 12 | 587 13 | 193 14 | 15 | 16 | 17 | 18 | 0 19 | 0 20 | 21 | 22 | 23 | 24 | 0 25 | 0 26 | 27 | 28 | 29 | 30 | 99999 31 | 99999 32 | 33 | 34 | 35 | Qt::ClickFocus 36 | 37 | 38 | Qt::NoContextMenu 39 | 40 | 41 | false 42 | 43 | 44 | qua2osu 45 | 46 | 47 | QTabWidget::Rounded 48 | 49 | 50 | 51 | 52 | 53 | 54 | Values 55 | 56 | 57 | Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter 58 | 59 | 60 | 61 | 62 | 63 | Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter 64 | 65 | 66 | Qt::AlignCenter 67 | 68 | 69 | 70 | 71 | OD 72 | 73 | 74 | odDoubleSpinBox 75 | 76 | 77 | 78 | 79 | 80 | 81 | 1 82 | 83 | 84 | 10.000000000000000 85 | 86 | 87 | 0.500000000000000 88 | 89 | 90 | 8.000000000000000 91 | 92 | 93 | 94 | 95 | 96 | 97 | HP 98 | 99 | 100 | hpDoubleSpinBox 101 | 102 | 103 | 104 | 105 | 106 | 107 | Hitsound Volume 108 | 109 | 110 | hsVolumeSpinBox 111 | 112 | 113 | 114 | 115 | 116 | 117 | % 118 | 119 | 120 | 100 121 | 122 | 123 | 5 124 | 125 | 126 | 20 127 | 128 | 129 | 130 | 131 | 132 | 133 | 1 134 | 135 | 136 | 10.000000000000000 137 | 138 | 139 | 0.500000000000000 140 | 141 | 142 | 8.000000000000000 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | Sample Set 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | Normal 163 | 164 | 165 | false 166 | 167 | 168 | 169 | 170 | 171 | 172 | Soft 173 | 174 | 175 | true 176 | 177 | 178 | 179 | 180 | 181 | 182 | Drum 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | Paths 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | qua2osu\output 206 | 207 | 208 | 209 | 210 | 211 | 212 | <html><head/><body><p>Open a file dialog to choose where your resulting .osz files should be put</p></body></html> 213 | 214 | 215 | ... 216 | 217 | 218 | 219 | 220 | 221 | 222 | <html><head/><body><p>Open a file dialog to select a folder of .qp files</p></body></html> 223 | 224 | 225 | ... 226 | 227 | 228 | QToolButton::DelayedPopup 229 | 230 | 231 | 232 | 233 | 234 | 235 | Input Folder 236 | 237 | 238 | Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter 239 | 240 | 241 | inputLineEdit 242 | 243 | 244 | 245 | 246 | 247 | 248 | Output Folder 249 | 250 | 251 | Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter 252 | 253 | 254 | outputLineEdit 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | qua2osu\input 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 0 277 | 278 | 279 | false 280 | 281 | 282 | Qt::Horizontal 283 | 284 | 285 | false 286 | 287 | 288 | 289 | 290 | 291 | 292 | Convert 293 | 294 | 295 | false 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop 306 | 307 | 308 | true 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | -------------------------------------------------------------------------------- /qua2osu-gui.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import time 4 | import webbrowser # to open the explorer cross-platform 5 | 6 | from PyQt5.QtCore import * 7 | from PyQt5.QtGui import * 8 | from PyQt5.QtWidgets import * 9 | 10 | from conversion import convertMapset 11 | 12 | # gui.py is autogenerated from gui.ui (qt designer) 13 | 14 | # use "pyuic5 -x gui/gui.ui -o gui/gui.py" after editing gui.ui with qt designer 15 | from gui.gui import Ui_MainWindow 16 | 17 | 18 | class IceMainWindow(QMainWindow, Ui_MainWindow): 19 | """Custom window class with all of the GUI functionality 20 | 21 | All of the objects themselves are managed by QT designer in the gui.ui (gui.py) file 22 | """ 23 | 24 | def __init__(self): 25 | QMainWindow.__init__(self) 26 | self.setupUi(self) 27 | self.inputToolButton.clicked.connect(self.openInputDirectoryDialog) 28 | self.outputToolButton.clicked.connect(self.openOutputDirectoryDialog) 29 | self.convertPushButton.clicked.connect(self.convertOnClick) 30 | 31 | self.updateStatus("Finished setup") 32 | 33 | def openInputDirectoryDialog(self): 34 | """Opens the input directory dialog and sets the input path in the GUI""" 35 | 36 | path = str(QFileDialog.getExistingDirectory( 37 | self, "Select Input Folder")) 38 | self.inputLineEdit.setText(path) 39 | self.updateStatus("Set input path to " + path) 40 | 41 | def openOutputDirectoryDialog(self): 42 | """Opens the output directory dialog and sets the input path in the GUI""" 43 | 44 | path = str(QFileDialog.getExistingDirectory( 45 | self, "Select Output Folder")) 46 | self.outputLineEdit.setText(path) 47 | self.updateStatus("Set output path to " + path) 48 | 49 | def updateStatus(self, status): 50 | """Wrapper for updating the status label, always shows the two last lines""" 51 | 52 | currentText = self.statusLabel.text() 53 | lastLine = currentText.split("\n")[-1] 54 | self.statusLabel.setText(lastLine + "\n" + status) 55 | 56 | def updateProgressBarMax(self, maximum): 57 | """Wrapper for updating the progress bar maximum""" 58 | 59 | self.progressBar.setMaximum(maximum) 60 | 61 | def incrementProgressBarValue(self): 62 | """Increments the progress bar by one unit total, adds a smooth animation""" 63 | 64 | self.progressBar.setValue(self.progressBar.value() + 1) 65 | 66 | def initiateConverterThread(self, inputPath, outputPath, options): 67 | """Sets up the converter thread""" 68 | 69 | converterThread = ConverterThread(inputPath, outputPath, options) 70 | converterThread.updateStatus.connect(self.updateStatus) 71 | converterThread.updateProgressbarMax.connect( 72 | self.updateProgressBarMax) 73 | converterThread.incrementProgressbarValue.connect( 74 | self.incrementProgressBarValue) 75 | 76 | return converterThread 77 | 78 | def convertOnClick(self): 79 | """Starts the map conversion of the folder""" 80 | 81 | inputPath = self.inputLineEdit.text() 82 | outputPath = self.outputLineEdit.text() 83 | 84 | if inputPath == "" or outputPath == "": 85 | self.updateStatus("Empty paths detected, using defaults") 86 | 87 | if inputPath == "": 88 | inputPath = "input" 89 | 90 | if outputPath == "": 91 | outputPath = "output" 92 | 93 | selectedOd = self.odDoubleSpinBox.value() 94 | selectedHp = self.hpDoubleSpinBox.value() 95 | selectedHitSoundVolume = self.hsVolumeSpinBox.value() 96 | 97 | # please tell me if there's an easier way to lookup 98 | # which radio button is checked because this is horrible 99 | selectedSampleSet = "" 100 | sampleSetButtons = [ 101 | self.sampleSetNormalRadioButton, 102 | self.sampleSetSoftRadioButton, 103 | self.sampleSetDrumRadioButton 104 | ] 105 | 106 | for button in sampleSetButtons: 107 | if button.isChecked(): 108 | selectedSampleSet = button.text() 109 | break 110 | 111 | options = { 112 | "od": selectedOd, 113 | "hp": selectedHp, 114 | "hitSoundVolume": selectedHitSoundVolume, 115 | "sampleSet": selectedSampleSet 116 | } 117 | 118 | self.progressBar.setValue(0) 119 | 120 | self.converterThread = self.initiateConverterThread( 121 | inputPath, outputPath, options) 122 | self.converterThread.start() 123 | 124 | 125 | class ConverterThread(QThread): 126 | """Using a different thread for the map conversion 127 | 128 | Otherwise the UI would freeze and become unresponsive during the conversion 129 | """ 130 | 131 | updateStatus = pyqtSignal(str) 132 | incrementProgressbarValue = pyqtSignal() 133 | updateProgressbarMax = pyqtSignal(int) 134 | 135 | def __init__(self, inputPath, outputPath, options): 136 | QThread.__init__(self) 137 | self.inputPath = inputPath 138 | self.outputPath = outputPath 139 | self.options = options 140 | 141 | def __del__(self): 142 | self.wait() 143 | 144 | def run(self): 145 | qpFilesInInputDir = [] 146 | 147 | for file in os.listdir(self.inputPath): 148 | path = os.path.join(self.inputPath, file) 149 | if file.endswith('.qp') and os.path.isfile(path): 150 | qpFilesInInputDir.append(file) 151 | 152 | numberOfQpFiles = len(qpFilesInInputDir) 153 | 154 | if numberOfQpFiles == 0: 155 | self.updateStatus.emit("No mapsets found in " + self.inputPath) 156 | return 157 | 158 | self.updateProgressbarMax.emit(numberOfQpFiles) 159 | 160 | start = time.time() 161 | count = 1 162 | 163 | for file in qpFilesInInputDir: 164 | filePath = os.path.join(self.inputPath, file) 165 | 166 | self.updateStatus.emit(f"({count}/{numberOfQpFiles}) " 167 | f"Converting {filePath}") 168 | 169 | convertMapset(filePath, self.outputPath, self.options) 170 | count += 1 171 | self.incrementProgressbarValue.emit() 172 | 173 | end = time.time() 174 | timeElapsed = round(end - start, 2) 175 | 176 | self.updateStatus.emit( 177 | f"Finished converting all mapsets," 178 | f"total time elapsed: {timeElapsed} seconds" 179 | ) 180 | 181 | # Opens output folder in explorer 182 | absoluteOutputPath = os.path.realpath(self.outputPath) 183 | webbrowser.open("file:///" + absoluteOutputPath) 184 | 185 | return 186 | 187 | 188 | class IceApp(QApplication): 189 | """Custom QApplication class for the sole purpose of applying the Fusion style""" 190 | 191 | def __init__(self): 192 | super().__init__(sys.argv) 193 | self.setStyle("Fusion") 194 | 195 | 196 | def main(): 197 | app = IceApp() 198 | iceApp = IceMainWindow() 199 | iceApp.show() 200 | sys.exit(app.exec_()) 201 | 202 | 203 | if __name__ == '__main__': 204 | main() 205 | -------------------------------------------------------------------------------- /qua2osu.py: -------------------------------------------------------------------------------- 1 | """Command-line tool for map conversion""" 2 | 3 | # ## Imports 4 | 5 | import argparse # parsing command line arguments 6 | import os # for paths and directories 7 | import re 8 | import sys # used only for sys.exit() 9 | import time # to measure execution time 10 | import webbrowser # to open the explorer cross-platform 11 | import zipfile # to handle .zip files (.qua and .osz) 12 | 13 | from reamber.algorithms.convert import QuaToOsu 14 | from reamber.quaver import QuaMap 15 | 16 | # ## Constants 17 | 18 | SAMPLESETS = [ 19 | "Soft", 20 | "Normal", 21 | "Drum" 22 | ] 23 | 24 | # ## Functions 25 | 26 | 27 | def initArgParser() -> argparse.ArgumentParser: 28 | """Creates an argument parser with all of the arguments already added""" 29 | 30 | argParser = argparse.ArgumentParser("Converts .qp files to .osz files") 31 | 32 | def qpOrDirPath(inputPath): 33 | if (inputPath.endswith(".qp") and os.path.isFile(inputPath)) or os.path.isdir(inputPath): 34 | return inputPath 35 | else: 36 | raise argparse.ArgumentTypeError("Path is not a directory or not a .qp file") 37 | 38 | argParser.add_argument( 39 | "input", 40 | help="Paths of directories containing .qp files, or direct paths to .qp files. Both are possible.", 41 | nargs="*", 42 | type=qpOrDirPath 43 | ) 44 | 45 | def directory(path): 46 | if os.path.isdir(path): 47 | return path 48 | raise argparse.ArgumentTypeError("Not a valid path") 49 | 50 | argParser.add_argument( 51 | "-o", 52 | "--output", 53 | required=False, 54 | help="Path of the output folder, defaults to ./output", 55 | default="./output", 56 | type=directory 57 | ) 58 | 59 | def diffValue(x): 60 | x = float(x) 61 | if x >= 0 and x <= 10: 62 | return x 63 | else: 64 | raise argparse.ArgumentTypeError("Value must be larger between 0 and 10") 65 | 66 | argParser.add_argument( 67 | "-od", 68 | "--overall-difficulty", 69 | required=False, 70 | help="Overall difficulty as an integer between 0 and 10, defaults to 8", 71 | default=8, 72 | type=diffValue 73 | ) 74 | 75 | argParser.add_argument( 76 | "-hp", 77 | "--hp-drain", 78 | required=False, 79 | help="HP drain as an integer between 0 and 10, defaults to 8", 80 | default=8, 81 | type=diffValue 82 | ) 83 | 84 | def hsVolume(n): 85 | n = int(n) 86 | if n >= 0 and n <= 100: 87 | return n 88 | else: 89 | raise argparse.ArgumentTypeError("Value must be between 0 and 100") 90 | 91 | argParser.add_argument( 92 | "-hv", 93 | "--hitsound-volume", 94 | required=False, 95 | help="Hitsound volume as an integer between 0 and 100, defaults to 20", 96 | default=20, 97 | type=hsVolume 98 | ) 99 | 100 | argParser.add_argument( 101 | "-hs", 102 | "--sampleset", 103 | required=False, 104 | help="Hitsound sample set as either 'Soft', 'Normal' or 'Drum', defaults to Soft", 105 | default="Soft", 106 | type=str, 107 | choices=SAMPLESETS 108 | ) 109 | 110 | argParser.add_argument( 111 | "-c", 112 | "--creator", 113 | required=False, 114 | help="Sets a different mapper name for all difficulties, in case Quaver username and osu! username are different", 115 | type=str 116 | ) 117 | 118 | argParser.add_argument( 119 | "-p", 120 | "--preserve-folder-structure", 121 | required=False, 122 | help="Outputs in original directory structure if specified", 123 | action="store_true" 124 | ) 125 | 126 | argParser.add_argument( 127 | "-r", 128 | "--recursive-search", 129 | required=False, 130 | help="Looks for .qp in all subdirectories of given directories if specified", 131 | action="store_true" 132 | ) 133 | 134 | return argParser 135 | 136 | 137 | def searchForQpFiles(directory: str, qpList: list, recursive: bool) -> list: 138 | for path in os.listdir(directory): 139 | fullRelativePath = os.path.join(directory, path) 140 | if os.path.isfile(fullRelativePath) and fullRelativePath.endswith(".qp"): 141 | qpList.append(os.path.normpath(fullRelativePath)) 142 | elif os.path.isdir(fullRelativePath) and recursive: 143 | searchForQpFiles(fullRelativePath, qpList, recursive) 144 | 145 | 146 | def convertQp(path: str, outputFolder: str, options) -> None: 147 | """Converts a whole .qp mapset to a .osz mapset 148 | 149 | Moves all files to a new directory and converts all .qua files to .osu files 150 | 151 | Options parameter is built up as following: 152 | 153 | options = { 154 | "od": int, 155 | "hp": int, 156 | "hitSoundVolume": int, 157 | "sampleSet": ["Soft","Normal","Drum"] 158 | } 159 | """ 160 | 161 | # Prefixing with "q_" to prevent osu from showing the wrong preview 162 | # backgrounds, because it takes the folder number to 163 | # choose the background for whatever reason 164 | folderName = "q_" + os.path.basename(path).replace(".qp", "") 165 | outputPath = os.path.join(outputFolder, folderName) 166 | 167 | # Opens the .qp (.zip) mapset file and extracts it into a folder in the same directory 168 | with zipfile.ZipFile(path, "r") as oldDir: 169 | oldDir.extractall(outputPath) 170 | 171 | # Converts each .qua difficulty file 172 | for file in os.listdir(outputPath): 173 | filePath = os.path.join(outputPath, file) 174 | 175 | # Replaces each .qua file with the converted .osu file, uses Evening's reamber package 176 | if file.endswith(".qua"): 177 | qua = QuaMap.readFile(filePath) 178 | convertedOsu = QuaToOsu.convert(qua) 179 | 180 | if options["od"]: 181 | convertedOsu.overallDifficulty = options["od"] 182 | if options["hp"]: 183 | convertedOsu.hpDrainRate = options["hp"] 184 | if options["creator"]: 185 | convertedOsu.creator = options["creator"] 186 | 187 | if options["hitSoundVolume"] or options["sampleset"]: 188 | for list in [convertedOsu.bpms.data(), convertedOsu.svs.data()]: 189 | for element in list: 190 | if options["hitSoundVolume"]: 191 | element.volume = options["hitSoundVolume"] 192 | if options["sampleSet"]: 193 | element.sampleSet = SAMPLESETS.index(options["sampleSet"]) 194 | 195 | newFileName = re.sub(r"\.qua$", ".osu", filePath, 1, re.MULTILINE) 196 | convertedOsu.writeFile(newFileName) 197 | os.remove(filePath) 198 | 199 | # Creates a new .osz (.zip) mapset file 200 | with zipfile.ZipFile(outputPath + ".osz", "w") as newDir: 201 | for root, dirs, files in os.walk(outputPath): 202 | for file in files: 203 | newDir.write(os.path.join(root, file), file) 204 | 205 | # Delete all files in output dir 206 | for root, dirs, files in os.walk(outputPath, topdown=False): 207 | for name in files: 208 | os.remove(os.path.join(root, name)) 209 | for name in dirs: 210 | os.rmdir(os.path.join(root, name)) 211 | 212 | os.rmdir(outputPath) 213 | 214 | # ### Main 215 | 216 | 217 | def main(): 218 | """Runs the map file conversions 219 | 220 | Run `py qua2osu.py --help` for help with command line arguments 221 | """ 222 | 223 | argParser = initArgParser() 224 | args = vars(argParser.parse_args()) 225 | 226 | # Run help command if no params 227 | if len(args["input"]) == 0: 228 | argParser.parse_args(["-h"]) 229 | sys.exit(1) 230 | 231 | print(args) 232 | 233 | # Filters for all files that end with .qp and puts the 234 | # complete path of the files into an array 235 | 236 | qpFilesInInputDir = [] 237 | 238 | for path in args["input"]: 239 | if os.path.isfile(path) and path.endswith(".qp"): 240 | qpFilesInInputDir.append(path) 241 | elif os.path.isdir(path): 242 | searchForQpFiles(path, qpFilesInInputDir, args["recursive_search"]) 243 | 244 | print(qpFilesInInputDir) 245 | 246 | if len(qpFilesInInputDir) == 0: 247 | print("No mapsets found in given paths") 248 | sys.exit(1) 249 | 250 | # Assigns the arguments to an options object to pass to 251 | # the `convertQp()` function 252 | options = { 253 | "od": args["overall_difficulty"], 254 | "hp": args["hp_drain"], 255 | "hitSoundVolume": args["hitsound_volume"], 256 | "sampleSet": args["sampleset"], 257 | "creator": args["creator"] 258 | } 259 | 260 | # Starts the timer for the total execution time 261 | start = time.time() 262 | 263 | # Run the conversion for each .qp file 264 | for file in qpFilesInInputDir: 265 | basePath = os.path.dirname(file) if args["preserve_folder_structure"] else "" 266 | 267 | outputPath = os.path.join(args["output"], basePath) 268 | if not os.path.exists(outputPath): 269 | os.mkdir(outputPath) 270 | 271 | print(f"Converting {file}") 272 | convertQp(file, outputPath, options) 273 | 274 | # Stops the timer for the total execution time 275 | end = time.time() 276 | timeElapsed = round(end - start, 2) 277 | 278 | print(f"Finished converting all mapsets, total time elapsed: {timeElapsed} seconds") 279 | 280 | # Opens output folder in explorer 281 | absoluteOutputPath = os.path.realpath(args["output"]) 282 | webbrowser.open("file:///" + absoluteOutputPath) 283 | 284 | 285 | if __name__ == '__main__': 286 | main() 287 | sys.exit(0) 288 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IceDynamix/qua2osu/380665b41af4ec9d41b7d4940d83766896785898/requirements.txt --------------------------------------------------------------------------------