├── src ├── core │ ├── exception.py │ ├── keys.py │ ├── log.py │ ├── singers.py │ ├── verify.py │ ├── engine.py │ ├── tracks.py │ └── projects.py ├── separate_tracks.py ├── different_singers.py └── multi_exports.py ├── LICENSE ├── README.md └── .gitignore /src/core/exception.py: -------------------------------------------------------------------------------- 1 | class AutoXStudioException(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /src/separate_tracks.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | sys.path.append('core') 4 | 5 | from core import engine, projects, tracks 6 | 7 | if __name__ == '__main__': 8 | path = r'..\demo\separate_tracks\assets\示例.svip' 9 | prefix = '示例' 10 | engine.start_xstudio(engine=r'C:\Users\YQ之神\AppData\Local\warp\packages\XStudioSinger_2.0.0_beta2.exe\XStudioSinger.exe', project=path) 11 | for track in tracks.enum_tracks()[::-1]: 12 | track.set_solo(True) 13 | projects.export_project(title=f'{prefix}_轨道{track.index}') 14 | engine.quit_xstudio() 15 | -------------------------------------------------------------------------------- /src/different_singers.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | sys.path.append('core') 4 | 5 | from core import engine, projects, tracks 6 | 7 | 8 | if __name__ == '__main__': 9 | # demo: 导出同一个工程文件的不同歌手演唱音频(公测版歌手不全无法使用,目前仅可运行在 2.0.0 beta2 版本上) 10 | singers = ['陈水若', '果妹'] # 需要导出的所有歌手 11 | path = r'..\泡沫.svip' # 工程文件存放路径 12 | prefix = '示例' # 各歌手演唱音频的公共前缀。本例中保存为“示例 - 陈水若.mp3”,“示例 - 果妹.mp3”等 13 | engine.start_xstudio(engine=r'C:\Users\YQ之神\AppData\Local\warp\packages\XStudioSinger_2.0.0_beta2.exe\XStudioSinger.exe', project=path) 14 | for s in singers: 15 | tracks.Track(1).switch_singer(singer=s) 16 | projects.export_project(title=f'{prefix} - {s}') 17 | engine.quit_xstudio() 18 | -------------------------------------------------------------------------------- /src/core/keys.py: -------------------------------------------------------------------------------- 1 | import uiautomation as auto 2 | 3 | 4 | def press_key_combination(*keys: int): 5 | for k in keys: 6 | auto.PressKey(k, waitTime=0) 7 | for k in keys[::-1]: 8 | auto.ReleaseKey(k, waitTime=0) 9 | 10 | 11 | def scroll_wheel_inside(target: auto.Control, bound: auto.Control): 12 | while True: 13 | if target.BoundingRectangle.top < bound.BoundingRectangle.top: 14 | bound.MoveCursorToMyCenter(simulateMove=False) 15 | auto.WheelUp(waitTime=0) 16 | elif target.BoundingRectangle.bottom > bound.BoundingRectangle.bottom: 17 | bound.MoveCursorToMyCenter(simulateMove=False) 18 | auto.WheelDown(waitTime=0) 19 | else: 20 | break 21 | -------------------------------------------------------------------------------- /src/core/log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import colorlog 4 | 5 | 6 | logger = logging.getLogger() 7 | log_colors_config = { 8 | 'DEBUG': 'white', 9 | 'INFO': 'green', 10 | 'WARNING': 'yellow', 11 | 'ERROR': 'red', 12 | 'CRITICAL': 'bold_red', 13 | } 14 | console_handler = logging.StreamHandler() 15 | logger.setLevel(logging.INFO) 16 | console_handler.setLevel(logging.INFO) 17 | console_formatter = colorlog.ColoredFormatter( 18 | fmt='%(log_color)s[%(asctime)s.%(msecs)03d] %(filename)s -> %(funcName)s line:%(lineno)d [%(levelname)s] : %(message)s', 19 | datefmt='%Y-%m-%d %H:%M:%S', 20 | log_colors=log_colors_config 21 | ) 22 | console_handler.setFormatter(console_formatter) 23 | if not logger.handlers: 24 | logger.addHandler(console_handler) 25 | console_handler.close() 26 | -------------------------------------------------------------------------------- /src/multi_exports.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | sys.path.append('core') 5 | 6 | from core import engine, projects 7 | 8 | 9 | if __name__ == '__main__': 10 | # demo: 导出某文件夹下所有的工程 11 | path = r'PATH_TO_PROJECTS' 12 | filelist = [] 13 | for name in os.listdir(path): 14 | if os.path.isfile(os.path.join(path, name)) and name.endswith('.svip'): 15 | filelist.append(name) 16 | num = len(filelist) 17 | if num > 0: 18 | engine.start_xstudio(os.path.join(path, filelist[0])) 19 | projects.export_project(format='wav', samplerate=48000) 20 | if num > 1: 21 | projects.open_project(filename=filelist[1], folder=path) 22 | projects.export_project(format='wav', samplerate=48000) 23 | for file in filelist[2:]: 24 | projects.open_project(filename=file) 25 | projects.export_project(format='wav', samplerate=48000) 26 | engine.quit_xstudio() 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 yqzhishen 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 | -------------------------------------------------------------------------------- /src/core/singers.py: -------------------------------------------------------------------------------- 1 | import uiautomation as auto 2 | 3 | from exception import AutoXStudioException 4 | import log 5 | 6 | logger = log.logger 7 | 8 | 9 | def choose_singer(name: str): 10 | """ 11 | 选择一名歌手。歌手市场必须处于打开状态。 12 | :param name: 歌手名字 13 | """ 14 | singer_market = auto.WindowControl(searchDepth=2, Name='歌手市场') 15 | singer_market.HyperlinkControl(searchDepth=9, Name='全部歌手').Click(simulateMove=False) 16 | browser_pane = singer_market.PaneControl(searchDepth=3, ClassName='CefBrowserWindow') 17 | bottom = browser_pane.BoundingRectangle.bottom 18 | while True: 19 | singer_text = browser_pane.TextControl(searchDepth=14, Name=name) 20 | bottom_text = browser_pane.TextControl(searchDepth=14, Name='已经到底了') 21 | if singer_text.Exists(maxSearchSeconds=0.1) and 0 < singer_text.BoundingRectangle.bottom < bottom: 22 | singer_text.Click(simulateMove=False) 23 | break 24 | elif bottom_text.Exists(maxSearchSeconds=0.1) and bottom_text.BoundingRectangle.bottom > 0: 25 | singer_market.ButtonControl(searchDepth=1, AutomationId='btnClose').Click(simulateMove=False) 26 | logger.error('指定的歌手“%s”不存在。' % name) 27 | raise AutoXStudioException() 28 | else: 29 | browser_pane.MoveCursorToMyCenter(simulateMove=False) 30 | auto.WheelDown(wheelTimes=2, waitTime=1) 31 | if singer_market.ButtonControl(searchDepth=17, Name='待解锁').Exists(maxSearchSeconds=0.5): 32 | singer_market.ImageControl(Depth=17).Click(simulateMove=False) 33 | logger.error('指定的歌手“%s”未解锁。' % name) 34 | raise AutoXStudioException() 35 | singer_market.ButtonControl(searchDepth=17, Name='选中').Click(simulateMove=False) 36 | -------------------------------------------------------------------------------- /src/core/verify.py: -------------------------------------------------------------------------------- 1 | import uiautomation as auto 2 | 3 | from exception import AutoXStudioException 4 | import log 5 | 6 | logger = log.logger 7 | 8 | 9 | def verify_startup(): 10 | """ 11 | 验证 X Studio 是否成功启动。 12 | """ 13 | warning_window = auto.WindowControl(searchDepth=1, Name='提示') 14 | if warning_window.Exists(maxSearchSeconds=3): 15 | warning = warning_window.TextControl(searchDepth=1, AutomationId='Tbx').Name 16 | warning_window.ButtonControl(searchDepth=1, AutomationId='OkBtn').Click(simulateMove=False) 17 | logger.error(warning) 18 | raise AutoXStudioException() 19 | 20 | 21 | def verify_opening(base): 22 | """ 23 | 验证工程是否成功打开。 24 | """ 25 | warning_window = base.WindowControl(searchDepth=1, ClassName='#32770') 26 | if warning_window.Exists(maxSearchSeconds=1): 27 | warning = warning_window.TextControl(searchDepth=2).Name.replace('\r\n', ' ').replace('。 ', '。') 28 | if warning.startswith('无法读取伴奏文件'): 29 | logger.warning('已自动忽略:无法读取伴奏文件。') 30 | while warning_window.Exists(maxSearchSeconds=1): 31 | warning_window.ButtonControl(searchDepth=1, Name='确定').Click(simulateMove=False) 32 | warning_window = auto.WindowControl(searchDepth=2, ClassName='#32770') 33 | elif '正在使用' in warning: 34 | warning_window.ButtonControl(searchDepth=2, Name='确定').Click(simulateMove=False) 35 | base.ButtonControl(searchDepth=1, Name='取消').Click(simulateMove=False) 36 | logger.error(warning) 37 | raise AutoXStudioException() 38 | else: 39 | warning_window.ButtonControl(searchDepth=1, Name='确定').Click(simulateMove=False) 40 | base.WindowControl(searchDepth=1, Name='X Studio').ButtonControl(searchDepth=1, AutomationId='OkBtn').Click(simulateMove=False) 41 | base.WindowControl(searchDepth=1, Name='X Studio').ButtonControl(searchDepth=1, AutomationId='btnClose').Click(simulateMove=False) 42 | logger.error(warning) 43 | raise AutoXStudioException() 44 | 45 | 46 | def verify_updates(): 47 | """ 48 | 验证 X Studio 更新,并关闭更新提示窗。 49 | """ 50 | update_window = auto.WindowControl(searchDepth=2, Name='检测到新版本') 51 | if update_window.Exists(maxSearchSeconds=3): 52 | update_window.ButtonControl(searchDepth=1, AutomationId='btnClose').Click(simulateMove=False) 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Auto X Studio 2 | 3 | X Studio · 歌手 UI 自动化 | UI Automation for X Studio Singer 4 | 5 | 6 | 7 | ## 项目简介与功能说明 | Introduction & Features 8 | 9 | 本项目基于 [Python-UIAutomation-for-Windows](https://github.com/yinkaisheng/Python-UIAutomation-for-Windows) 开发,用于自动控制 X Studio · 歌手软件完成若干常用操作。 10 | 11 | 功能大致包括: 12 | 13 | - 启动和退出 X Studio 14 | - 新建、打开、保存、导出工程 15 | - 为某条轨道切换歌手 16 | - 静音、独奏某条轨道 17 | 18 | 可能的应用场景: 19 | 20 | - 批量导出若干份工程 21 | - 分轨导出一个工程 22 | - 导出一个工程的若干版本 23 | - 批量编辑并另存为工程 24 | - **工程在线试听**(欢迎网站站长合作!) 25 | 26 | 欢迎加入仓库或提交 pull requests。若发现 bug 或有功能建议,欢迎在本仓库提交 issue,或[联系作者](https://space.bilibili.com/102844209)。 27 | 28 | 29 | 30 | ## 环境要求 | Requirements & Dependencies 31 | 32 | - Windows 7 及以上操作系统 33 | - X Studio · 歌手 2.0.0 及以上版本 34 | - Python 3(除 3.7.6 和 3.8.1 外) 35 | - 第三方模块:comtypes, typing, uiautomation, colorlog 36 | 37 | 38 | 39 | ## 使用方法 | How to Use 40 | 41 | 发行版中包含若干个 demo,安装好所需依赖后即可运行 main.py 查看效果。 42 | 43 | UI 自动化执行的成功与否受到系统流畅度等客观因素影响。本模块考虑了一些异常特殊情况的处理,但为了确保执行顺畅、无误,使用时请关闭不需要的应用以保证软件界面不卡顿,并保持网络畅通,防止出现窗口或组件未响应、卡加载、服务器忙碌等意外情况。 44 | 45 | 模块中定义的函数可供外部调用,可自由组合或添加其他操作(详见代码注释与参考资料)。若需借助本工程实现自定义的自动化流程,需将 src 文件夹复制到自己的项目中,随后可以 import 使用。 46 | 47 | 不会编程或需要定制自动化流程?在本仓库提交 issue,或[联系作者](https://space.bilibili.com/102844209)。 48 | 49 | 50 | 51 | ## 更新日志 | Update Log 52 | 53 | #### 1.0.0 (2022.03.04) 54 | 55 | > - 启动和退出 X Studio 56 | > - 新建、打开、保存、导出工程 57 | > - Demo - 批量导出某个文件夹下的所有工程 58 | 59 | #### 1.1.0 (2022.03.05) 60 | 61 | > - 打开或新建空白工程时,支持指定初始歌手 62 | > - 支持为某条轨道切换歌手 63 | > - 支持指定 X Studio 主程序目录(适用于多版本共存与内测版的情况) 64 | > - 新增若干特殊异常情况的处理 65 | > - 部分步骤采用快捷键,简化流程 66 | > - 缩短等待时间,提升运行速度 67 | > - Demo - 为同一份工程文件导出不同歌手的演唱音频 68 | 69 | #### 1.2.0 (2022.03.06) 70 | 71 | > - 修复音轨操作不能向上滚动的问题 72 | > - 切换歌手时将打印日志 73 | > - 重构部分代码,优化使用方式 74 | 75 | #### 1.3.0 (2022.03.06) 76 | 77 | > - 支持静音、取消静音、独奏、取消独奏 78 | > - 调整项目结构,优化部分代码 79 | > - Demo - 分轨导出一份工程文件 80 | 81 | #### 1.3.1 (2022.03.06) 82 | 83 | > - 解除了对第三方库 pywin32 的依赖 84 | 85 | #### 1.3.2 (2022.03.08) 86 | 87 | > - 对从未保存过的工程进行未指定路径和文件名的保存操作时,将产生警告 88 | > - 出现错误时,将抛出异常,而非终止程序 89 | 90 | 91 | 92 | ## 参考资料与相关链接 | References & Links 93 | 94 | - [Python-UIAutomation-for-Windows](https://github.com/yinkaisheng/Python-UIAutomation-for-Windows) 95 | - [UI Automation - Win32 apps | Microsoft Docs](https://docs.microsoft.com/en-us/windows/win32/winauto/entry-uiauto-win32) 96 | - [X Studio · 歌手 - 官方网站](https://singer.xiaoice.com/) 97 | - [作者主页 - YQ之神的个人空间](https://space.bilibili.com/102844209) 98 | - [X Studio · 歌手 - 视频教程](https://www.bilibili.com/video/BV1nk4y117AC) 99 | -------------------------------------------------------------------------------- /.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 | # PyCharm 59 | .idea/ 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # Others 135 | /automation.py 136 | test.py 137 | *.wav 138 | *.mp3 139 | *.mid 140 | *.svip 141 | @* 142 | GPUCache/ 143 | demo/ 144 | -------------------------------------------------------------------------------- /src/core/engine.py: -------------------------------------------------------------------------------- 1 | import os 2 | import winreg 3 | 4 | import uiautomation as auto 5 | 6 | from exception import AutoXStudioException 7 | import log 8 | import singers 9 | import verify 10 | 11 | logger = log.logger 12 | 13 | 14 | def find_xstudio() -> str: 15 | """ 16 | 根据注册表查找 X Studio 主程序路径。 17 | :return: XStudioSinger.exe 的路径 18 | """ 19 | key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, 'SOFTWARE\\Classes\\svipfile\\shell\\open\\command') 20 | value = winreg.QueryValueEx(key, '') 21 | return value[0].split('"')[1] 22 | 23 | 24 | def start_xstudio(engine: str = None, project: str = None, singer: str = '陈水若'): 25 | """ 26 | 启动 X Studio。 27 | :param engine: 手动指定 X Studio 主程序路径 28 | :param project: 启动时需要打开的工程文件路径,默认打开空白工程 29 | :param singer: 若打开空白工程,可指定初始歌手名称 30 | """ 31 | if engine: 32 | engine = os.path.abspath(engine) 33 | if not os.path.exists(engine): 34 | logger.error('指定的主程序路径不存在。') 35 | raise AutoXStudioException() 36 | if not os.path.isfile(engine) or not engine.endswith('.exe'): 37 | logger.error('指定的主程序不是合法的可执行 (.exe) 文件。') 38 | raise AutoXStudioException() 39 | logger.info('指定的主程序:%s。' % engine) 40 | if project: 41 | project = os.path.abspath(project) 42 | if not os.path.exists(project): 43 | logger.error('工程文件不存在。') 44 | raise AutoXStudioException() 45 | if not os.path.isfile(project) or not project.endswith('.svip'): 46 | logger.error('不是合法的 X Studio 工程 (.svip) 文件。') 47 | raise AutoXStudioException() 48 | if engine: 49 | os.popen(f'"{engine}" "{project}"') 50 | else: 51 | os.popen(f'"{project}"') 52 | verify.verify_startup() 53 | verify.verify_opening(auto) 54 | logger.info('启动 X Studio 并打开工程:%s。' % project) 55 | verify.verify_updates() 56 | else: 57 | if engine: 58 | os.popen(f'"{engine}"') 59 | else: 60 | os.popen(f'"{find_xstudio()}"') 61 | verify.verify_startup() 62 | auto.WindowControl(searchDepth=1, Name='X Studio').TextControl(searchDepth=2, Name='开始创作').Click(simulateMove=False) 63 | singers.choose_singer(name=singer) 64 | logger.info('启动 X Studio 并创建空白工程,初始歌手:%s。' % singer) 65 | verify.verify_updates() 66 | 67 | 68 | def quit_xstudio(): 69 | """ 70 | 退出 X Studio。 71 | """ 72 | auto.WindowControl(searchDepth=1, RegexName='X Studio .*').ButtonControl(searchDepth=1, AutomationId='btnClose').Click(simulateMove=False) 73 | confirm_window = auto.WindowControl(searchDepth=1, Name='X Studio') 74 | text = confirm_window.TextControl(searchDepth=1, AutomationId='Tbx').Name 75 | if text.startswith('确认'): 76 | confirm_window.ButtonControl(searchDepth=1, AutomationId='OkBtn').Click(simulateMove=False) 77 | else: 78 | confirm_window.ButtonControl(searchDepth=1, AutomationId='NoBtn').Click(simulateMove=False) 79 | logger.info('退出 X Studio。') 80 | 81 | 82 | if __name__ == '__main__': 83 | start_xstudio(singer='陈水若') 84 | -------------------------------------------------------------------------------- /src/core/tracks.py: -------------------------------------------------------------------------------- 1 | import uiautomation as auto 2 | 3 | from exception import AutoXStudioException 4 | import log 5 | import keys 6 | import singers 7 | 8 | logger = log.logger 9 | 10 | 11 | all_tracks = [] 12 | 13 | 14 | def enum_tracks() -> list: 15 | all_tracks.clear() 16 | index = 1 17 | track = Track(index) 18 | while track.exists(): 19 | all_tracks.append(track) 20 | index += 1 21 | track = Track(index) 22 | return all_tracks 23 | 24 | 25 | def count_tracks() -> int: 26 | return len(enum_tracks()) 27 | 28 | 29 | class Track: 30 | def __init__(self, index: int): 31 | if index < 1: 32 | logger.error('轨道编号最小为 1。') 33 | raise AutoXStudioException() 34 | self.index = index 35 | self.track_window = auto.WindowControl(searchDepth=1, RegexName='X Studio .*').CustomControl(searchDepth=1, ClassName='TrackWin') 36 | self.scroll_bound = self.track_window.PaneControl(searchDepth=1, ClassName='ScrollViewer') 37 | self.pane = self.track_window.CustomControl(searchDepth=2, foundIndex=self.index, ClassName='TrackChannelControlPanel') 38 | self.mute_button = self.pane.ButtonControl(searchDepth=1, Name='UnMute') 39 | self.unmute_button = self.pane.ButtonControl(searchDepth=1, Name='Mute') 40 | self.solo_button = self.pane.ButtonControl(searchDepth=1, Name='notSolo') 41 | self.notsolo_button = self.pane.ButtonControl(searchDepth=1, Name='Solo') 42 | self.switch_button = self.pane.ButtonControl(searchDepth=2, AutomationId='switchSingerButton') 43 | 44 | def exists(self) -> bool: 45 | return self.pane.Exists(maxSearchSeconds=0.5) 46 | 47 | def is_instrumental(self) -> bool: 48 | return self.pane.ComboBoxControl(searchDepth=1, ClassName='ComboBox').IsOffscreen 49 | 50 | def is_muted(self) -> bool: 51 | return self.unmute_button.Exists(maxSearchSeconds=0.5) 52 | 53 | def is_solo(self) -> bool: 54 | return self.notsolo_button.Exists(maxSearchSeconds=0.5) 55 | 56 | def set_muted(self, muted: bool): 57 | if muted and not self.is_muted(): 58 | keys.scroll_wheel_inside(target=self.mute_button, bound=self.scroll_bound) 59 | self.mute_button.Click(simulateMove=False) 60 | elif not muted and self.is_muted(): 61 | keys.scroll_wheel_inside(target=self.unmute_button, bound=self.scroll_bound) 62 | self.unmute_button.Click(simulateMove=False) 63 | if muted: 64 | logger.info('静音轨道 %d。' % self.index) 65 | else: 66 | logger.info('取消静音轨道 %d。' % self.index) 67 | 68 | def set_solo(self, solo: bool): 69 | if solo and not self.is_solo(): 70 | keys.scroll_wheel_inside(target=self.solo_button, bound=self.scroll_bound) 71 | self.solo_button.Click(simulateMove=False) 72 | elif not solo and self.is_solo(): 73 | keys.scroll_wheel_inside(target=self.notsolo_button, bound=self.scroll_bound) 74 | self.notsolo_button.Click(simulateMove=False) 75 | if solo: 76 | logger.info('独奏轨道 %d。' % self.index) 77 | else: 78 | logger.info('取消独奏轨道 %d。' % self.index) 79 | 80 | def switch_singer(self, singer: str): 81 | """ 82 | 切换歌手。 83 | :param singer: 歌手名字 84 | """ 85 | if not self.exists(): 86 | logger.error('未找到对应序号的轨道。') 87 | raise AutoXStudioException() 88 | if self.is_instrumental(): 89 | logger.error('指定的轨道不是演唱轨。') 90 | raise AutoXStudioException() 91 | keys.scroll_wheel_inside(target=self.switch_button, bound=self.scroll_bound) 92 | self.switch_button.DoubleClick(simulateMove=False) 93 | singers.choose_singer(name=singer) 94 | logger.info('为轨道 %d 切换歌手:%s。' % (self.index, singer)) 95 | -------------------------------------------------------------------------------- /src/core/projects.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import uiautomation as auto 4 | 5 | from exception import AutoXStudioException 6 | import keys 7 | import log 8 | import verify 9 | import singers 10 | 11 | logger = log.logger 12 | 13 | 14 | def new_project(singer: str = None): 15 | """ 16 | 新建工程。X Studio 必须已处于启动状态。 17 | :param singer: 可指定新工程的初始歌手 18 | """ 19 | keys.press_key_combination(auto.Keys.VK_CONTROL, auto.Keys.VK_N) 20 | confirm_window = auto.WindowControl(searchDepth=1, Name='X Studio') 21 | if confirm_window.Exists(maxSearchSeconds=1): 22 | confirm_window.ButtonControl(searchDepth=1, AutomationId='NoBtn').Click(simulateMove=False) 23 | if singer: 24 | track_window = auto.WindowControl(searchDepth=1, RegexName='X Studio .*').CustomControl(searchDepth=1, ClassName='TrackWin') 25 | track_window.CustomControl(searchDepth=2, ClassName='TrackChannelControlPanel').ButtonControl(searchDepth=2, AutomationId='switchSingerButton').DoubleClick(simulateMove=False) 26 | singers.choose_singer(singer) 27 | logger.info('创建新工程,初始歌手:%s。' % singer) 28 | else: 29 | logger.info('创建新工程。') 30 | 31 | 32 | def open_project(filename: str, folder: str = None): 33 | """ 34 | 打开工程。X Studio 必须已处于启动状态。 35 | :param filename: 工程文件名 36 | :param folder: 工程所处文件夹路径,默认为 X Studio 上一次打开工程的路径 37 | """ 38 | if folder: 39 | project = os.path.abspath(os.path.join(folder, filename)) 40 | if not os.path.exists(project): 41 | logger.error('工程文件不存在。') 42 | raise AutoXStudioException() 43 | else: 44 | project = filename 45 | if not filename.endswith('.svip'): 46 | logger.error('不是一个可打开的 X Studio 工程 (.svip) 文件。') 47 | raise AutoXStudioException() 48 | keys.press_key_combination(auto.Keys.VK_CONTROL, auto.Keys.VK_O) 49 | confirm_window = auto.WindowControl(searchDepth=1, Name='X Studio') 50 | if confirm_window.Exists(maxSearchSeconds=1): 51 | confirm_window.ButtonControl(searchDepth=1, AutomationId='NoBtn').Click(simulateMove=False) 52 | main_window = auto.WindowControl(searchDepth=1, RegexName='X Studio .*') 53 | open_window = main_window.WindowControl(searchDepth=1, Name='打开文件') 54 | open_window.EditControl(searchDepth=3, Name='文件名(N):').GetValuePattern().SetValue(project) 55 | auto.PressKey(auto.Keys.VK_ENTER, waitTime=0.1) 56 | warning_window = open_window.WindowControl(searchDepth=1, ClassName='#32770') 57 | if warning_window.Exists(maxSearchSeconds=1): 58 | warning = warning_window.TextControl(searchDepth=2).Name 59 | warning_window.ButtonControl(searchDepth=2, Name='确定').Click(simulateMove=False) 60 | open_window.ButtonControl(searchDepth=1, Name='取消').Click(simulateMove=False) 61 | logger.error(warning.replace('\r\n', ' ').replace('。 ', '。')) 62 | raise AutoXStudioException() 63 | verify.verify_opening(main_window) 64 | logger.info('打开工程:%s。' % project) 65 | 66 | 67 | def export_project(title: str = None, folder: str = None, format: str = 'mp3', samplerate: int = 48000): 68 | """ 69 | 导出当前打开的工程。 70 | :param title: 目标文件名,默认与工程同名 71 | :param folder: 目标文件夹路径,默认为工程所在文件夹 72 | :param format: 导出格式 (mp3/wav/midi),默认为 mp3 73 | :param samplerate: 采样率 (48000/44100),默认为 48000 74 | """ 75 | if format not in ['mp3', 'wav', 'midi']: 76 | logger.error('只能保存为 mp3, wav 或 midi 格式。') 77 | raise AutoXStudioException() 78 | if format == 'midi': 79 | samplerate = None 80 | elif samplerate != 48000 and samplerate != 44100: 81 | logger.error('采样率只能为 48000 或 44100。') 82 | raise AutoXStudioException() 83 | if folder and not os.path.exists(folder): 84 | folder = folder.replace('/', '\\') 85 | os.makedirs(folder) 86 | auto.ButtonControl(searchDepth=2, Name='导出').Click(simulateMove=False) 87 | setting_window = auto.WindowControl(searchDepth=2, Name='导出设置') 88 | if title: 89 | setting_window.EditControl(searchDepth=1, AutomationId='FileNameTbx').GetValuePattern().SetValue(title) 90 | else: 91 | title = setting_window.EditControl(searchDepth=1, AutomationId='FileNameTbx').GetValuePattern().Value 92 | if folder: 93 | logger.warning('当前尚不支持指定导出文件夹路径。') 94 | setting_window.EditControl(searchDepth=1, AutomationId='DestTbx').SendKeys(folder, interval=0.05) 95 | if format != 'mp3': 96 | format_box = setting_window.ComboBoxControl(searchDepth=1, AutomationId='FormatComboBox') 97 | format_box.Click(simulateMove=False) 98 | if format == 'wav': 99 | format_box.ListItemControl(searchDepth=1, Name='WAVE文件').Click(simulateMove=False) 100 | else: 101 | format_box.ListItemControl(searchDepth=1, Name='Midi文件').Click(simulateMove=False) 102 | if samplerate == 44100: 103 | samplerate_box = setting_window.ComboBoxControl(searchDepth=1, AutomationId='SampleRateComboBox') 104 | samplerate_box.Click(simulateMove=False) 105 | samplerate_box.ListItemControl(searchDepth=1, Name='44100HZ').Click(simulateMove=False) 106 | setting_window.ButtonControl(searchDepth=1, Name='导出').Click(simulateMove=False) 107 | export_window = auto.WindowControl(searchDepth=2, RegexName='导出.*') 108 | label = export_window.TextControl(searchDepth=1, ClassName='TextBlock', AutomationId='label') 109 | while True: 110 | message_window = auto.WindowControl(searchDepth=2, ClassName='#32770') 111 | if message_window.Exists(maxSearchSeconds=1): 112 | message = message_window.TextControl(searchDepth=1, ClassName='Static').Name 113 | message_window.ButtonControl(searchDepth=1, Name='确定').Click(simulateMove=False) 114 | logger.error(message + '。') 115 | raise AutoXStudioException() 116 | if label.Name == '导出成功': 117 | break 118 | elif label.Name.startswith('导出失败'): 119 | logger.error('导出失败,请稍后再试。') 120 | raise AutoXStudioException() 121 | export_window.ButtonControl(searchDepth=1, AutomationId='okBtn').Click(simulateMove=False) 122 | logger.info('导出工程:%s, 格式 %s, 采样率 %d Hz。' % (title, format, samplerate)) 123 | 124 | 125 | def save_project(filename: str = None, folder: str = None): 126 | """ 127 | 保存或另存为当前打开的工程。 128 | :param filename: 另存为的工程文件名 129 | :param folder: 另存为的文件夹路径,默认为工程所在文件夹 130 | """ 131 | if folder: 132 | if not filename: 133 | logger.error('另存为工程时必须指定文件名。') 134 | raise AutoXStudioException() 135 | if not os.path.exists(folder): 136 | os.makedirs(folder) 137 | folder = os.path.abspath(folder.replace('/', '\\')) 138 | if not filename: 139 | keys.press_key_combination(auto.Keys.VK_CONTROL, auto.Keys.VK_S) 140 | save_window = auto.WindowControl(Depth=2, Name='另存为') 141 | if save_window.Exists(maxSearchSeconds=0.5): 142 | save_window.ButtonControl(searchDepth=1, Name='取消').Click(simulateMove=False) 143 | logger.warning('当前工程从未保存过,请指定另存为路径后再试。') 144 | else: 145 | logger.info('保存工程。') 146 | else: 147 | if folder: 148 | project = os.path.join(folder, filename) 149 | else: 150 | project = filename 151 | keys.press_key_combination(auto.Keys.VK_CONTROL, auto.Keys.VK_SHIFT, auto.Keys.VK_S) 152 | save_window = auto.WindowControl(searchDepth=1, RegexName='X Studio .*').WindowControl(searchDepth=1, Name='另存为') 153 | save_window.EditControl(searchDepth=6, Name='文件名:').GetValuePattern().SetValue(project) 154 | save_window.ButtonControl(searchDepth=1, Name='保存(S)').Click(simulateMove=False) 155 | confirm_window = save_window.WindowControl(searchDepth=1, ClassName='#32770') 156 | if confirm_window.Exists(maxSearchSeconds=1): 157 | warning = confirm_window.TextControl(searchDepth=2).Name 158 | if warning.endswith('是否替换它?'): 159 | confirm_window.ButtonControl(searchDepth=1, Name='是(Y)').Click(simulateMove=False) 160 | else: 161 | confirm_window.ButtonControl(searchDepth=2, Name='确定').Click(simulateMove=False) 162 | save_window.ButtonControl(searchDepth=1, Name='取消').Click(simulateMove=False) 163 | logger.error(warning.replace('\r\n', ' ').replace('。 ', '。')) 164 | raise AutoXStudioException() 165 | logger.info('另存为工程:%s。' % project) 166 | --------------------------------------------------------------------------------